بناء واجهات برمجة تطبيقات جاهزة للإنتاج باستخدام Fastify و TypeScript

AI Bot
بواسطة AI Bot ·

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

لماذا Fastify؟

يُعد Fastify أحد أسرع أطر عمل Node.js المتاحة، حيث يحقق باستمرار أكثر من 70,000 طلب في الثانية في اختبارات الأداء. على عكس Express، بُني Fastify من الأساس مع التركيز على الأداء وتجربة المطور ودعم TypeScript الأصلي. بنيته المعتمدة على الإضافات، والتحقق المدمج من المخطط باستخدام JSON Schema، والتسلسل التلقائي تجعله خيارًا ممتازًا لواجهات برمجة التطبيقات في بيئة الإنتاج.

في هذا الدليل، ستبني واجهة برمجة تطبيقات REST كاملة لنظام إدارة المهام — من إعداد المشروع إلى تكوين جاهز للنشر.

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

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

  • Node.js 20+ مُثبّت
  • أساسيات TypeScript (الواجهات، الأنواع العامة)
  • فهم أساسي لمفاهيم REST API
  • محرر أكواد (يُنصح بـ VS Code)
  • Docker مُثبّت (لقاعدة البيانات)

ما ستبنيه

واجهة برمجة تطبيقات لإدارة المهام جاهزة للإنتاج تتضمن:

  • عمليات CRUD للمهام والمشاريع
  • مصادقة JWT مع رموز التحديث
  • قاعدة بيانات PostgreSQL مع Drizzle ORM
  • التحقق من الطلبات باستخدام JSON Schema / TypeBox
  • تسجيل منظم مع Pino
  • تحديد معدل الطلبات و CORS
  • معالجة شاملة للأخطاء
  • اختبارات وحدة واختبارات تكامل

الخطوة 1: إعداد المشروع

ابدأ بإنشاء مشروع جديد وتثبيت التبعيات:

mkdir fastify-tasks-api && cd fastify-tasks-api
npm init -y

ثبّت التبعيات الأساسية:

npm install fastify @fastify/cors @fastify/rate-limit @fastify/jwt @fastify/swagger @fastify/swagger-ui @sinclair/typebox drizzle-orm postgres dotenv

ثبّت تبعيات التطوير:

npm install -D typescript @types/node tsx vitest drizzle-kit

هيّئ TypeScript:

npx tsc --init

حدّث ملف tsconfig.json بهذه الإعدادات:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

أضف السكربتات إلى package.json:

{
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js",
    "test": "vitest",
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate",
    "db:studio": "drizzle-kit studio"
  }
}

الخطوة 2: هيكل المشروع

نظّم مشروعك بهيكل نظيف ومعياري:

src/
├── config/
│   └── env.ts            # تكوين البيئة
├── db/
│   ├── index.ts          # اتصال قاعدة البيانات
│   ├── schema.ts         # تعريفات مخطط Drizzle
│   └── migrate.ts        # مُشغّل الترحيل
├── modules/
│   ├── auth/
│   │   ├── auth.routes.ts
│   │   ├── auth.service.ts
│   │   └── auth.schema.ts
│   └── tasks/
│       ├── tasks.routes.ts
│       ├── tasks.service.ts
│       └── tasks.schema.ts
├── plugins/
│   ├── auth.ts           # إضافة JWT
│   ├── cors.ts           # إضافة CORS
│   └── rate-limit.ts     # تحديد المعدل
├── utils/
│   └── errors.ts         # فئات الأخطاء المخصصة
├── app.ts                # مصنع تطبيق Fastify
└── server.ts             # نقطة دخول الخادم

الخطوة 3: تكوين البيئة

أنشئ ملف .env في جذر المشروع:

DATABASE_URL=postgres://postgres:postgres@localhost:5432/fastify_tasks
JWT_SECRET=your-super-secret-jwt-key-change-in-production
JWT_REFRESH_SECRET=your-refresh-secret-key-change-in-production
PORT=3000
HOST=0.0.0.0
NODE_ENV=development

