بناء واجهة GraphQL آمنة الأنواع مع Next.js App Router و Yoga و Pothos

AI Bot
بواسطة AI Bot ·

جاري تحميل مشغل تحويل النص إلى كلام الصوتي...

GraphQL مع استدلال TypeScript الكامل — بدون توليد أكواد. Pothos هو منشئ مخططات يمنحك الإكمال التلقائي وأمان الأنواع عبر واجهة GraphQL API بالكامل، بينما يوفر GraphQL Yoga خادمًا خفيفًا ومتوافقًا مع المواصفات. في هذا الدليل، ستجمع بينهما مع Next.js 15 App Router لبناء واجهة API لإدارة الإشارات المرجعية جاهزة للإنتاج.

ما ستتعلمه

بنهاية هذا الدليل، ستتمكن من:

  • إعداد GraphQL Yoga كمعالج مسار داخل Next.js 15 App Router
  • تعريف مخطط GraphQL بأسلوب code-first مع Pothos — بدون ملفات SDL وبدون codegen
  • بناء استعلامات وتحولات مع إكمال تلقائي كامل في TypeScript
  • إضافة وسيط مصادقة باستخدام دوال السياق
  • تنفيذ التحقق من المدخلات مع Zod عبر إضافات Pothos
  • ربط عميل React باستخدام urql لجلب البيانات
  • التعامل مع الأخطاء وحالات التحميل بطريقة GraphQL

المتطلبات الأساسية

قبل البدء، تأكد من توفر:

  • Node.js 20+ مثبت (node --version)
  • خبرة في TypeScript (الأنواع، الأنواع العامة، الواجهات)
  • إلمام بـ Next.js App Router (معالجات المسارات، Server Components)
  • أساسيات GraphQL — فهم الاستعلامات والتحولات والمخططات مفيد لكنه ليس مطلوبًا
  • محرر أكواد — VS Code أو Cursor يُنصح بهما

لماذا GraphQL في 2026؟

يعمل REST جيدًا مع CRUD البسيط، لكن مع نمو الواجهة الأمامية، تواجه مشاكل مألوفة: جلب بيانات زائدة لا تحتاجها، جلب ناقص يتطلب طلبات إضافية، صعوبات في إدارة الإصدارات، وعدم وجود طريقة موحدة لوصف الواجهة البرمجية.

GraphQL يحل هذه المشاكل بالسماح للعميل بطلب ما يحتاجه بالضبط في طلب واحد. لكن إعدادات GraphQL التقليدية تتطلب صيانة ملفات SDL منفصلة وتشغيل مولدات أكواد للحفاظ على تزامن أنواع TypeScript.

Pothos يغير هذا. تعرّف المخطط في TypeScript، ويستنتج جميع الأنواع تلقائيًا. مع GraphQL Yoga (خادم خفيف ومتوافق مع المواصفات) و Next.js App Router، تحصل على واجهة GraphQL API آمنة الأنواع من قاعدة البيانات إلى واجهة المستخدم — بدون أي توليد أكواد.


ما ستبنيه

مدير إشارات مرجعية يتضمن:

  • واجهة GraphQL API مكشوفة عبر معالجات مسارات Next.js
  • استعلامات لعرض والبحث في الإشارات المرجعية
  • تحولات لإنشاء وتحديث وحذف الإشارات المرجعية
  • مصادقة المستخدم عبر السياق
  • واجهة React أمامية تستخدم urql لاستهلاك الواجهة البرمجية

هيكل المشروع النهائي:

bookmark-app/
├── app/
│   ├── api/
│   │   └── graphql/
│   │       └── route.ts          # معالج GraphQL Yoga
│   ├── bookmarks/
│   │   └── page.tsx              # واجهة الإشارات المرجعية
│   └── layout.tsx
├── graphql/
│   ├── builder.ts                # منشئ مخطط Pothos
│   ├── schema.ts                 # المخطط الرئيسي
│   ├── types/
│   │   ├── bookmark.ts           # نوع الإشارة المرجعية + المحللات
│   │   └── user.ts               # نوع المستخدم
│   └── context.ts                # سياق الطلب
├── lib/
│   ├── db.ts                     # قاعدة بيانات في الذاكرة
│   └── urql.ts                   # إعداد عميل urql
└── package.json

