Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Як побудувати GraphQL API з NestJS?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**GraphQL API з NestJS** будується через `@nestjs/graphql` з Apollo driver - TypeScript-декоратори описують схему, резолвери обробляють запити та мутації. ```typescript GraphQLModule.forRoot<ApolloDriverConfig>({ driver: ApolloDriver, autoSchemaFile: 'schema.gql', }) ``` **Головне:** DataLoader у request-scoped провайдері вирішує проблему N+1 на вкладених полях.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**GraphQL API з NestJS** - це інтеграція на базі Apollo, де TypeScript-декоратори описують схему, резолвери (resolvers) обробляють запити та мутації, а фреймворк сам підключає всі частини. ## Теорія ### TL;DR - Встановлюєш `@nestjs/graphql`, `@nestjs/apollo`, `@apollo/server` і `graphql` - Code-first: пишеш TypeScript-класи з декораторами, схема генерується автоматично при старті - Schema-first: пишеш SDL-файл вручну, NestJS генерує TypeScript-типи з нього - Резолвери замінюють контролери - `@Query`, `@Mutation`, `@ResolveField` замість HTTP-методів - DataLoader вирішує проблему N+1 на вкладених полях; без нього вкладені зв'язки вб'ють продуктивність ### Code-first vs schema-first При code-first підході TypeScript-класи й є схемою. NestJS читає декоратори і записує `schema.gql` при старті. При schema-first ти сам пишеш SDL-файл, а NestJS генерує з нього відповідні TypeScript-типи. Більшість команд обирають code-first, бо TypeScript-типи та GraphQL-схема синхронізуються без зайвих кроків. Schema-first підходить, коли схема - це контракт між командами, або коли один пакет схеми використовується в кількох сервісах. ### Налаштування ```bash npm install @nestjs/graphql @nestjs/apollo @apollo/server graphql ``` ```typescript // app.module.ts import { GraphQLModule } from '@nestjs/graphql'; import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; @Module({ imports: [ GraphQLModule.forRoot<ApolloDriverConfig>({ driver: ApolloDriver, autoSchemaFile: 'schema.gql', // сканує декоратори, пише схему при старті sortSchema: true, playground: true, // вимикай у продакшені }), ], }) export class AppModule {} ``` Якщо файл на диску не потрібен - передай `autoSchemaFile: true`. Схема генерується в пам'яті. ### Об'єктні типи та вхідні типи Кожен тип, який повертає запит, потребує `@ObjectType()`. Кожне поле потребує `@Field()`. NestJS не може вивести з TypeScript масиви чи GraphQL-скаляри на кшталт `ID` і `Int`, тому їх передають явно першим аргументом. ```typescript import { ObjectType, Field, ID, Int } from '@nestjs/graphql'; @ObjectType() export class User { @Field(() => ID) id: string; @Field() name: string; @Field(() => Int) age: number; @Field(() => [Post], { nullable: true }) posts?: Post[]; } ``` Для мутацій використовуй `@InputType()`. Часткову версію генерує `PartialType` - але імпортуй його з `@nestjs/graphql`, а не з `@nestjs/mapped-types`. Версія з mapped-types не додає `@Field()` до згенерованих полів. ```typescript import { InputType, Field, PartialType } from '@nestjs/graphql'; @InputType() export class CreateUserInput { @Field() name: string; @Field() email: string; @Field(() => Int) age: number; } @InputType() export class UpdateUserInput extends PartialType(CreateUserInput) {} ``` ### Резолвери Резолвер - це місце, де бізнес-логіка підключається до GraphQL. `@Resolver(() => User)` прив'язує клас до типу `User`, що важливо для `@ResolveField`. Без цього field resolver не знає, до якого типу він належить. ```typescript import { Resolver, Query, Mutation, Args, ResolveField, Parent } from '@nestjs/graphql'; @Resolver(() => User) export class UsersResolver { constructor( private usersService: UsersService, private postsService: PostsService, ) {} @Query(() => [User], { name: 'users' }) findAll() { return this.usersService.findAll(); } @Query(() => User, { name: 'user' }) findOne(@Args('id', { type: () => ID }) id: string) { return this.usersService.findOne(id); } @Mutation(() => User) createUser(@Args('input') input: CreateUserInput) { return this.usersService.create(input); } @Mutation(() => Boolean) deleteUser(@Args('id', { type: () => ID }) id: string) { return this.usersService.delete(id); } @ResolveField(() => [Post]) posts(@Parent() user: User) { return this.postsService.findByUserId(user.id); // виконується для кожного User у відповіді } } ``` `@ResolveField` спрацьовує для кожного об'єкта в результаті. Повернеш 50 користувачів - отримаєш 50 окремих звернень до `findByUserId`. Це і є N+1. ### Аутентифікація Guard-и NestJS працюють у GraphQL з однією зміною: потрібно діставати запит із контексту виконання GraphQL, а не HTTP. ```typescript import { ExecutionContext, Injectable } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { GqlExecutionContext } from '@nestjs/graphql'; @Injectable() export class GqlAuthGuard extends AuthGuard('jwt') { getRequest(context: ExecutionContext) { const ctx = GqlExecutionContext.create(context); return ctx.getContext().req; // читає HTTP-запит із GraphQL-контексту } } // У резолвері @Query(() => User) @UseGuards(GqlAuthGuard) me(@CurrentUser() user: User) { return user; } ``` Без перевизначення `getRequest` JWT-стратегія читає з HTTP-контексту, який у GraphQL повертає нічого. Guard проходить без помилки, але токен ніколи не перевіряється. ### Підписки (subscriptions) Підписки (subscriptions) використовують WebSocket. Їх потрібно увімкнути в конфігурації модуля і налаштувати `PubSub`-інстанс для передачі подій між мутаціями і підписниками. ```typescript // Увімкнення у модулі GraphQLModule.forRoot<ApolloDriverConfig>({ driver: ApolloDriver, autoSchemaFile: 'schema.gql', subscriptions: { 'graphql-ws': true, // сучасний протокол; 'subscriptions-transport-ws' - застарілий }, }) // У резолвері @Subscription(() => Message, { filter: (payload, variables) => payload.messageAdded.roomId === variables.roomId, }) messageAdded(@Args('roomId') roomId: string) { return pubSub.asyncIterator('messageAdded'); } @Mutation(() => Message) async sendMessage(@Args('input') input: SendMessageInput) { const message = await this.messagesService.create(input); pubSub.publish('messageAdded', { messageAdded: message }); return message; } ``` Для продакшену замінюй вбудований `PubSub` з `graphql-subscriptions` на Redis-версію (`graphql-redis-subscriptions`). In-memory варіант не працює при горизонтальному масштабуванні - події з одного сервера не потраплять до клієнтів, підключених до іншого. ### DataLoader і проблема N+1 DataLoader збирає всі окремі виклики `.load(id)` за один тік події і передає їх в одну batch-функцію. Замість 50 запитів до бази - один. ```typescript import DataLoader from 'dataloader'; import { Injectable, Scope } from '@nestjs/common'; @Injectable({ scope: Scope.REQUEST }) // обов'язково request-scoped, не singleton export class PostsLoader { constructor(private postsService: PostsService) {} readonly batchPosts = new DataLoader<string, Post[]>( async (userIds: string[]) => { const posts = await this.postsService.findByUserIds([...userIds]); // результати мають бути в тому ж порядку, що й вхідні id - це вимога DataLoader return userIds.map((id) => posts.filter((p) => p.userId === id)); }, ); } // У резолвері - замінюємо пряме звернення до сервісу на лоадер @ResolveField(() => [Post]) posts(@Parent() user: User) { return this.postsLoader.batchPosts.load(user.id); } ``` `scope: Scope.REQUEST` - обов'язковий. Singleton DataLoader живе весь час роботи сервера і кешує дані між запитами різних користувачів. Це пряма вразливість: юзер A може побачити дані юзера B. ### Типові помилки **`PartialType` з неправильного пакету.** ```typescript // Неправильно - не додає @Field() до згенерованих полів import { PartialType } from '@nestjs/mapped-types'; // Правильно - зберігає @Field() для генерації GraphQL-схеми import { PartialType } from '@nestjs/graphql'; ``` **DataLoader без `scope: Scope.REQUEST`.** ```typescript // Неправильно - кешує дані між усіма запитами @Injectable() export class PostsLoader { ... } // Правильно - скидає кеш при кожному запиті @Injectable({ scope: Scope.REQUEST }) export class PostsLoader { ... } ``` **Відсутність `getRequest` у auth guard.** Guard компілюється і запускається, але читає не з того контексту. Жодної помилки не виникає. Токен просто не перевіряється. **`playground: true` у продакшені.** Playground відкриває повну схему всім охочим без будь-якого захисту. **In-memory `PubSub` при горизонтальному масштабуванні.** Підписки перестають працювати, щойно з'являється другий інстанс сервера. ### Де зустрічається - Продукти з веб і мобільними клієнтами, які потребують різних наборів даних - найсильніший кейс для GraphQL - `@ResolveField` + DataLoader покриває глибоку вкладеність: User -> Posts -> Comments -> Likes - Підписки підходять для чату, сповіщень, live-дашбордів - Schema-first добре працює в монорепо, де схема ділиться з інструментами генерації коду на фронті (GraphQL Code Generator) - Параметр `name` у `@Query(() => User, { name: 'user' })` задає назву операції в схемі - без нього NestJS бере назву методу ### Follow-up питання **Q:** Яка різниця між `@Query` і `@ResolveField` у NestJS? **A:** `@Query` визначає операцію верхнього рівня на типі Query схеми - виконується один раз на запит. `@ResolveField` визначає резолвер конкретного поля для певного типу - виконується для кожного об'єкта в результаті, саме тому там і виникає N+1. **Q:** Чому DataLoader потребує `scope: Scope.REQUEST`? **A:** DataLoader кешує результати за ключем протягом усього свого життя. Singleton-лоадер живе весь час роботи сервера, тому кеш від одного запиту потрапляє в наступний. Request scope скидає кеш з кожним запитом. **Q:** Чим відрізняється аутентифікація в GraphQL від REST у NestJS? **A:** JWT-стратегія однакова. Різниця - у тому, звідки guard читає запит. У REST - `context.switchToHttp().getRequest()`. У GraphQL потрібен `GqlExecutionContext.create(context).getContext().req`, бо контекст виконання має іншу форму всередині Apollo. **Q:** Коли обирати schema-first замість code-first? **A:** Schema-first підходить, коли GraphQL-схема - це договір між командами, зафіксований до реалізації. Якщо фронтенд і бекенд команди погоджують схему заздалегідь, schema-first робить цей договір явним файлом. Code-first краще, коли одна команда контролює обидві сторони і хоче TypeScript як єдине джерело правди. **Q:** Запит повертає 100 користувачів із полем `posts`. Скільки запитів до бази без DataLoader і зі ним? **A:** Без DataLoader: 101 запит (1 для користувачів, 100 для постів). З DataLoader: 2 запити (1 для користувачів, 1 batch-запит з усіма 100 ID). DataLoader збирає всі `.load(id)` за один тік, потім робить один виклик batch-функції. ## Приклади ### Базовий резолвер із запитом і мутацією Мінімальне підключення для отримання робочого GraphQL-ендпоінту з двома операціями. ```typescript // user.type.ts import { ObjectType, Field, ID } from '@nestjs/graphql'; @ObjectType() export class User { @Field(() => ID) id: string; @Field() name: string; @Field() email: string; } // create-user.input.ts import { InputType, Field } from '@nestjs/graphql'; @InputType() export class CreateUserInput { @Field() name: string; @Field() email: string; } // users.resolver.ts @Resolver(() => User) export class UsersResolver { constructor(private usersService: UsersService) {} @Query(() => [User], { name: 'users' }) findAll() { return this.usersService.findAll(); } @Mutation(() => User) createUser(@Args('input') input: CreateUserInput) { return this.usersService.create(input); } } ``` Після старту відкрий `http://localhost:3000/graphql` і виконай `query { users { id name email } }`. Схема вже згенерована з декораторів при запуску. ### Вкладені поля з DataLoader Цей патерн відрізняє GraphQL API, що просто працює, від того, що витримує навантаження. ```typescript // posts.loader.ts @Injectable({ scope: Scope.REQUEST }) export class PostsLoader { constructor(private postsService: PostsService) {} readonly batchPosts = new DataLoader<string, Post[]>( async (userIds: string[]) => { const posts = await this.postsService.findByUserIds([...userIds]); return userIds.map((id) => posts.filter((p) => p.userId === id)); }, ); } // users.resolver.ts - додаємо до існуючого резолвера @ResolveField(() => [Post]) posts(@Parent() user: User) { return this.postsLoader.batchPosts.load(user.id); } ``` `query { users { name posts { title } } }` тепер виконує рівно два запити до бази, незалежно від кількості користувачів. Я бачив, як цей патерн на реальному продукті знижував час відповіді з 400мс до 40мс - це найвища за впливом оптимізація в будь-якому NestJS GraphQL проекті. ### Захищена мутація з JWT guard ```typescript // gql-auth.guard.ts @Injectable() export class GqlAuthGuard extends AuthGuard('jwt') { getRequest(context: ExecutionContext) { const ctx = GqlExecutionContext.create(context); return ctx.getContext().req; } } // users.resolver.ts @Mutation(() => User) @UseGuards(GqlAuthGuard) updateProfile( @CurrentUser() currentUser: User, @Args('input') input: UpdateUserInput, ) { return this.usersService.update(currentUser.id, input); } ``` Клієнт передає `Authorization: Bearer <token>` у HTTP-заголовку. Guard читає його через міст GraphQL-контексту, валідує JWT, а `@CurrentUser()` витягує розкодованого користувача з об'єкта запиту. Мутація виконується тільки якщо токен валідний.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.