الآن أنشئ وحدة تكوين البيئة مع التحقق بواسطة TypeBox:

// src/config/env.ts
import { Type, type Static } from "@sinclair/typebox";
import { Value } from "@sinclair/typebox/value";
import "dotenv/config";
 
const EnvSchema = Type.Object({
  DATABASE_URL: Type.String(),
  JWT_SECRET: Type.String({ minLength: 32 }),
  JWT_REFRESH_SECRET: Type.String({ minLength: 32 }),
  PORT: Type.Number({ default: 3000 }),
  HOST: Type.String({ default: "0.0.0.0" }),
  NODE_ENV: Type.Union([
    Type.Literal("development"),
    Type.Literal("production"),
    Type.Literal("test"),
  ]),
});
 
type Env = Static<typeof EnvSchema>;
 
const rawEnv = {
  ...process.env,
  PORT: Number(process.env.PORT) || 3000,
};
 
if (!Value.Check(EnvSchema, rawEnv)) {
  const errors = [...Value.Errors(EnvSchema, rawEnv)];
  console.error("❌ متغيرات بيئة غير صالحة:");
  errors.forEach((err) => console.error(`  ${err.path}: ${err.message}`));
  process.exit(1);
}
 
export const env: Env = rawEnv as Env;

يمنحك هذا متغيرات بيئة مُتحقق منها وآمنة الأنواع. إذا كان أي متغير مطلوب مفقودًا أو غير صالح، يتوقف التطبيق فورًا مع رسالة خطأ واضحة.

الخطوة 4: مخطط قاعدة البيانات مع Drizzle

شغّل حاوية PostgreSQL:

docker run -d --name fastify-pg \
  -e POSTGRES_PASSWORD=postgres \
  -e POSTGRES_DB=fastify_tasks \
  -p 5432:5432 \
  postgres:16-alpine

عرّف مخطط قاعدة البيانات:

// src/db/schema.ts
import {
  pgTable,
  uuid,
  varchar,
  text,
  timestamp,
  boolean,
  pgEnum,
} from "drizzle-orm/pg-core";
 
export const taskStatusEnum = pgEnum("task_status", [
  "todo",
  "in_progress",
  "done",
  "cancelled",
]);
 
export const taskPriorityEnum = pgEnum("task_priority", [
  "low",
  "medium",
  "high",
  "urgent",
]);
 