الخطوة 1: إنشاء مشروع Next.js

أنشئ تطبيق Next.js 15 جديد مع TypeScript:

npx create-next-app@latest bookmark-app --typescript --tailwind --app --src-dir=false
cd bookmark-app

اختر الإعدادات الافتراضية عند السؤال. ثم ثبّت تبعيات GraphQL:

npm install graphql graphql-yoga @pothos/core @pothos/plugin-zod zod
npm install urql @urql/next graphql-tag

ما يفعله كل حزمة:

الحزمةالغرض
graphqlالتنفيذ المرجعي لـ GraphQL
graphql-yogaخادم GraphQL خفيف ومتوافق مع المواصفات
@pothos/coreمنشئ مخططات code-first مع استدلال الأنواع
@pothos/plugin-zodتكامل التحقق من Zod لـ Pothos
zodالتحقق من المخططات أثناء التشغيل
urqlعميل GraphQL خفيف لـ React
@urql/nextربط urql الخاص بـ Next.js
graphql-tagقوالب نصية محددة لاستعلامات GraphQL

الخطوة 2: إعداد قاعدة البيانات في الذاكرة

في هذا الدليل، ستستخدم مخزنًا في الذاكرة. في الإنتاج، استبدل هذا بـ Prisma أو Drizzle أو أي طبقة قاعدة بيانات.

أنشئ lib/db.ts:

export interface User {
  id: string;
  name: string;
  email: string;
}
 
export interface Bookmark {
  id: string;
  url: string;
  title: string;
  description: string | null;
  tags: string[];
  userId: string;
  createdAt: Date;
  updatedAt: Date;
}
 
// بيانات أولية
const users: User[] = [
  { id: "1", name: "Alice", email: "alice@example.com" },
  { id: "2", name: "Bob", email: "bob@example.com" },
];
 
const bookmarks: Bookmark[] = [
  {
    id: "1",
    url: "https://graphql.org",
    title: "GraphQL Official Site",
    description: "The official GraphQL documentation and specification",
    tags: ["graphql", "api", "documentation"],
    userId: "1",
    createdAt: new Date("2026-01-15"),
    updatedAt: new Date("2026-01-15"),
  },
  {
    id: "2",
    url: "https://nextjs.org",
    title: "Next.js by Vercel",
    description: "The React framework for the web",
    tags: ["nextjs", "react", "framework"],
    userId: "1",
    createdAt: new Date("2026-02-01"),
    updatedAt: new Date("2026-02-01"),
  },
  {
    id: "3",
    url: "https://pothos-graphql.dev",
    title: "Pothos GraphQL",
    description: "Code-first GraphQL schema builder for TypeScript",
    tags: ["graphql", "typescript", "schema"],
    userId: "2",
    createdAt: new Date("2026-02-20"),
    updatedAt: new Date("2026-02-20"),
  },
];
 
let nextId = 4;
 
