← From JavaScript to Fastify: one Task API, four levels
04 / 04 · 80 min

Fastify production API: schemas, hooks and HTTP tests

The final vertical slice: typed routes, JSON Schema, one error contract, request context, graceful shutdown and inject tests.

Lesson content is currently available in Russian.

What you will learn

  • →Создать Fastify plugin с маршрутами, не смешивая транспорт и Task Core.
  • →Валидировать body/params/response JSON Schema на HTTP-границе.
  • →Использовать hooks для сквозных задач, не пряча бизнес-логику.
  • →Проверять API через fastify.inject без занятого TCP-порта.
  • →Завершить capstone контрактом, тестами и перечнем production-ограничений.

Fastify plugin как модуль композиции

Создавайте сервер функцией buildApp, а маршруты оформляйте plugin. Это отделяет сборку зависимостей от listen и позволяет каждому тесту получить чистый экземпляр. Handler переводит HTTP-вход в вызов домена, но не реализует правила задачи заново.

import Fastify from "fastify";
import type { FastifyPluginAsync } from "fastify";

const taskRoutes: FastifyPluginAsync<{ store: TaskStore }> = async (app, opts) => {
  app.get("/tasks", async () => opts.store.list());
};

export function buildApp(store: TaskStore) {
  const app = Fastify({ logger: true });
  app.register(taskRoutes, { prefix: "/api", store });
  return app;
}

Не вызывайте listen внутри buildApp и не создавайте store в route-файле. Иначе тесты начнут конкурировать за порт и состояние. Encapsulation Fastify помогает локализовать decorators и hooks внутри plugin; это полезная архитектурная граница, а не просто способ разнести большой файл.

JSON Schema: runtime-контракт запроса и ответа

TypeScript исчезает после компиляции, поэтому HTTP-вход проверяет JSON Schema. Fastify компилирует схемы для валидации и сериализации. Response schema одновременно документирует форму и не даёт случайно вернуть внутреннее поле.

const taskSchema = {
  type: "object",
  additionalProperties: false,
  required: ["id", "title", "status", "createdAt"],
  properties: {
    id: { type: "string" },
    title: { type: "string" },
    status: { enum: ["todo", "doing", "done"] },
    createdAt: { type: "string", format: "date-time" },
  },
} as const;

app.post<{ Body: { title: string } }>("/tasks", {
  schema: {
    body: {
      type: "object", additionalProperties: false, required: ["title"],
      properties: { title: { type: "string", minLength: 1, maxLength: 200 } },
    },
    response: { 201: taskSchema },
  },
}, async (request, reply) => reply.code(201).send(store.create(request.body)));

Схема не должна расходиться с TypeScript-типом. В production выберите один способ совместного вывода или добавьте contract-тесты. Не принимайте additionalProperties молча на write-маршрутах: опечатка клиента иначе потеряется без ошибки.

Ошибки и hooks: одна ответственность на слой

setErrorHandler превращает известные доменные ошибки в стабильный JSON-контракт, а неизвестные оставляет 500 и логирует с request id. Не возвращайте stack клиенту. Hook подходит для request context, аутентификации, метрик и очистки ресурсов; переход статуса задачи остаётся в домене.

app.setErrorHandler((error, request, reply) => {
  const status = error.message === "TASK_NOT_FOUND" ? 404 : 500;
  if (status === 500) request.log.error({ err: error }, "request failed");
  reply.code(status).send({
    error: status === 404 ? "not_found" : "internal_error",
    requestId: request.id,
  });
});

app.addHook("onRequest", async (request) => {
  request.log.info({ method: request.method, url: request.url }, "request started");
});

Реальная авторизация должна завершиться до handler и передать проверенного пользователя; одного декодированного токена недостаточно, если учётная запись отключена. Не ловите каждую ошибку в каждом handler — это размножает разные форматы ответа. Но и не классифицируйте по тексту в большом проекте: в capstone замените message на собственный DomainError с code.

inject, graceful shutdown и capstone Definition of Done

fastify.inject отправляет виртуальный HTTP-запрос в приложение после готовности plugin-дерева, не открывая сетевой порт. Такой тест проверяет схему, handler, error mapping и сериализацию вместе.

import test from "node:test";
import assert from "node:assert/strict";

test("POST /api/tasks создаёт задачу", async (t) => {
  const app = buildApp(createTaskStore());
  t.after(() => app.close());

  const response = await app.inject({
    method: "POST", url: "/api/tasks", payload: { title: "Ship API" },
  });
  assert.equal(response.statusCode, 201);
  assert.equal(response.json().status, "todo");
});

В entrypoint вызывайте listen отдельно и на SIGTERM ожидайте app.close(): сервер прекратит принимать новые соединения и выполнит hooks завершения. Capstone готов, когда есть POST /tasks, GET /tasks, PATCH /tasks/:id/status; схемы запросов и ответов; единый error shape; тесты 201/200/400/404; отсутствие открытого порта в тестах; лог с request id; документированные ограничения файлового store.

Practice

Task API v4: финальный vertical slice

Подключите Task Core к Fastify. Реализуйте POST /api/tasks, GET /api/tasks и PATCH /api/tasks/:id/status, JSON Schema для params/body/response, DomainError mapping и graceful shutdown.

Deliverable

Запускаемый Fastify plugin и entrypoint, минимум восемь inject-тестов, README с curl-примерами и таблицей error contract. Все проверки проходят командами tsc --noEmit и npm test.

Checkpoint

Проведите десятиминутное production review финального Task API.

  • ✓ Handler делегирует правила Task Core и не дублирует переходы статусов.
  • ✓ Невалидные body/params отклоняются схемой, успешные ответы сериализуются response schema.
  • ✓ Известные и неизвестные ошибки имеют стабильный безопасный формат с requestId.
  • ✓ Inject-тесты закрывают happy path и 400/404 без реального TCP-порта.
  • ✓ SIGTERM вызывает await app.close(), а ограничения файлового хранилища явно описаны.

Check yourself

Почему TypeScript-типа Body недостаточно для HTTP-запроса?

01Почему TypeScript-типа Body недостаточно для HTTP-запроса?

Для чего лучше всего подходит onRequest hook?

02Для чего лучше всего подходит onRequest hook?

Главное преимущество fastify.inject в тесте?

03Главное преимущество fastify.inject в тесте?

Sources

Fastify production API: schemas, hooks and HTTP tests · From JavaScript to Fastify: one Task API, four levels · VORCL