export const users = pgTable("users", {
  id: uuid("id").defaultRandom().primaryKey(),
  email: varchar("email", { length: 255 }).notNull().unique(),
  passwordHash: varchar("password_hash", { length: 255 }).notNull(),
  name: varchar("name", { length: 100 }).notNull(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
 
export const projects = pgTable("projects", {
  id: uuid("id").defaultRandom().primaryKey(),
  name: varchar("name", { length: 200 }).notNull(),
  description: text("description"),
  ownerId: uuid("owner_id")
    .references(() => users.id)
    .notNull(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
 
export const tasks = pgTable("tasks", {
  id: uuid("id").defaultRandom().primaryKey(),
  title: varchar("title", { length: 300 }).notNull(),
  description: text("description"),
  status: taskStatusEnum("status").default("todo").notNull(),
  priority: taskPriorityEnum("priority").default("medium").notNull(),
  dueDate: timestamp("due_date"),
  completed: boolean("completed").default(false).notNull(),
  projectId: uuid("project_id")
    .references(() => projects.id)
    .notNull(),
  assigneeId: uuid("assignee_id").references(() => users.id),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

أعدّ اتصال قاعدة البيانات:

// src/db/index.ts
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { env } from "../config/env.js";
import * as schema from "./schema.js";
 
const client = postgres(env.DATABASE_URL);
export const db = drizzle(client, { schema });
export type Database = typeof db;

أنشئ ملف تكوين Drizzle في جذر المشروع:

// drizzle.config.ts
import { defineConfig } from "drizzle-kit";
 
export default defineConfig({
  schema: "./src/db/schema.ts",
  out: "./drizzle",
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
});

أنشئ وشغّل عمليات الترحيل:

npm run db:generate
npm run db:migrate

الخطوة 5: معالجة الأخطاء المخصصة

أنشئ فئات أخطاء منظمة يستطيع Fastify تسلسلها:

// src/utils/errors.ts
export class AppError extends Error {
  constructor(
    public statusCode: number,
    message: string,
    public code: string
  ) {
    super(message);
    this.name = "AppError";
  }
}
 
export class NotFoundError extends AppError {
  constructor(resource: string, id: string) {
    super(404, `${resource} with id '${id}' not found`, "NOT_FOUND");
  }
}
 
export class UnauthorizedError extends AppError {
  constructor(message = "Authentication required") {
    super(401, message, "UNAUTHORIZED");
  }
}
 
export class ForbiddenError extends AppError {
  constructor(message = "Insufficient permissions") {
    super(403, message, "FORBIDDEN");
  }
}
 
export class ConflictError extends AppError {
  constructor(message: string) {
    super(409, message, "CONFLICT");
  }
}
 
export class ValidationError extends AppError {
  constructor(message: string) {
    super(400, message, "VALIDATION_ERROR");
  }
}

الخطوة 6: إضافة المصادقة

ابنِ إضافة مصادقة JWT قابلة لإعادة الاستخدام:

// src/plugins/auth.ts
import fp from "fastify-plugin";
import fjwt from "@fastify/jwt";
import { type FastifyInstance, type FastifyRequest } from "fastify";
import { env } from "../config/env.js";
import { UnauthorizedError } from "../utils/errors.js";
 
declare module "fastify" {
  interface FastifyInstance {
    authenticate: (
      request: FastifyRequest
    ) => Promise<void>;
  }
}
 
declare module "@fastify/jwt" {
  interface FastifyJWT {
    payload: { userId: string; email: string };
    user: { userId: string; email: string };
  }
}
 
export default fp(async (fastify: FastifyInstance) => {
  await fastify.register(fjwt, {
    secret: env.JWT_SECRET,
    sign: { expiresIn: "15m" },
  });
 
  fastify.decorate(
    "authenticate",
    async (request: FastifyRequest) => {
      try {
        await request.jwtVerify();
      } catch {
        throw new UnauthorizedError("رمز غير صالح أو منتهي الصلاحية");
      }
    }
  );
});

الخطوة 7: مخططات التحقق من الطلبات مع TypeBox

يُولّد TypeBox مخطط JSON في وقت التشغيل بينما يمنحك أنواع TypeScript كاملة في وقت الترجمة. هذه إحدى القدرات الخارقة لـ Fastify — يبقى التحقق والأنواع متزامنين تمامًا:

// src/modules/auth/auth.schema.ts
import { Type, type Static } from "@sinclair/typebox";
 
export const RegisterBody = Type.Object({
  email: Type.String({ format: "email" }),
  password: Type.String({ minLength: 8, maxLength: 128 }),
  name: Type.String({ minLength: 1, maxLength: 100 }),
});
 
export const LoginBody = Type.Object({
  email: Type.String({ format: "email" }),
  password: Type.String(),
});
 
export const AuthResponse = Type.Object({
  accessToken: Type.String(),
  refreshToken: Type.String(),
  user: Type.Object({
    id: Type.String(),
    email: Type.String(),
    name: Type.String(),
  }),
});
 
export type RegisterBodyType = Static<typeof RegisterBody>;
export type LoginBodyType = Static<typeof LoginBody>;
export type AuthResponseType = Static<typeof AuthResponse>;
// src/modules/tasks/tasks.schema.ts
import { Type, type Static } from "@sinclair/typebox";
 
export const TaskParams = Type.Object({
  id: Type.String({ format: "uuid" }),
});
 
export const TaskQuerystring = Type.Object({
  status: Type.Optional(
    Type.Union([
      Type.Literal("todo"),
      Type.Literal("in_progress"),
      Type.Literal("done"),
      Type.Literal("cancelled"),
    ])
  ),
  priority: Type.Optional(
    Type.Union([
      Type.Literal("low"),
      Type.Literal("medium"),
      Type.Literal("high"),
      Type.Literal("urgent"),
    ])
  ),
  projectId: Type.Optional(Type.String({ format: "uuid" })),
  page: Type.Optional(Type.Number({ minimum: 1, default: 1 })),
  limit: Type.Optional(Type.Number({ minimum: 1, maximum: 100, default: 20 })),
});
 
export const CreateTaskBody = Type.Object({
  title: Type.String({ minLength: 1, maxLength: 300 }),
  description: Type.Optional(Type.String()),
  status: Type.Optional(
    Type.Union([
      Type.Literal("todo"),
      Type.Literal("in_progress"),
      Type.Literal("done"),
      Type.Literal("cancelled"),
    ])
  ),
  priority: Type.Optional(
    Type.Union([
      Type.Literal("low"),
      Type.Literal("medium"),
      Type.Literal("high"),
      Type.Literal("urgent"),
    ])
  ),
  dueDate: Type.Optional(Type.String({ format: "date-time" })),
  projectId: Type.String({ format: "uuid" }),
  assigneeId: Type.Optional(Type.String({ format: "uuid" })),
});
 
export const UpdateTaskBody = Type.Partial(CreateTaskBody);
 
export const TaskResponse = Type.Object({
  id: Type.String(),
  title: Type.String(),
  description: Type.Union([Type.String(), Type.Null()]),
  status: Type.String(),
  priority: Type.String(),
  dueDate: Type.Union([Type.String(), Type.Null()]),
  completed: Type.Boolean(),
  projectId: Type.String(),
  assigneeId: Type.Union([Type.String(), Type.Null()]),
  createdAt: Type.String(),
  updatedAt: Type.String(),
});
 
export const TaskListResponse = Type.Object({
  data: Type.Array(TaskResponse),
  total: Type.Number(),
  page: Type.Number(),
  limit: Type.Number(),
});
 
export type TaskParamsType = Static<typeof TaskParams>;
export type TaskQuerystringType = Static<typeof TaskQuerystring>;
export type CreateTaskBodyType = Static<typeof CreateTaskBody>;
export type UpdateTaskBodyType = Static<typeof UpdateTaskBody>;

الخطوة 8: طبقة الخدمات

احتفظ بمنطق الأعمال في وحدات الخدمة، منفصلة عن معالجات المسارات:

// src/modules/tasks/tasks.service.ts
import { eq, and, sql, type SQL } from "drizzle-orm";
import { type Database } from "../../db/index.js";
import { tasks } from "../../db/schema.js";
import {
  type CreateTaskBodyType,
  type UpdateTaskBodyType,
  type TaskQuerystringType,
} from "./tasks.schema.js";
import { NotFoundError } from "../../utils/errors.js";
 
export class TasksService {
  constructor(private db: Database) {}
 
  async list(query: TaskQuerystringType) {
    const page = query.page ?? 1;
    const limit = query.limit ?? 20;
    const offset = (page - 1) * limit;
 
    const conditions: SQL[] = [];
    if (query.status) conditions.push(eq(tasks.status, query.status));
    if (query.priority) conditions.push(eq(tasks.priority, query.priority));
    if (query.projectId) conditions.push(eq(tasks.projectId, query.projectId));
 
    const where = conditions.length > 0 ? and(...conditions) : undefined;
 
    const [data, countResult] = await Promise.all([
      this.db
        .select()
        .from(tasks)
        .where(where)
        .limit(limit)
        .offset(offset)
        .orderBy(tasks.createdAt),
      this.db
        .select({ count: sql<number>`count(*)` })
        .from(tasks)
        .where(where),
    ]);
 
    return {
      data,
      total: Number(countResult[0].count),
      page,
      limit,
    };
  }
 
  async getById(id: string) {
    const result = await this.db
      .select()
      .from(tasks)
      .where(eq(tasks.id, id))
      .limit(1);
 
    if (result.length === 0) {
      throw new NotFoundError("Task", id);
    }
    return result[0];
  }
 
  async create(data: CreateTaskBodyType) {
    const result = await this.db
      .insert(tasks)
      .values({
        title: data.title,
        description: data.description,
        status: data.status ?? "todo",
        priority: data.priority ?? "medium",
        dueDate: data.dueDate ? new Date(data.dueDate) : null,
        projectId: data.projectId,
        assigneeId: data.assigneeId,
      })
      .returning();
 
    return result[0];
  }
 
  async update(id: string, data: UpdateTaskBodyType) {
    await this.getById(id);
 
    const result = await this.db
      .update(tasks)
      .set({
        ...data,
        dueDate: data.dueDate ? new Date(data.dueDate) : undefined,
        updatedAt: new Date(),
      })
      .where(eq(tasks.id, id))
      .returning();
 
    return result[0];
  }
 
  async delete(id: string) {
    await this.getById(id);
    await this.db.delete(tasks).where(eq(tasks.id, id));
  }
}

الخطوة 9: معالجات المسارات

الآن اربط كل شيء في معالجات المسارات. لاحظ كيف يمنحك خيار schema في Fastify التحقق التلقائي واستنتاج الأنواع:

// src/modules/tasks/tasks.routes.ts
import { type FastifyInstance } from "fastify";
import { TasksService } from "./tasks.service.js";
import {
  TaskParams,
  TaskQuerystring,
  CreateTaskBody,
  UpdateTaskBody,
  TaskResponse,
  TaskListResponse,
  type TaskParamsType,
  type TaskQuerystringType,
  type CreateTaskBodyType,
  type UpdateTaskBodyType,
} from "./tasks.schema.js";
import { db } from "../../db/index.js";
 
export default async function tasksRoutes(fastify: FastifyInstance) {
  const service = new TasksService(db);
 
  fastify.addHook("onRequest", fastify.authenticate);
 
  // GET /tasks
  fastify.get<{ Querystring: TaskQuerystringType }>(
    "/",
    {
      schema: {
        querystring: TaskQuerystring,
        response: { 200: TaskListResponse },
        tags: ["Tasks"],
        summary: "قائمة جميع المهام مع فلاتر اختيارية",
      },
    },
    async (request) => {
      return service.list(request.query);
    }
  );
 
  // GET /tasks/:id
  fastify.get<{ Params: TaskParamsType }>(
    "/:id",
    {
      schema: {
        params: TaskParams,
        response: { 200: TaskResponse },
        tags: ["Tasks"],
        summary: "الحصول على مهمة بالمعرف",
      },
    },
    async (request) => {
      return service.getById(request.params.id);
    }
  );
 
  // POST /tasks
  fastify.post<{ Body: CreateTaskBodyType }>(
    "/",
    {
      schema: {
        body: CreateTaskBody,
        response: { 201: TaskResponse },
        tags: ["Tasks"],
        summary: "إنشاء مهمة جديدة",
      },
    },
    async (request, reply) => {
      const task = await service.create(request.body);
      return reply.status(201).send(task);
    }
  );
 
  // PATCH /tasks/:id
  fastify.patch<{ Params: TaskParamsType; Body: UpdateTaskBodyType }>(
    "/:id",
    {
      schema: {
        params: TaskParams,
        body: UpdateTaskBody,
        response: { 200: TaskResponse },
        tags: ["Tasks"],
        summary: "تحديث مهمة",
      },
    },
    async (request) => {
      return service.update(request.params.id, request.body);
    }
  );
 
  // DELETE /tasks/:id
  fastify.delete<{ Params: TaskParamsType }>(
    "/:id",
    {
      schema: {
        params: TaskParams,
        tags: ["Tasks"],
        summary: "حذف مهمة",
      },
    },
    async (request, reply) => {
      await service.delete(request.params.id);
      return reply.status(204).send();
    }
  );
}

الخطوة 10: مصنع التطبيق والخادم

أنشئ مصنع التطبيق. هذا النمط يجعل الاختبار أسهل بكثير حيث يمكنك إنشاء نسخ معزولة من التطبيق:

// src/app.ts
import Fastify, { type FastifyInstance } from "fastify";
import cors from "@fastify/cors";
import rateLimit from "@fastify/rate-limit";
import swagger from "@fastify/swagger";
import swaggerUi from "@fastify/swagger-ui";
import authPlugin from "./plugins/auth.js";
import tasksRoutes from "./modules/tasks/tasks.routes.js";
import { AppError } from "./utils/errors.js";
import { env } from "./config/env.js";
 
export async function buildApp(): Promise<FastifyInstance> {
  const app = Fastify({
    logger: {
      level: env.NODE_ENV === "production" ? "info" : "debug",
      transport:
        env.NODE_ENV === "development"
          ? { target: "pino-pretty", options: { colorize: true } }
          : undefined,
    },
  });
 
  // --- الإضافات ---
  await app.register(cors, {
    origin: env.NODE_ENV === "production"
      ? ["https://yourdomain.com"]
      : true,
    credentials: true,
  });
 
  await app.register(rateLimit, {
    max: 100,
    timeWindow: "1 minute",
  });
 
  await app.register(authPlugin);
 
  // --- توثيق Swagger ---
  await app.register(swagger, {
    openapi: {
      info: {
        title: "Fastify Tasks API",
        version: "1.0.0",
        description: "واجهة برمجة تطبيقات لإدارة المهام جاهزة للإنتاج",
      },
      components: {
        securitySchemes: {
          bearerAuth: {
            type: "http",
            scheme: "bearer",
            bearerFormat: "JWT",
          },
        },
      },
      security: [{ bearerAuth: [] }],
    },
  });
 
  await app.register(swaggerUi, {
    routePrefix: "/docs",
  });
 
  // --- المسارات ---
  await app.register(tasksRoutes, { prefix: "/api/tasks" });
 
  // --- فحص الصحة ---
  app.get("/health", async () => ({
    status: "ok",
    timestamp: new Date().toISOString(),
    uptime: process.uptime(),
  }));
 
  // --- معالج الأخطاء العام ---
  app.setErrorHandler((error, request, reply) => {
    if (error instanceof AppError) {
      return reply.status(error.statusCode).send({
        error: error.code,
        message: error.message,
        statusCode: error.statusCode,
      });
    }
 
    if (error.validation) {
      return reply.status(400).send({
        error: "VALIDATION_ERROR",
        message: error.message,
        statusCode: 400,
      });
    }
 
    request.log.error(error);
    return reply.status(500).send({
      error: "INTERNAL_SERVER_ERROR",
      message:
        env.NODE_ENV === "production"
          ? "حدث خطأ غير متوقع"
          : error.message,
      statusCode: 500,
    });
  });
 
  return app;
}
// src/server.ts
import { buildApp } from "./app.js";
import { env } from "./config/env.js";
 
async function start() {
  const app = await buildApp();
 
  try {
    await app.listen({ port: env.PORT, host: env.HOST });
    app.log.info(`الخادم يعمل على http://${env.HOST}:${env.PORT}`);
    app.log.info(`توثيق API على http://${env.HOST}:${env.PORT}/docs`);
  } catch (err) {
    app.log.error(err);
    process.exit(1);
  }
}
 
start();

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

npm run dev

زر http://localhost:3000/docs لمشاهدة توثيق Swagger المُولّد تلقائيًا.

الخطوة 11: إضافة الإيقاف الآمن

تحتاج خوادم الإنتاج إلى التعامل مع إشارات الإيقاف بشكل صحيح — إنهاء الطلبات الجارية قبل الخروج:

// أضف إلى src/server.ts بعد app.listen()
 
const signals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
 
for (const signal of signals) {
  process.on(signal, async () => {
    app.log.info(`تم استلام ${signal}، جارٍ الإيقاف الآمن...`);
    await app.close();
    process.exit(0);
  });
}
 
process.on("unhandledRejection", (err) => {
  app.log.error(err, "رفض غير معالج");
  process.exit(1);
});

الخطوة 12: الاختبار مع Vitest

تتيح لك طريقة inject في Fastify اختبار المسارات دون تشغيل خادم HTTP حقيقي:

// src/modules/tasks/__tests__/tasks.routes.test.ts
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { buildApp } from "../../../app.js";
import { type FastifyInstance } from "fastify";
 
describe("Tasks Routes", () => {
  let app: FastifyInstance;
  let authToken: string;
 
  beforeAll(async () => {
    app = await buildApp();
 
    const registerRes = await app.inject({
      method: "POST",
      url: "/api/auth/register",
      payload: {
        email: "test@example.com",
        password: "securepassword123",
        name: "Test User",
      },
    });
 
    const body = JSON.parse(registerRes.body);
    authToken = body.accessToken;
  });
 
  afterAll(async () => {
    await app.close();
  });
 
  it("should return 401 without auth token", async () => {
    const response = await app.inject({
      method: "GET",
      url: "/api/tasks",
    });
 
    expect(response.statusCode).toBe(401);
  });
 
  it("should create a task", async () => {
    const response = await app.inject({
      method: "POST",
      url: "/api/tasks",
      headers: {
        authorization: `Bearer ${authToken}`,
      },
      payload: {
        title: "كتابة التوثيق",
        description: "كتابة توثيق API لوحدة المهام",
        priority: "high",
        projectId: "some-project-id",
      },
    });
 
    expect(response.statusCode).toBe(201);
    const task = JSON.parse(response.body);
    expect(task.title).toBe("كتابة التوثيق");
    expect(task.priority).toBe("high");
  });
 
  it("should validate request body", async () => {
    const response = await app.inject({
      method: "POST",
      url: "/api/tasks",
      headers: {
        authorization: `Bearer ${authToken}`,
      },
      payload: {
        description: "بدون عنوان",
      },
    });
 
    expect(response.statusCode).toBe(400);
  });
 
  it("should return health check", async () => {
    const response = await app.inject({
      method: "GET",
      url: "/health",
    });
 
    expect(response.statusCode).toBe(200);
    const body = JSON.parse(response.body);
    expect(body.status).toBe("ok");
  });
});

هيّئ Vitest:

// vitest.config.ts
import { defineConfig } from "vitest/config";
 
export default defineConfig({
  test: {
    globals: true,
    environment: "node",
    include: ["src/**/*.test.ts"],
    coverage: {
      provider: "v8",
      reporter: ["text", "html"],
    },
  },
});

شغّل الاختبارات:

npm test

الخطوة 13: تكوين Docker

أنشئ Dockerfile متعدد المراحل للإنتاج:

# مرحلة البناء
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
 
# مرحلة الإنتاج
FROM node:20-alpine AS runner
WORKDIR /app
 
RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 fastify
 
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/drizzle ./drizzle
 
RUN npm ci --omit=dev
 
USER fastify
EXPOSE 3000
 
HEALTHCHECK --interval=30s --timeout=3s \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
 
CMD ["node", "dist/server.js"]

و docker-compose.yml للتطوير المحلي:

version: "3.8"
services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      - DATABASE_URL=postgres://postgres:postgres@db:5432/fastify_tasks
      - JWT_SECRET=dev-secret-that-is-at-least-32-chars-long
      - JWT_REFRESH_SECRET=dev-refresh-secret-32-chars-long-too
      - NODE_ENV=production
    depends_on:
      db:
        condition: service_healthy
 
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: fastify_tasks
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
 
volumes:
  pgdata:

نصائح الأداء

Fastify سريع بالفعل من البداية، لكن إليك طرق لاستخلاص مزيد من الأداء:

1. استخدم المخطط لتسلسل الاستجابة

عندما تعرّف مخططات الاستجابة، يستخدم Fastify مكتبة fast-json-stringify بدلاً من JSON.stringify، والتي يمكن أن تكون أسرع بمرتين:

schema: {
  response: {
    200: TaskResponse  // هذا يفعّل fast-json-stringify
  }
}

2. تجميع الاتصالات

هيّئ مجمع اتصالات PostgreSQL بناءً على حجم العمل:

const client = postgres(env.DATABASE_URL, {
  max: 20,            // الحد الأقصى لحجم المجمع
  idle_timeout: 30,   // إغلاق الاتصالات الخاملة بعد 30 ثانية
  connect_timeout: 10 // مهلة الاتصالات الجديدة
});

3. فعّل HTTP/2 للإنتاج

import { readFileSync } from "fs";
 
const app = Fastify({
  http2: true,
  https: {
    key: readFileSync("./certs/key.pem"),
    cert: readFileSync("./certs/cert.pem"),
  },
});

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

المشاكل الشائعة

أخطاء "Cannot find module" مع ESM: تأكد من أن package.json يحتوي على "type": "module" وأن جميع الاستيرادات المحلية تستخدم امتداد .js (حتى لملفات .ts). يحل TypeScript استيرادات .js إلى ملفات .ts أثناء الترجمة.

ترتيب تسجيل إضافات Fastify مهم: يجب تسجيل الإضافات قبل المسارات التي تعتمد عليها. سجّل دائمًا authPlugin قبل المسارات التي تستخدم fastify.authenticate.

أخطاء الأنواع مع request.user: تأكد من وجود تعزيز declare module "@fastify/jwt" في إضافة المصادقة. بدونه، لن يتعرف TypeScript على خاصية user في الطلبات.

التحقق من المخطط لا يعمل: تحقق من أنك تمرر مخططات TypeBox (وليس أنواع TypeScript العادية) إلى خيار schema. ينتج TypeBox كائنات JSON Schema في وقت التشغيل، وهذا ما يحتاجه Fastify.

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

الآن بعد أن أصبح لديك واجهة برمجة تطبيقات Fastify جاهزة للإنتاج، فكّر في إضافة:

  • دعم WebSocket مع @fastify/websocket للتحديثات الفورية
  • رفع الملفات مع @fastify/multipart
  • التخزين المؤقت مع @fastify/caching أو Redis
  • أدوات OpenTelemetry للتتبع الموزع
  • خط أنابيب CI/CD مع GitHub Actions للاختبار والنشر التلقائي

الخلاصة

لقد بنيت واجهة برمجة تطبيقات كاملة وجاهزة للإنتاج باستخدام Fastify و TypeScript. الجمع بين التحقق المدمج في Fastify، ومخططات TypeBox الآمنة الأنواع، واستعلامات Drizzle ORM الآمنة الأنواع، ومعالجة الأخطاء المنظمة يمنحك أساسًا متينًا يلتقط الأخطاء في وقت الترجمة ويتحقق من البيانات في وقت التشغيل.

تجعل بنية الإضافات في Fastify من السهل إضافة الميزات تدريجيًا. نمط مصنع التطبيق يحافظ على قابلية اختبار الكود، و Docker يجعل النشر متسقًا عبر البيئات. سواء كنت تبني خدمة مصغرة أو واجهة خلفية كاملة الميزات، تتوسع هذه البنية جيدًا مع نمو مشروعك.


هل تريد قراءة المزيد من الدروس التعليمية؟ تحقق من أحدث درس تعليمي لدينا على تحسين التواصل في GitLab باستخدام Webhooks.

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

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

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

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

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

تعلم كيفية بناء واجهة GraphQL API آمنة الأنواع بالكامل باستخدام Next.js 15 App Router و GraphQL Yoga و Pothos schema builder. يغطي هذا الدليل العملي تصميم المخططات والاستعلامات والتحولات والمصادقة وعميل React باستخدام urql.

30 د قراءة·