export const db = {
  users: {
    findMany: () => users,
    findById: (id: string) => users.find((u) => u.id === id) ?? null,
    findByEmail: (email: string) => users.find((u) => u.email === email) ?? null,
  },
  bookmarks: {
    findMany: (filters?: { userId?: string; tag?: string; search?: string }) => {
      let result = [...bookmarks];
      if (filters?.userId) {
        result = result.filter((b) => b.userId === filters.userId);
      }
      if (filters?.tag) {
        result = result.filter((b) => b.tags.includes(filters.tag!));
      }
      if (filters?.search) {
        const q = filters.search.toLowerCase();
        result = result.filter(
          (b) =>
            b.title.toLowerCase().includes(q) ||
            b.url.toLowerCase().includes(q) ||
            b.description?.toLowerCase().includes(q)
        );
      }
      return result;
    },
    findById: (id: string) => bookmarks.find((b) => b.id === id) ?? null,
    create: (data: Omit<Bookmark, "id" | "createdAt" | "updatedAt">) => {
      const bookmark: Bookmark = {
        ...data,
        id: String(nextId++),
        createdAt: new Date(),
        updatedAt: new Date(),
      };
      bookmarks.push(bookmark);
      return bookmark;
    },
    update: (id: string, data: Partial<Omit<Bookmark, "id" | "createdAt">>) => {
      const index = bookmarks.findIndex((b) => b.id === id);
      if (index === -1) return null;
      bookmarks[index] = { ...bookmarks[index], ...data, updatedAt: new Date() };
      return bookmarks[index];
    },
    delete: (id: string) => {
      const index = bookmarks.findIndex((b) => b.id === id);
      if (index === -1) return false;
      bookmarks.splice(index, 1);
      return true;
    },
  },
};

الخطوة 3: تعريف سياق الطلب

سياق GraphQL هو المكان الذي تضع فيه البيانات الخاصة بكل طلب مثل المستخدم المصادق عليه واتصالات قاعدة البيانات.

أنشئ graphql/context.ts:

import { db, type User } from "@/lib/db";
 
export interface GraphQLContext {
  currentUser: User | null;
  db: typeof db;
}
 
export function createContext(request: Request): GraphQLContext {
  // في الإنتاج، استخرج وتحقق من JWT أو رمز الجلسة هنا
  const userId = request.headers.get("x-user-id");
  const currentUser = userId ? db.users.findById(userId) : null;
 
  return {
    currentUser,
    db,
  };
}

في هذا الدليل، نحاكي المصادقة بقراءة ترويسة x-user-id. في الإنتاج، ستتحقق من JWT أو تفحص ملف تعريف ارتباط الجلسة.


الخطوة 4: إنشاء منشئ مخطط Pothos

منشئ المخطط هو جوهر Pothos. هنا تُعدّ الإضافات وتعرّف نوع السياق وتُنشئ نسخة المنشئ.

أنشئ graphql/builder.ts:

import SchemaBuilder from "@pothos/core";
import ZodPlugin from "@pothos/plugin-zod";
import type { GraphQLContext } from "./context";
 
export const builder = new SchemaBuilder<{
  Context: GraphQLContext;
  Scalars: {
    DateTime: {
      Input: Date;
      Output: Date;
    };
  };
}>({
  plugins: [ZodPlugin],
});
 
// تسجيل نوع DateTime مخصص
builder.scalarType("DateTime", {
  serialize: (value) => value.toISOString(),
  parseValue: (value) => {
    if (typeof value !== "string") {
      throw new Error("DateTime must be a string");
    }
    return new Date(value);
  },
});
 
// تهيئة أنواع Query و Mutation
builder.queryType({});
builder.mutationType({});

لاحظ كيف تمرر GraphQLContext كنوع عام. هذا يعني أن كل محلل سيكون لديه وصول إلى currentUser و db مع استدلال أنواع كامل — بدون تحويل أنواع.


الخطوة 5: تعريف نوع المستخدم

أنشئ graphql/types/user.ts:

import { builder } from "../builder";
 
builder.objectType("User", {
  description: "A registered user",
  fields: (t) => ({
    id: t.exposeString("id"),
    name: t.exposeString("name"),
    email: t.exposeString("email"),
    bookmarks: t.field({
      type: ["Bookmark"],
      resolve: (user, _args, ctx) => {
        return ctx.db.bookmarks.findMany({ userId: user.id });
      },
    }),
  }),
});
 
// استعلام: الحصول على المستخدم الحالي
builder.queryField("me", (t) =>
  t.field({
    type: "User",
    nullable: true,
    resolve: (_root, _args, ctx) => ctx.currentUser,
  })
);

