السلسلة: هذا الدرس جزء من سلسلة NestJS 11. اقرأ المقال الرئيسي.
GraphQL في وضع code-first يبقى النهج الأكثر إنتاجية لعرض API يستهلكه front Next.js أو React Native. أنواع TypeScript المكتوبة مرة واحدة تخدم منطق الأعمال وschema SDL المعروض للعملاء، ما يلغي التكرار. هذا الدرس يبني module GraphQL كاملاً تحت NestJS 11.1 مع Apollo: entities موسومة، resolvers typés، dataloaders لحل N+1، اشتراكات آنية عبر graphql-ws، وحراسة تفويض.
المتطلبات
- API NestJS 11 شغّال مع Prisma 7
- مصادقة JWT في المكان
- أساسيات GraphQL: queries، mutations، types
- 80 دقيقة
الخطوة 1 — تثبيت @nestjs/graphql وApollo Server
cd apps/api
pnpm add @nestjs/graphql @nestjs/apollo @apollo/server graphql graphql-ws ws
graphql-ws هو نقل WebSocket الحديث للاشتراكات، الذي استبدل subscriptions-transport-ws المهجور منذ 2023.
الخطوة 2 — ضبط GraphQLModule في وضع code-first
// app.module.ts (مقتطف)
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), 'apps/api/src/schema.gql'),
sortSchema: true,
playground: false,
plugins: [ApolloServerPluginLandingPageLocalDefault()],
context: ({ req }) => ({ req }),
subscriptions: { 'graphql-ws': true },
})
sortSchema: true يُثبّت ترتيب الأنواع في SDL، ما يجعل diffs git مقروءة. السياق يكشف req لـ resolvers، ما يعطيها وصولاً للمستخدم المُصادَق.
الخطوة 3 — تعريف الأنواع بـ decorators
// users/user.entity.ts
import { ObjectType, Field, ID, registerEnumType } from '@nestjs/graphql';
import { Role } from '@prisma/client';
registerEnumType(Role, { name: 'Role' });
@ObjectType()
export class User {
@Field(() => ID) id: string;
@Field() email: string;
@Field(() => Role) role: Role;
@Field() createdAt: Date;
}
كلمة السرّ غائبة عمداً من @Field: لا حقل غير موسوم يتسرّب في schema المعروض. حماية opt-in جودة جوهرية لـ code-first مقابل schema-first.
الخطوة 4 — كتابة resolver لـ query
// users/users.resolver.ts
@Resolver(() => User)
export class UsersResolver {
constructor(private prisma: PrismaService) {}
@Query(() => User, { nullable: true })
async user(@Args('id', { type: () => ID }) id: string) {
return this.prisma.user.findUnique({ where: { id } });
}
@Query(() => [User])
async users() {
return this.prisma.user.findMany();
}
}
الخطوة 5 — Mutations وinput types
// users/dto/create-user.input.ts
@InputType()
export class CreateUserInput {
@Field() @IsEmail() email: string;
@Field() @MinLength(12) password: string;
@Field(() => Role, { defaultValue: 'MEMBER' }) role: Role;
}
@Mutation(() => User)
async createUser(@Args('input') input: CreateUserInput) {
return this.prisma.user.create({
data: { ...input, password: await argon2.hash(input.password) },
});
}
الـ pipe ValidationPipe العامة تعترض كل input وتطبّق قيود class-validator. email غير صالح أو كلمة سرّ قصيرة جداً تُرجع خطأ GraphQL مهيكل.
الخطوة 6 — حل N+1 بـ DataLoader
أسوأ فخّ GraphQL: query تطلب 100 مستخدم مع طلباتهم تُطلق ساذجاً 1 + 100 استعلام SQL. الحلّ DataLoader، يجمع IDs المطلوبة في نفس tick حلقة الأحداث ويُجمّعها في استعلام واحد WHERE userId IN (...).
// users/users.loader.ts
@Injectable({ scope: Scope.REQUEST })
export class OrdersByUserLoader {
constructor(private prisma: PrismaService) {}
loader = new DataLoader<string, Order[]>(async (userIds) => {
const orders = await this.prisma.order.findMany({
where: { userId: { in: userIds as string[] } },
});
return userIds.map((id) => orders.filter((o) => o.userId === id));
});
}
scope REQUEST يضمن أن loader لا يخلط البيانات بين طلبين متزامنين. على query تطلب 100 مستخدم وطلباتهم، عدد استعلامات SQL ينتقل من 101 إلى 2.
الخطوة 7 — Subscriptions آنية مع graphql-ws
// orders/orders.resolver.ts
@Subscription(() => Order, { name: 'orderCreated' })
orderCreated() { return this.pubSub.asyncIterator('orderCreated'); }
@Mutation(() => Order)
async createOrder(@Args('input') input: CreateOrderInput) {
const order = await this.prisma.order.create({ data: input });
await this.pubSub.publish('orderCreated', { orderCreated: order });
return order;
}
في الإنتاج multi-instances، انتقل إلى graphql-redis-subscriptions الذي يستخدم Redis Pub/Sub. مصادقة WebSocket في connectionParams للـ handshake الأولي.
الخطوة 8 — تأمين resolvers بـ guards وdirectives
@Mutation(() => User)
@UseGuards(JwtAuthGuard, PoliciesGuard)
async deleteUser(@Args('id', { type: () => ID }) id: string) {
return this.prisma.user.delete({ where: { id } });
}
الخطوة 9 — Persisted queries وحدّ التعقيد
API GraphQL عامة بلا حراسات تقبل أي طلب: الخطر طلب خبيث يطلب 10 مستويات علاقات متداخلة ويُسقط القاعدة. حمايتان متكاملتان: حساب تكلفة عبر graphql-query-complexity، ونمط persisted queries.
GraphQLModule.forRoot({
driver: ApolloDriver,
validationRules: [ costAnalysis({ maximumCost: 1000 }) ],
persistedQueries: { cache: 'bounded' },
})
أخطاء شائعة
| الخطأ | السبب | الحل |
|---|---|---|
| Schema فارغ عند التوليد | Resolvers غير مسجَّلة في module | تحقّق providers |
| N+1 صامت في prod | ResolveField بلا DataLoader | فعّل Apollo Trace + Loader request-scoped |
| Subscriptions مفقودة بين instances | PubSub in-memory | graphql-redis-subscriptions |
| Type Date مُسلسَل سلسلة ISO | افتراضي GraphQLISODateTime |
لا تحمّل إلا عند حاجة client |
| أخطاء 500 بلا تنميط على client | Filter Apollo غائب | ApolloError + extensions code |
أسئلة شائعة
Code-first أم schema-first؟ code-first يربح إنتاجية حين يكون TypeScript مصدر الحقيقة جانب backend. schema-first مفضّل حين يصمَّم schema SDL مسبقاً من فريق API design.
هل نعرض REST وGraphQL متوازيين؟ ممارسة قابلة وحتى موصى بها. REST يخدم تكاملات الشركاء وwebhooks. GraphQL يخدم front داخلي.
كيف ندير uploads ملفات؟ لا تستخدم graphql-upload الذي يفتح CVEs. فضّل endpoint REST POST /uploads مع URL موقَّعة S3.
Apollo Federation أم monolithe GraphQL؟ الفيدراسيون أداة للشركات الكبيرة بعشرات الخدمات. لـ startup، monolithe GraphQL يعرض عدة مجالات يكفي تماماً.