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
Sources