طريقة exposeString هي اختصار في Pothos يربط الحقل مباشرة بخاصية الكائن. للحقول المحسوبة مثل bookmarks، تكتب محللاً كاملاً.


الخطوة 6: تعريف نوع الإشارة المرجعية مع الاستعلامات

هذا هو النوع الرئيسي. أنشئ graphql/types/bookmark.ts:

import { builder } from "../builder";
import { z } from "zod";
 
// تعريف نوع كائن Bookmark
const BookmarkType = builder.objectType("Bookmark", {
  description: "A saved bookmark with URL and metadata",
  fields: (t) => ({
    id: t.exposeString("id"),
    url: t.exposeString("url"),
    title: t.exposeString("title"),
    description: t.exposeString("description", { nullable: true }),
    tags: t.exposeStringList("tags"),
    createdAt: t.expose("createdAt", { type: "DateTime" }),
    updatedAt: t.expose("updatedAt", { type: "DateTime" }),
    user: t.field({
      type: "User",
      nullable: true,
      resolve: (bookmark, _args, ctx) => {
        return ctx.db.users.findById(bookmark.userId);
      },
    }),
  }),
});
 
// استعلام: عرض جميع الإشارات المرجعية مع فلاتر اختيارية
builder.queryField("bookmarks", (t) =>
  t.field({
    type: [BookmarkType],
    args: {
      tag: t.arg.string({ required: false }),
      search: t.arg.string({ required: false }),
    },
    resolve: (_root, args, ctx) => {
      return ctx.db.bookmarks.findMany({
        tag: args.tag ?? undefined,
        search: args.search ?? undefined,
      });
    },
  })
);
 
// استعلام: الحصول على إشارة مرجعية واحدة بالمعرف
builder.queryField("bookmark", (t) =>
  t.field({
    type: BookmarkType,
    nullable: true,
    args: {
      id: t.arg.string({ required: true }),
    },
    resolve: (_root, args, ctx) => {
      return ctx.db.bookmarks.findById(args.id);
    },
  })
);

الخطوة 7: إضافة التحولات مع التحقق بواسطة Zod

أضف تحولات الإنشاء والتحديث والحذف إلى نفس الملف graphql/types/bookmark.ts:

// مخططات Zod للتحقق من المدخلات
const CreateBookmarkInput = z.object({
  url: z.string().url("Must be a valid URL"),
  title: z.string().min(1, "Title is required").max(200),
  description: z.string().max(1000).nullable().optional(),
  tags: z.array(z.string().max(50)).max(10).default([]),
});
 
const UpdateBookmarkInput = z.object({
  url: z.string().url("Must be a valid URL").optional(),
  title: z.string().min(1).max(200).optional(),
  description: z.string().max(1000).nullable().optional(),
  tags: z.array(z.string().max(50)).max(10).optional(),
});
 
// تحول: إنشاء إشارة مرجعية
builder.mutationField("createBookmark", (t) =>
  t.field({
    type: BookmarkType,
    args: {
      input: t.arg({
        type: builder.inputType("CreateBookmarkInput", {
          fields: (t) => ({
            url: t.string({ required: true }),
            title: t.string({ required: true }),
            description: t.string({ required: false }),
            tags: t.stringList({ required: false, defaultValue: [] }),
          }),
        }),
        required: true,
      }),
    },
    resolve: (_root, args, ctx) => {
      if (!ctx.currentUser) {
        throw new Error("You must be logged in to create a bookmark");
      }
 
      const validated = CreateBookmarkInput.parse({
        url: args.input.url,
        title: args.input.title,
        description: args.input.description,
        tags: args.input.tags,
      });
 
      return ctx.db.bookmarks.create({
        ...validated,
        description: validated.description ?? null,
        tags: validated.tags,
        userId: ctx.currentUser.id,
      });
    },
  })
);
 
// تحول: تحديث إشارة مرجعية
builder.mutationField("updateBookmark", (t) =>
  t.field({
    type: BookmarkType,
    nullable: true,
    args: {
      id: t.arg.string({ required: true }),
      input: t.arg({
        type: builder.inputType("UpdateBookmarkInput", {
          fields: (t) => ({
            url: t.string({ required: false }),
            title: t.string({ required: false }),
            description: t.string({ required: false }),
            tags: t.stringList({ required: false }),
          }),
        }),
        required: true,
      }),
    },
    resolve: (_root, args, ctx) => {
      if (!ctx.currentUser) {
        throw new Error("You must be logged in to update a bookmark");
      }
 
      const existing = ctx.db.bookmarks.findById(args.id);
      if (!existing || existing.userId !== ctx.currentUser.id) {
        throw new Error("Bookmark not found or not authorized");
      }
 
      const validated = UpdateBookmarkInput.parse({
        url: args.input.url,
        title: args.input.title,
        description: args.input.description,
        tags: args.input.tags,
      });
 
      return ctx.db.bookmarks.update(args.id, validated);
    },
  })
);
 
// تحول: حذف إشارة مرجعية
builder.mutationField("deleteBookmark", (t) =>
  t.field({
    type: "Boolean",
    args: {
      id: t.arg.string({ required: true }),
    },
    resolve: (_root, args, ctx) => {
      if (!ctx.currentUser) {
        throw new Error("You must be logged in to delete a bookmark");
      }
 
      const existing = ctx.db.bookmarks.findById(args.id);
      if (!existing || existing.userId !== ctx.currentUser.id) {
        throw new Error("Bookmark not found or not authorized");
      }
 
      return ctx.db.bookmarks.delete(args.id);
    },
  })
);

كل تحول يتحقق من ctx.currentUser قبل المتابعة. هذا هو المكافئ لوسيط middleware في GraphQL — تتحكم في الوصول على مستوى المحلل.

مخططات Zod تتحقق من المدخلات أثناء التشغيل. إذا أرسل العميل عنوان URL غير صالح أو عنوانًا طويلاً جدًا، يحصل على رسالة خطأ واضحة.


الخطوة 8: تجميع المخطط

أنشئ graphql/schema.ts لجمع كل شيء معًا:

import { builder } from "./builder";
 
// استيراد الأنواع لتسجيلها مع المنشئ
import "./types/user";
import "./types/bookmark";
 
// بناء وتصدير المخطط القابل للتنفيذ
export const schema = builder.toSchema();

الاستيرادات لها تأثيرات جانبية — تسجل الأنواع مع المنشئ عند تحميل الوحدة. هذا نمط شائع في Pothos.


الخطوة 9: إنشاء معالج مسار GraphQL

الآن اكشف المخطط كمسار API في Next.js. أنشئ app/api/graphql/route.ts:

import { createYoga } from "graphql-yoga";
import { schema } from "@/graphql/schema";
import { createContext } from "@/graphql/context";
 
const yoga = createYoga({
  schema,
  context: ({ request }) => createContext(request),
  graphqlEndpoint: "/api/graphql",
  graphiql: process.env.NODE_ENV === "development",
  fetchAPI: { Response },
});
 
const handler = yoga;
 
export { handler as GET, handler as POST };

هذا كل ما تحتاجه. GraphQL Yoga يتعامل مع تحليل الاستعلامات والتحقق منها والتنفيذ ومعالجة الأخطاء وواجهة GraphiQL للتطوير.


الخطوة 10: الاختبار مع GraphiQL

شغّل خادم التطوير:

npm run dev

افتح http://localhost:3000/api/graphql في المتصفح. سترى واجهة GraphiQL. جرّب هذه الاستعلامات:

عرض جميع الإشارات المرجعية:

query {
  bookmarks {
    id
    title
    url
    tags
    user {
      name
    }
  }
}

البحث في الإشارات المرجعية:

query {
  bookmarks(search: "react") {
    title
    url
    description
  }
}

التصفية حسب الوسم:

query {
  bookmarks(tag: "graphql") {
    title
    tags
  }
}

للتحولات، أضف ترويسة x-user-id في لوحة الترويسات في GraphiQL:

{
  "x-user-id": "1"
}

ثم نفّذ:

mutation {
  createBookmark(input: {
    url: "https://yoga-graphql.com"
    title: "GraphQL Yoga"
    description: "A fully-featured GraphQL server"
    tags: ["graphql", "server"]
  }) {
    id
    title
    url
    tags
    createdAt
  }
}

الخطوة 11: إعداد عميل urql

الآن ابنِ واجهة React أمامية لاستهلاك الواجهة البرمجية. أنشئ lib/urql.ts:

import { cacheExchange, createClient, fetchExchange } from "@urql/next";
 
export function makeClient() {
  return createClient({
    url: "/api/graphql",
    exchanges: [cacheExchange, fetchExchange],
    fetchOptions: () => ({
      headers: {
        "x-user-id": "1",
      },
    }),
  });
}

أنشئ مكون المزود في app/providers.tsx:

"use client";
 
import { UrqlProvider, ssrExchange } from "@urql/next";
import { useMemo } from "react";
import { makeClient } from "@/lib/urql";
 
export function Providers({ children }: { children: React.ReactNode }) {
  const [client, ssr] = useMemo(() => {
    const ssr = ssrExchange({ isClient: typeof window !== "undefined" });
    const client = makeClient();
    return [client, ssr];
  }, []);
 
  return (
    <UrqlProvider client={client} ssr={ssr}>
      {children}
    </UrqlProvider>
  );
}

الخطوة 12: بناء صفحة الإشارات المرجعية

أنشئ app/bookmarks/page.tsx:

"use client";
 
import { useQuery, useMutation } from "@urql/next";
import { gql } from "graphql-tag";
import { useState } from "react";
 
const BOOKMARKS_QUERY = gql`
  query Bookmarks($search: String, $tag: String) {
    bookmarks(search: $search, tag: $tag) {
      id
      title
      url
      description
      tags
      createdAt
      user {
        name
      }
    }
  }
`;
 
const CREATE_BOOKMARK = gql`
  mutation CreateBookmark($input: CreateBookmarkInput!) {
    createBookmark(input: $input) {
      id
      title
      url
      tags
    }
  }
`;
 
const DELETE_BOOKMARK = gql`
  mutation DeleteBookmark($id: String!) {
    deleteBookmark(id: $id)
  }
`;
 
export default function BookmarksPage() {
  const [search, setSearch] = useState("");
  const [selectedTag, setSelectedTag] = useState("");
 
  const [result, reexecute] = useQuery({
    query: BOOKMARKS_QUERY,
    variables: {
      search: search || null,
      tag: selectedTag || null,
    },
  });
 
  const [, createBookmark] = useMutation(CREATE_BOOKMARK);
  const [, deleteBookmark] = useMutation(DELETE_BOOKMARK);
 
  const { data, fetching, error } = result;
 
  async function handleCreate(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const form = new FormData(e.currentTarget);
 
    await createBookmark({
      input: {
        url: form.get("url") as string,
        title: form.get("title") as string,
        description: (form.get("description") as string) || null,
        tags: (form.get("tags") as string)
          .split(",")
          .map((t) => t.trim())
          .filter(Boolean),
      },
    });
 
    e.currentTarget.reset();
    reexecute({ requestPolicy: "network-only" });
  }
 
  async function handleDelete(id: string) {
    await deleteBookmark({ id });
    reexecute({ requestPolicy: "network-only" });
  }
 
  const allTags = [
    ...new Set(data?.bookmarks?.flatMap((b: any) => b.tags) ?? []),
  ];
 
  return (
    <div className="max-w-4xl mx-auto p-8">
      <h1 className="text-3xl font-bold mb-8">الإشارات المرجعية</h1>
 
      <div className="flex gap-4 mb-6">
        <input
          type="text"
          placeholder="البحث في الإشارات المرجعية..."
          value={search}
          onChange={(e) => setSearch(e.target.value)}
          className="flex-1 px-4 py-2 border rounded-lg"
        />
        <select
          value={selectedTag}
          onChange={(e) => setSelectedTag(e.target.value)}
          className="px-4 py-2 border rounded-lg"
        >
          <option value="">جميع الوسوم</option>
          {allTags.map((tag) => (
            <option key={tag} value={tag}>
              {tag}
            </option>
          ))}
        </select>
      </div>
 
      <form onSubmit={handleCreate} className="mb-8 p-4 bg-gray-50 rounded-lg">
        <h2 className="text-lg font-semibold mb-4">إضافة إشارة مرجعية</h2>
        <div className="grid grid-cols-2 gap-4">
          <input name="url" placeholder="الرابط" required className="px-3 py-2 border rounded" />
          <input name="title" placeholder="العنوان" required className="px-3 py-2 border rounded" />
          <input name="description" placeholder="الوصف (اختياري)" className="px-3 py-2 border rounded" />
          <input name="tags" placeholder="الوسوم (مفصولة بفواصل)" className="px-3 py-2 border rounded" />
        </div>
        <button type="submit" className="mt-4 px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700">
          إضافة
        </button>
      </form>
 
      {fetching && <p>جاري التحميل...</p>}
      {error && <p className="text-red-500">خطأ: {error.message}</p>}
      {data?.bookmarks && (
        <div className="space-y-4">
          {data.bookmarks.map((bookmark: any) => (
            <div key={bookmark.id} className="p-4 border rounded-lg hover:shadow-md transition-shadow">
              <div className="flex justify-between items-start">
                <div>
                  <a
                    href={bookmark.url}
                    target="_blank"
                    rel="noopener noreferrer"
                    className="text-lg font-semibold text-blue-600 hover:underline"
                  >
                    {bookmark.title}
                  </a>
                  <p className="text-sm text-gray-500 mt-1">{bookmark.url}</p>
                  {bookmark.description && (
                    <p className="text-gray-700 mt-2">{bookmark.description}</p>
                  )}
                  <div className="flex gap-2 mt-2">
                    {bookmark.tags.map((tag: string) => (
                      <span
                        key={tag}
                        className="px-2 py-1 bg-gray-200 rounded-full text-xs cursor-pointer hover:bg-gray-300"
                        onClick={() => setSelectedTag(tag)}
                      >
                        {tag}
                      </span>
                    ))}
                  </div>
                  <p className="text-xs text-gray-400 mt-2">
                    بواسطة {bookmark.user?.name} &middot; {new Date(bookmark.createdAt).toLocaleDateString("ar")}
                  </p>
                </div>
                <button
                  onClick={() => handleDelete(bookmark.id)}
                  className="text-red-500 hover:text-red-700 text-sm"
                >
                  حذف
                </button>
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

الخطوة 13: إضافة معالجة الأخطاء

GraphQL لديه نظام أخطاء مدمج. أنشئ graphql/errors.ts:

import { GraphQLError } from "graphql";
 
export class AuthenticationError extends GraphQLError {
  constructor(message = "You must be logged in") {
    super(message, {
      extensions: { code: "UNAUTHENTICATED" },
    });
  }
}
 
export class AuthorizationError extends GraphQLError {
  constructor(message = "You are not authorized to perform this action") {
    super(message, {
      extensions: { code: "FORBIDDEN" },
    });
  }
}
 
export class NotFoundError extends GraphQLError {
  constructor(resource: string) {
    super(`${resource} not found`, {
      extensions: { code: "NOT_FOUND" },
    });
  }
}

حقل extensions.code يسمح للعميل بالتعامل مع الأخطاء برمجيًا:

if (error.graphQLErrors.some((e) => e.extensions?.code === "UNAUTHENTICATED")) {
  // إعادة التوجيه لتسجيل الدخول
}

الخطوة 14: إضافة التصفح المرقّم

لواجهات API الإنتاجية، تحتاج التصفح المرقّم. أضف تصفحًا مرقّمًا قائمًا على المؤشر:

query {
  paginatedBookmarks(first: 2) {
    edges {
      cursor
      node {
        title
        url
      }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
    totalCount
  }
}

للصفحة التالية، مرر after: "last-cursor-value".


استكشاف الأخطاء وإصلاحها

"Cannot find module @/graphql/schema"

تأكد من أن tsconfig.json يحتوي على إعداد مسار @/*:

{
  "compilerOptions": {
    "paths": {
      "@/*": ["./*"]
    }
  }
}

GraphiQL لا يتحمّل

GraphiQL مفعّل فقط في وضع التطوير. تحقق من أن NODE_ENV ليس معينًا على production.

أخطاء "You must be logged in"

للتحولات، أضف ترويسة x-user-id: 1 في GraphiQL أو في إعداد عميل urql.


الخطوات التالية

لديك الآن واجهة GraphQL API آمنة الأنواع بالكامل مع Next.js. إليك طرقًا لتوسيعها:

  • إضافة قاعدة بيانات حقيقية — استبدل المخزن في الذاكرة بـ Prisma أو Drizzle ORM متصل بـ PostgreSQL
  • تنفيذ المصادقة — استخدم NextAuth.js أو Better Auth لإصدار رموز حقيقية
  • إضافة اشتراكات — GraphQL Yoga يدعم اشتراكات WebSocket للتحديثات الفورية
  • توليد أنواع العميل — استخدم graphql-codegen لتوليد hooks مكتوبة للواجهة الأمامية
  • النشر — GraphQL Yoga يعمل على Vercel و Cloudflare Workers وأي منصة Node.js

الخلاصة

في هذا الدليل، بنيت واجهة GraphQL API كاملة باستخدام Next.js App Router و GraphQL Yoga و Pothos. تعلمت كيفية:

  1. تعريف مخطط code-first مع استدلال TypeScript كامل باستخدام Pothos
  2. إعداد GraphQL Yoga كمعالج مسار Next.js
  3. بناء استعلامات مع تصفية و تحولات مع التحقق بواسطة Zod
  4. تنفيذ المصادقة عبر السياق ومعالجة أخطاء منظمة
  5. إضافة تصفح مرقّم قائم على المؤشر للاستخدام الإنتاجي
  6. ربط واجهة React أمامية مع urql لجلب بيانات آمن الأنواع

الجمع بين Pothos و GraphQL Yoga يمنحك أفضل ما في العالمين: مرونة GraphQL مع أمان أنواع TypeScript — بدون توليد أكواد، بدون ملفات SDL، فقط TypeScript من البداية للنهاية.


هل تريد قراءة المزيد من الدروس التعليمية؟ تحقق من أحدث درس تعليمي لدينا على AI SDK 4.0: الميزات الجديدة وحالات الاستخدام.

ناقش مشروعك معنا

نحن هنا للمساعدة في احتياجات تطوير الويب الخاصة بك. حدد موعدًا لمناقشة مشروعك وكيف يمكننا مساعدتك.

دعنا نجد أفضل الحلول لاحتياجاتك.

مقالات ذات صلة

بناء واجهات برمجة تطبيقات آمنة الأنواع من البداية للنهاية مع tRPC و Next.js App Router

تعلم كيفية بناء واجهات برمجة تطبيقات آمنة الأنواع بالكامل مع tRPC و Next.js 15 App Router. يغطي هذا الدليل العملي إعداد الموجه والإجراءات والوسيط وتكامل React Query والاستدعاءات من جانب الخادم — كل ذلك دون كتابة أي مخطط API.

28 د قراءة·