api-framework-fastify
Fastify routes, JSON Schema validation, plugin system, TypeScript type providers
Install
npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/api-framework-fastify/skills/api-framework-fastify
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
git clone https://github.com/agents-inc/skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agents-inc/skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
API Development with Fastify
Quick Guide: Use Fastify for high-performance Node.js REST APIs with built-in JSON Schema validation and powerful plugin encapsulation. Use
@fastify/type-provider-typeboxfor end-to-end type safety (bothTypeandTypeBoxTypeProviderre-exported from it). Wrap shared plugins withfastify-pluginto expose decorators. Always define response schemas for serialization performance and data leak prevention.
<critical_requirements>
CRITICAL: Before Using This Skill
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering,
import type, named constants)
(You MUST use withTypeProvider<>() for type-safe request/response handling)
(You MUST wrap shared plugins with fastify-plugin to expose decorators to parent scope)
(You MUST define response schemas to enable fast-json-stringify optimization)
(You MUST use named constants for HTTP status codes - never raw numbers)
</critical_requirements>
Auto-detection: Fastify, fastify.register, fastify.decorate, fastify-plugin, TypeBox, @fastify/type-provider-typebox, @fastify/type-provider-json-schema-to-ts, fastify-type-provider-zod, preHandler, onRequest, preSerialization, JSON Schema validation, fast-json-stringify, FastifyPluginAsyncTypebox
When to use:
- Building high-performance REST APIs (45k+ req/sec benchmarks)
- Need schema-based validation with automatic coercion
- Want plugin encapsulation for modular architecture
- Require lifecycle hooks for cross-cutting concerns
- Building APIs with strict TypeScript type safety requirements
When NOT to use:
- Simple internal APIs without performance requirements (consider your existing solution)
- GraphQL APIs (use dedicated GraphQL servers)
- Edge/serverless with size constraints (Fastify has larger footprint than minimal frameworks)
- When middleware ecosystem compatibility with Express is required
Key patterns covered:
- Server setup with TypeScript type providers
- Plugin system and encapsulation patterns
- JSON Schema validation for request/response
- Lifecycle hooks (onRequest, preHandler, onSend, etc.)
- Decorators for extending Fastify/Request/Reply
- Error handling with setErrorHandler
- Route organization with prefix patterns
Detailed Resources:
- examples/core.md - Server setup, routes, schemas, error handling, testing
- examples/plugins.md - Plugin system, encapsulation, decorators
- examples/schemas.md - TypeBox schemas, validation, type-safe routes
- examples/hooks.md - Lifecycle hooks and cross-cutting concerns
- reference.md - Decision frameworks, anti-patterns, quick reference
<red_flags>
RED FLAGS
High Priority Issues
- No type provider configured - Loses compile-time type safety on request/response
- Shared plugins without
fastify-plugin- Decorators invisible to other plugins - Missing response schemas - Loses 2-3x serialization performance AND risks data leaks
- Raw status code numbers - Use named constants (
HTTP_OK,HTTP_NOT_FOUND) - Reference types in
decorateRequest/decorateReply- Shared mutable state across ALL requests (security risk)
Medium Priority Issues
- No error handler configured - Stack traces exposed to clients in production
- Missing
dependenciesin plugin options - Race conditions on decorator access - No schema for query/params - No validation, types are
unknown - Inline route handlers in god files - Use modular route plugins with prefix
Common Mistakes
- Forgetting
await server.ready()- Plugins may not be fully loaded - Not cleaning up in
onClose- Connection leaks on shutdown - Mixing async/await with
donecallback - Pick one pattern per hook (causes double-completion) - Using Express patterns -
res.send()vsreply.send(),next()vs returning
Gotchas & Edge Cases
- Hook return values: Returning a value from hooks sends response immediately (short-circuits)
- Plugin registration order: Later plugins can't access earlier encapsulated decorators
- Validation error shape: Fastify validation errors have
.validationarray, not.message - Route specificity: More specific routes must be registered before wildcards
- preHandler order: Route-level runs AFTER plugin-level hooks
- onResponse timing: Runs after response sent, cannot modify response
- Schema compilation: Happens at startup, errors surface during
server.ready() - v5 redirect order:
reply.redirect(url, statusCode)notreply.redirect(statusCode, url)(reversed from v4) - v5 reply.sent: Use
reply.hijack()instead of settingreply.sent = true
</red_flags>
<critical_reminders>
CRITICAL REMINDERS
All code must follow project conventions in CLAUDE.md
(You MUST use withTypeProvider<>() for type-safe request/response handling)
(You MUST wrap shared plugins with fastify-plugin to expose decorators to parent scope)
(You MUST define response schemas to enable fast-json-stringify optimization)
(You MUST use named constants for HTTP status codes - never raw numbers)
Failure to follow these rules will break type safety and lose performance benefits.
</critical_reminders>
Files (skills)
-
examples
-
core.md 9.3 KB
# Fastify - Core Examples > Essential patterns for server setup, route definition, error handling, and testing. See [SKILL.md](../SKILL.md) for decision guidance. **Prerequisites**: None - start here. --- ## Pattern 1: Server Setup with Type Provider ### Good Example - Factory Pattern with TypeBox ```typescript // src/server.ts import Fastify from "fastify"; import { TypeBoxTypeProvider } from "@fastify/type-provider-typebox"; import { errorHandler } from "./plugins/error-handler"; import { userRoutes } from "./routes/users"; const SERVER_PORT = 3000; const SERVER_HOST = "0.0.0.0"; const buildServer = () => { const server = Fastify({ logger: { level: process.env.LOG_LEVEL ?? "info", }, }).withTypeProvider<TypeBoxTypeProvider>(); // Register global error handler server.setErrorHandler(errorHandler); // Register route plugins with prefixes server.register(userRoutes, { prefix: "/api/users" }); return server; }; const start = async () => { const server = buildServer(); try { await server.listen({ port: SERVER_PORT, host: SERVER_HOST }); } catch (error) { server.log.error(error); process.exit(1); } }; // Named exports export { buildServer, start }; ``` **Why good:** TypeBox provider enables type inference from schemas, factory function enables testing, logger configured from environment ### Bad Example - No Type Provider ```typescript // WRONG: No type provider, inline configuration import Fastify from "fastify"; const server = Fastify(); server.get("/users", async (req, reply) => { const limit = req.query.limit; // any type! return { users: [] }; }); server.listen({ port: 3000 }); ``` **Why bad:** No type safety on request/response, magic number for port, no error handling for startup --- ## Pattern 2: Route Definition with Full Schema ### Good Example - Complete CRUD Routes ```typescript // src/routes/users.ts import type { FastifyPluginAsync } from "fastify"; import { Type } from "@fastify/type-provider-typebox"; import { UserSchema, CreateUserSchema, UserParamsSchema, UsersQuerySchema, } from "../schemas/user"; const HTTP_OK = 200; const HTTP_CREATED = 201; const HTTP_NOT_FOUND = 404; const ErrorSchema = Type.Object({ statusCode: Type.Integer(), error: Type.String(), message: Type.String(), }); export const userRoutes: FastifyPluginAsync = async (fastify) => { // GET /api/users fastify.get( "/", { schema: { querystring: UsersQuerySchema, response: { [HTTP_OK]: Type.Object({ users: Type.Array(UserSchema), total: Type.Integer(), }), }, }, }, async (request, reply) => { const { limit, offset } = request.query; // request.query is typed as UsersQuery const users = await fastify.userService.list({ limit, offset }); return reply.status(HTTP_OK).send({ users, total: users.length, }); }, ); // GET /api/users/:id fastify.get( "/:id", { schema: { params: UserParamsSchema, response: { [HTTP_OK]: UserSchema, [HTTP_NOT_FOUND]: ErrorSchema, }, }, }, async (request, reply) => { const { id } = request.params; // request.params is typed as UserParams const user = await fastify.userService.findById(id); if (!user) { return reply.status(HTTP_NOT_FOUND).send({ statusCode: HTTP_NOT_FOUND, error: "Not Found", message: `User ${id} not found`, }); } return reply.status(HTTP_OK).send(user); }, ); // POST /api/users fastify.post( "/", { schema: { body: CreateUserSchema, response: { [HTTP_CREATED]: UserSchema, }, }, }, async (request, reply) => { const userData = request.body; // request.body is typed as CreateUser const user = await fastify.userService.create(userData); return reply.status(HTTP_CREATED).send(user); }, ); }; ``` **Why good:** Response schemas enable fast-json-stringify optimization (2-3x faster), full type inference on request objects, HTTP constants prevent magic numbers --- ## Pattern 3: Error Handling ### Good Example - Centralized Error Handler ```typescript // src/plugins/error-handler.ts import type { FastifyError, FastifyReply, FastifyRequest } from "fastify"; const HTTP_BAD_REQUEST = 400; const HTTP_NOT_FOUND = 404; const HTTP_INTERNAL_ERROR = 500; // Custom error classes export class NotFoundError extends Error { statusCode = HTTP_NOT_FOUND; constructor(resource: string, id: string) { super(`${resource} with id ${id} not found`); this.name = "NotFoundError"; } } export class ValidationError extends Error { statusCode = HTTP_BAD_REQUEST; details: unknown; constructor(message: string, details?: unknown) { super(message); this.name = "ValidationError"; this.details = details; } } export const errorHandler = ( error: FastifyError, request: FastifyRequest, reply: FastifyReply, ) => { // Fastify validation errors if (error.validation) { request.log.warn( { reqId: request.id, validation: error.validation }, "Validation failed", ); return reply.status(HTTP_BAD_REQUEST).send({ statusCode: HTTP_BAD_REQUEST, error: "Bad Request", message: "Validation failed", details: error.validation, }); } // Custom application errors if ("statusCode" in error && typeof error.statusCode === "number") { request.log.warn( { reqId: request.id, error: error.message }, "Application error", ); return reply.status(error.statusCode).send({ statusCode: error.statusCode, error: error.name, message: error.message, ...("details" in error ? { details: error.details } : {}), }); } // Unexpected errors - log full details but send generic response request.log.error( { reqId: request.id, error: error.message, stack: error.stack }, "Unexpected error", ); return reply.status(HTTP_INTERNAL_ERROR).send({ statusCode: HTTP_INTERNAL_ERROR, error: "Internal Server Error", message: "An unexpected error occurred", }); }; ``` **Why good:** Fastify validation errors handled specially (expose details), custom errors with statusCode, unexpected errors logged with stack but hidden from client --- ## Pattern 4: Service Injection via Decorators ### Good Example - Registration Order ```typescript // src/server.ts import Fastify from "fastify"; import { TypeBoxTypeProvider } from "@fastify/type-provider-typebox"; import { appConfig } from "./plugins/config"; import { services } from "./plugins/services"; import { userRoutes } from "./routes/users"; const buildServer = () => { const server = Fastify({ logger: { level: process.env.LOG_LEVEL ?? "info" }, }).withTypeProvider<TypeBoxTypeProvider>(); // 1. Infrastructure plugins first (no dependencies) server.register(appConfig); // 2. Service plugins (may depend on infrastructure) server.register(services); // 3. Route plugins last (depend on all above) server.register(userRoutes, { prefix: "/api/users" }); return server; }; export { buildServer }; ``` **Why good:** Dependencies declared explicitly, clear registration order, infrastructure before services before routes --- ## Pattern 5: Testing with server.inject() ### Good Example - Test Structure ```typescript // src/server.test.ts import { describe, it, expect, beforeEach, afterEach } from "your-test-runner"; import { buildServer } from "./server"; describe("User API", () => { let server: ReturnType<typeof buildServer>; beforeEach(async () => { server = buildServer(); await server.ready(); }); afterEach(async () => { await server.close(); }); it("should list users", async () => { const response = await server.inject({ method: "GET", url: "/api/users", query: { limit: "10" }, }); expect(response.statusCode).toBe(200); const body = response.json(); expect(body).toHaveProperty("users"); expect(body).toHaveProperty("total"); }); it("should validate query parameters", async () => { const response = await server.inject({ method: "GET", url: "/api/users", query: { limit: "-1" }, // Invalid: below minimum }); expect(response.statusCode).toBe(400); const body = response.json(); expect(body.message).toContain("Validation failed"); }); it("should create user with valid data", async () => { const response = await server.inject({ method: "POST", url: "/api/users", payload: { username: "testuser", email: "test@example.com", }, }); expect(response.statusCode).toBe(201); const body = response.json(); expect(body.username).toBe("testuser"); expect(body.id).toBeDefined(); }); it("should reject invalid email format", async () => { const response = await server.inject({ method: "POST", url: "/api/users", payload: { username: "testuser", email: "not-an-email", }, }); expect(response.statusCode).toBe(400); }); }); ``` **Why good:** `server.inject()` tests without network overhead, `beforeEach`/`afterEach` ensures clean state, tests validation and success paths, no external test runner dependency -
hooks.md 12.1 KB
# Fastify - Lifecycle Hooks Examples > Request lifecycle, hooks, and cross-cutting concerns. See [SKILL.md](../SKILL.md) for decision guidance. **Prerequisites**: Understand [Pattern 5: Lifecycle Hooks](../SKILL.md) from core patterns. --- ## Hook Execution Order Fastify hooks execute in this order: 1. `onRequest` - Before parsing (first hook) 2. `preParsing` - Before body parsing 3. `preValidation` - Before schema validation 4. `preHandler` - After validation, before handler 5. `preSerialization` - Before response serialization 6. `onSend` - Before sending response 7. `onResponse` - After response sent (async, non-blocking) 8. `onError` - When error occurs (can modify response) --- ## onRequest Hook ### Good Example - Request Timing and ID ```typescript // src/plugins/request-timing.ts import fp from "fastify-plugin"; import type { FastifyPluginAsync } from "fastify"; import { randomUUID } from "node:crypto"; const REQUEST_ID_HEADER = "x-request-id"; declare module "fastify" { interface FastifyRequest { requestId: string; startTime: bigint; } } const requestTimingPlugin: FastifyPluginAsync = async (fastify) => { fastify.decorateRequest("requestId", ""); fastify.decorateRequest("startTime", BigInt(0)); // onRequest: First hook, before any parsing fastify.addHook("onRequest", async (request) => { request.requestId = request.headers[REQUEST_ID_HEADER]?.toString() ?? randomUUID(); request.startTime = process.hrtime.bigint(); request.log.info({ requestId: request.requestId }, "Request started"); }); }; export const requestTiming = fp(requestTimingPlugin, { name: "request-timing", }); ``` **Why good:** Captures timing before any processing, generates request ID if not provided, logs request start --- ## preHandler Hook ### Good Example - Authentication ```typescript // src/plugins/auth.ts import fp from "fastify-plugin"; import type { FastifyPluginAsync, FastifyRequest, FastifyReply } from "fastify"; const HTTP_UNAUTHORIZED = 401; const BEARER_PREFIX = "Bearer "; declare module "fastify" { interface FastifyRequest { userId: string | null; userRole: string | null; } } // Route-level hook for authentication export const requireAuth = async ( request: FastifyRequest, reply: FastifyReply, ) => { const authHeader = request.headers.authorization; if (!authHeader || !authHeader.startsWith(BEARER_PREFIX)) { return reply.status(HTTP_UNAUTHORIZED).send({ statusCode: HTTP_UNAUTHORIZED, error: "Unauthorized", message: "Missing or invalid authorization header", }); } const token = authHeader.slice(BEARER_PREFIX.length); try { const decoded = await request.server.authService.verifyToken(token); request.userId = decoded.userId; request.userRole = decoded.role; } catch { return reply.status(HTTP_UNAUTHORIZED).send({ statusCode: HTTP_UNAUTHORIZED, error: "Unauthorized", message: "Invalid or expired token", }); } }; // Plugin to set up request decorators const authPlugin: FastifyPluginAsync = async (fastify) => { fastify.decorateRequest("userId", null); fastify.decorateRequest("userRole", null); }; export const auth = fp(authPlugin, { name: "auth", }); ``` **Usage in routes:** ```typescript // Apply to specific routes fastify.get( "/profile", { preHandler: [requireAuth] }, async (request, reply) => { // request.userId is set by auth hook const user = await fastify.userService.findById(request.userId!); return user; }, ); // Apply to all routes in a plugin export const protectedRoutes: FastifyPluginAsync = async (fastify) => { fastify.addHook("preHandler", requireAuth); fastify.get("/dashboard", async (request) => { // All routes here require auth }); }; ``` **Why good:** preHandler runs after validation (body is parsed), can short-circuit with reply, sets request decorators for downstream use --- ## onResponse Hook ### Good Example - Request Logging and Metrics ```typescript // src/plugins/request-logger.ts import fp from "fastify-plugin"; import type { FastifyPluginAsync } from "fastify"; const requestLoggerPlugin: FastifyPluginAsync = async (fastify) => { // onResponse: After response sent (non-blocking) fastify.addHook("onResponse", async (request, reply) => { const duration = process.hrtime.bigint() - request.startTime; const durationMs = Number(duration) / 1e6; request.log.info( { requestId: request.requestId, method: request.method, url: request.url, statusCode: reply.statusCode, durationMs: durationMs.toFixed(2), contentLength: reply.getHeader("content-length"), }, "Request completed", ); // Send to metrics system if (fastify.metrics) { fastify.metrics.recordRequest({ method: request.method, path: request.routeOptions.url, statusCode: reply.statusCode, duration: durationMs, }); } }); }; export const requestLogger = fp(requestLoggerPlugin, { name: "request-logger", dependencies: ["request-timing"], }); ``` **Why good:** onResponse is async and non-blocking (doesn't delay response), captures final status code and timing, good for metrics --- ## onError Hook ### Good Example - Error Logging and Transformation ```typescript // src/plugins/error-logger.ts import fp from "fastify-plugin"; import type { FastifyPluginAsync } from "fastify"; interface AppError extends Error { statusCode?: number; code?: string; } const errorLoggerPlugin: FastifyPluginAsync = async (fastify) => { // onError: When error occurs in handler fastify.addHook("onError", async (request, reply, error: AppError) => { const isClientError = error.statusCode && error.statusCode >= 400 && error.statusCode < 500; if (isClientError) { // Log client errors as warnings request.log.warn( { requestId: request.requestId, error: error.message, code: error.code, statusCode: error.statusCode, }, "Client error", ); } else { // Log server errors with full stack request.log.error( { requestId: request.requestId, error: error.message, code: error.code, stack: error.stack, statusCode: error.statusCode || 500, }, "Server error", ); // Report to error tracking service if (fastify.errorTracker) { fastify.errorTracker.capture(error, { requestId: request.requestId, userId: request.userId, url: request.url, }); } } }); }; export const errorLogger = fp(errorLoggerPlugin, { name: "error-logger", dependencies: ["request-timing"], }); ``` **Why good:** Distinguishes client vs server errors for logging levels, captures full context, integrates with error tracking --- ## preSerialization Hook ### Good Example - Response Transformation ```typescript // src/plugins/response-wrapper.ts import fp from "fastify-plugin"; import type { FastifyPluginAsync } from "fastify"; const HTTP_SUCCESS_MIN = 200; const HTTP_SUCCESS_MAX = 299; interface WrappedResponse { success: boolean; data: unknown; meta?: { requestId: string; timestamp: string; }; } const responseWrapperPlugin: FastifyPluginAsync = async (fastify) => { // preSerialization: Transform payload before stringify fastify.addHook("preSerialization", async (request, reply, payload) => { // Skip if already wrapped or error response if (payload && typeof payload === "object" && "success" in payload) { return payload; } // Only wrap successful responses const isSuccess = reply.statusCode >= HTTP_SUCCESS_MIN && reply.statusCode <= HTTP_SUCCESS_MAX; if (!isSuccess) { return payload; } const wrapped: WrappedResponse = { success: true, data: payload, meta: { requestId: request.requestId, timestamp: new Date().toISOString(), }, }; return wrapped; }); }; export const responseWrapper = fp(responseWrapperPlugin, { name: "response-wrapper", dependencies: ["request-timing"], }); ``` **Why good:** preSerialization transforms before JSON.stringify, conditionally wraps only success responses, adds metadata --- ## onSend Hook ### Good Example - Response Headers ```typescript // src/plugins/response-headers.ts import fp from "fastify-plugin"; import type { FastifyPluginAsync } from "fastify"; const responseHeadersPlugin: FastifyPluginAsync = async (fastify) => { // onSend: Last chance to modify response fastify.addHook("onSend", async (request, reply, payload) => { // Add request ID to response headers reply.header("x-request-id", request.requestId); // Add cache headers for GET requests if (request.method === "GET" && !reply.hasHeader("cache-control")) { reply.header("cache-control", "no-store"); } // Calculate and add duration header if (request.startTime) { const duration = process.hrtime.bigint() - request.startTime; const durationMs = Number(duration) / 1e6; reply.header("x-response-time", `${durationMs.toFixed(2)}ms`); } return payload; }); }; export const responseHeaders = fp(responseHeadersPlugin, { name: "response-headers", dependencies: ["request-timing"], }); ``` **Why good:** onSend is last chance before response, adds correlation headers, calculates response time --- ## Route-Level vs Plugin-Level Hooks ### Good Example - Selective Hook Application ```typescript // src/routes/admin.ts import type { FastifyPluginAsync } from "fastify"; import { requireAuth } from "../plugins/auth"; const HTTP_FORBIDDEN = 403; // Admin authorization hook const requireAdmin = async (request: FastifyRequest, reply: FastifyReply) => { if (request.userRole !== "admin") { return reply.status(HTTP_FORBIDDEN).send({ statusCode: HTTP_FORBIDDEN, error: "Forbidden", message: "Admin access required", }); } }; export const adminRoutes: FastifyPluginAsync = async (fastify) => { // Plugin-level: applies to ALL routes in this plugin fastify.addHook("preHandler", requireAuth); fastify.addHook("preHandler", requireAdmin); // All routes below require admin auth fastify.get("/stats", async () => { return fastify.statsService.getAll(); }); fastify.get("/users", async () => { return fastify.userService.listAll(); }); }; // Alternative: Route-level hooks export const mixedRoutes: FastifyPluginAsync = async (fastify) => { // Public route - no hooks fastify.get("/public", async () => { return { message: "Public content" }; }); // Protected route - route-level preHandler fastify.get("/protected", { preHandler: [requireAuth] }, async (request) => { return { userId: request.userId }; }); // Admin route - multiple route-level hooks fastify.delete( "/users/:id", { preHandler: [requireAuth, requireAdmin] }, async (request) => { // Only admins }, ); }; ``` **Why good:** Plugin-level for consistent protection, route-level for selective application, hooks execute in array order --- ## Quick Reference | Hook | Timing | Use For | | ------------------ | ----------------- | ----------------------------- | | `onRequest` | Before parsing | Request ID, timing start | | `preParsing` | Before body parse | Stream transformation | | `preValidation` | Before validation | Custom validation | | `preHandler` | After validation | Auth, authorization | | `preSerialization` | Before stringify | Response transformation | | `onSend` | Before send | Headers, final modifications | | `onResponse` | After sent | Logging, metrics | | `onError` | On error | Error logging, transformation | | Hook Level | Applies To | | ------------------------------------- | ------------------------- | | `fastify.addHook()` in plugin | All routes in that plugin | | Route options `{ preHandler: [...] }` | Single route | | Root-level after fp() plugin | All routes | -
plugins.md 11.1 KB
# Fastify - Plugin Examples > Plugin system, encapsulation, and decorators. See [SKILL.md](../SKILL.md) for decision guidance. **Prerequisites**: Understand [Pattern 4: Plugin Encapsulation](../SKILL.md) from core patterns. --- ## Plugin Basics ### Good Example - Encapsulated Domain Plugin ```typescript // src/plugins/auth-routes.ts import type { FastifyPluginAsync } from "fastify"; import { Type } from "@fastify/type-provider-typebox"; const HTTP_OK = 200; const HTTP_CREATED = 201; const HTTP_UNAUTHORIZED = 401; // This plugin is ENCAPSULATED - decorators stay within this scope export const authRoutes: FastifyPluginAsync = async (fastify) => { // Local decorator - only available in this plugin fastify.decorate("authConfig", { tokenExpiry: 3600, refreshExpiry: 86400, }); fastify.post( "/login", { schema: { body: Type.Object({ email: Type.String({ format: "email" }), password: Type.String({ minLength: 8 }), }), response: { [HTTP_OK]: Type.Object({ token: Type.String(), expiresIn: Type.Integer(), }), [HTTP_UNAUTHORIZED]: Type.Object({ message: Type.String(), }), }, }, }, async (request, reply) => { const { email, password } = request.body; const user = await validateCredentials(email, password); if (!user) { return reply.status(HTTP_UNAUTHORIZED).send({ message: "Invalid credentials", }); } const token = generateToken(user, fastify.authConfig.tokenExpiry); return reply.status(HTTP_OK).send({ token, expiresIn: fastify.authConfig.tokenExpiry, }); }, ); fastify.post("/logout", async (request, reply) => { // Logout logic return reply.status(HTTP_OK).send({ success: true }); }); }; ``` **Why good:** Plugin is encapsulated, authConfig decorator only accessible within this plugin, schemas define request/response types --- ## Shared Plugins with fastify-plugin ### Good Example - Database Connection Plugin ```typescript // src/plugins/database.ts import fp from "fastify-plugin"; import type { FastifyPluginAsync } from "fastify"; // Type augmentation for shared decorator declare module "fastify" { interface FastifyInstance { db: { query: <T>(sql: string, params?: unknown[]) => Promise<T[]>; close: () => Promise<void>; }; } } const databasePlugin: FastifyPluginAsync = async (fastify) => { // Initialize database connection const pool = createDatabasePool({ host: process.env.DB_HOST, port: Number(process.env.DB_PORT), database: process.env.DB_NAME, }); const db = { query: async <T>(sql: string, params?: unknown[]): Promise<T[]> => { const result = await pool.query(sql, params); return result.rows as T[]; }, close: async () => { await pool.end(); }, }; // Expose to parent scope via fastify-plugin fastify.decorate("db", db); // Cleanup on server close fastify.addHook("onClose", async () => { await db.close(); fastify.log.info("Database connection closed"); }); }; // Wrap with fastify-plugin to break encapsulation export const database = fp(databasePlugin, { name: "database", dependencies: [], // No dependencies }); ``` **Why good:** fastify-plugin exposes decorator to parent scope, type augmentation provides TypeScript support, onClose hook for cleanup ### Bad Example - Shared Plugin Without fp ```typescript // WRONG - Decorator not visible to other plugins import type { FastifyPluginAsync } from "fastify"; export const databasePlugin: FastifyPluginAsync = async (fastify) => { fastify.decorate("db", createDatabase()); // This decorator is encapsulated! }; // In another plugin: fastify.register(databasePlugin); fastify.register(async (f) => { f.db; // undefined! Encapsulation blocks access }); ``` **Why bad:** Without fastify-plugin wrapper, decorators are encapsulated and invisible to sibling plugins --- ## Plugin Registration Order ### Good Example - Dependency Management ```typescript // src/server.ts import Fastify from "fastify"; import { TypeBoxTypeProvider } from "@fastify/type-provider-typebox"; import { database } from "./plugins/database"; import { auth } from "./plugins/auth"; import { requestContext } from "./plugins/request-context"; import { userRoutes } from "./routes/users"; import { productRoutes } from "./routes/products"; const SERVER_PORT = 3000; const SERVER_HOST = "0.0.0.0"; const buildServer = () => { const server = Fastify({ logger: { level: process.env.LOG_LEVEL ?? "info" }, }).withTypeProvider<TypeBoxTypeProvider>(); // 1. Infrastructure plugins first (no dependencies) server.register(database); server.register(requestContext); // 2. Middleware plugins (may depend on infrastructure) server.register(auth); // 3. Route plugins last (depend on all above) server.register(userRoutes, { prefix: "/api/users" }); server.register(productRoutes, { prefix: "/api/products" }); return server; }; export { buildServer }; ``` **Why good:** Clear registration order, infrastructure before middleware before routes, prefix option for route namespacing --- ## Plugin with Dependencies ### Good Example - Declaring Dependencies ```typescript // src/plugins/user-service.ts import fp from "fastify-plugin"; import type { FastifyPluginAsync } from "fastify"; interface UserService { findById: (id: string) => Promise<User | null>; create: (data: CreateUserInput) => Promise<User>; update: (id: string, data: UpdateUserInput) => Promise<User>; } declare module "fastify" { interface FastifyInstance { userService: UserService; } } const userServicePlugin: FastifyPluginAsync = async (fastify) => { // Access db decorator from database plugin const { db } = fastify; const userService: UserService = { findById: async (id) => { const [user] = await db.query<User>("SELECT * FROM users WHERE id = $1", [ id, ]); return user || null; }, create: async (data) => { const [user] = await db.query<User>( "INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *", [data.name, data.email], ); return user; }, update: async (id, data) => { const [user] = await db.query<User>( "UPDATE users SET name = $1 WHERE id = $2 RETURNING *", [data.name, id], ); return user; }, }; fastify.decorate("userService", userService); }; export const userService = fp(userServicePlugin, { name: "user-service", dependencies: ["database"], // Requires database plugin }); ``` **Why good:** dependencies array ensures registration order, TypeScript augmentation for type safety, uses db decorator from database plugin --- ## Request Decorators ### Good Example - Per-Request State ```typescript // src/plugins/request-context.ts import fp from "fastify-plugin"; import type { FastifyPluginAsync } from "fastify"; import { randomUUID } from "node:crypto"; const REQUEST_ID_HEADER = "x-request-id"; declare module "fastify" { interface FastifyRequest { requestId: string; startTime: bigint; userId: string | null; } } const requestContextPlugin: FastifyPluginAsync = async (fastify) => { // Decorate with initial values fastify.decorateRequest("requestId", ""); fastify.decorateRequest("startTime", BigInt(0)); fastify.decorateRequest("userId", null); // Set values per-request fastify.addHook("onRequest", async (request) => { request.requestId = request.headers[REQUEST_ID_HEADER]?.toString() ?? randomUUID(); request.startTime = process.hrtime.bigint(); }); }; export const requestContext = fp(requestContextPlugin, { name: "request-context", }); ``` **Usage in routes:** ```typescript fastify.get("/", async (request, reply) => { // Access request decorators request.log.info({ requestId: request.requestId }, "Processing request"); return { requestId: request.requestId }; }); ``` **Why good:** decorateRequest with initial values, onRequest hook sets per-request state, type-safe access in routes --- ## Reply Decorators ### Good Example - Response Helpers ```typescript // src/plugins/reply-helpers.ts import fp from "fastify-plugin"; import type { FastifyPluginAsync, FastifyReply } from "fastify"; const HTTP_BAD_REQUEST = 400; const HTTP_NOT_FOUND = 404; const HTTP_CONFLICT = 409; declare module "fastify" { interface FastifyReply { notFound: (message: string) => void; badRequest: (message: string, details?: unknown) => void; conflict: (message: string) => void; } } const replyHelpersPlugin: FastifyPluginAsync = async (fastify) => { fastify.decorateReply( "notFound", function (this: FastifyReply, message: string) { this.status(HTTP_NOT_FOUND).send({ statusCode: HTTP_NOT_FOUND, error: "Not Found", message, }); }, ); fastify.decorateReply( "badRequest", function (this: FastifyReply, message: string, details?: unknown) { this.status(HTTP_BAD_REQUEST).send({ statusCode: HTTP_BAD_REQUEST, error: "Bad Request", message, ...(details ? { details } : {}), }); }, ); fastify.decorateReply( "conflict", function (this: FastifyReply, message: string) { this.status(HTTP_CONFLICT).send({ statusCode: HTTP_CONFLICT, error: "Conflict", message, }); }, ); }; export const replyHelpers = fp(replyHelpersPlugin, { name: "reply-helpers", }); ``` **Usage:** ```typescript fastify.get("/:id", async (request, reply) => { const user = await fastify.userService.findById(request.params.id); if (!user) { return reply.notFound(`User ${request.params.id} not found`); } return user; }); fastify.post("/", async (request, reply) => { const exists = await fastify.userService.findByEmail(request.body.email); if (exists) { return reply.conflict("Email already registered"); } return fastify.userService.create(request.body); }); ``` **Why good:** Reply helpers reduce boilerplate, consistent error format, `this` binding for access to reply instance --- ## Quick Reference | Plugin Type | Wrapper | Decorator Visibility | | --------------------- | ------- | ------------------------- | | Domain/route plugin | None | Encapsulated (local only) | | Shared infrastructure | `fp()` | Exposed to parent scope | | Utility plugin | `fp()` | Exposed to parent scope | | fp() Option | Purpose | | -------------- | ----------------------------------- | | `name` | Plugin identifier for debugging | | `dependencies` | Required plugins (registered first) | | `fastify` | Fastify version constraint | | `encapsulate` | Override encapsulation behavior | | Decorator Type | Initial Value | Set Per-Request | | ------------------- | -------------------- | ------------------- | | `decorate()` | Any | No (instance-level) | | `decorateRequest()` | Required | Yes (in hooks) | | `decorateReply()` | Function with `this` | No (method) | -
schemas.md 12.4 KB
# Fastify - Schema Examples > TypeBox schemas, validation, and type-safe routes. See [SKILL.md](../SKILL.md) for decision guidance. **Prerequisites**: Understand [Pattern 2: Schema Definition](../SKILL.md) from core patterns. --- ## Pattern 3: Complete Schema Set ### Good Example - Full Domain Schemas ```typescript // src/schemas/user.ts import { Type, Static } from "@fastify/type-provider-typebox"; // Named constants for validation constraints const MIN_USERNAME_LENGTH = 3; const MAX_USERNAME_LENGTH = 50; const MIN_PASSWORD_LENGTH = 8; const MIN_AGE = 0; const MAX_AGE = 150; // Base user schema export const UserSchema = Type.Object({ id: Type.String({ format: "uuid" }), username: Type.String({ minLength: MIN_USERNAME_LENGTH, maxLength: MAX_USERNAME_LENGTH, }), email: Type.String({ format: "email" }), age: Type.Optional(Type.Integer({ minimum: MIN_AGE, maximum: MAX_AGE })), role: Type.Union([ Type.Literal("user"), Type.Literal("admin"), Type.Literal("moderator"), ]), createdAt: Type.String({ format: "date-time" }), updatedAt: Type.String({ format: "date-time" }), }); // Create input (subset of User) export const CreateUserSchema = Type.Object({ username: Type.String({ minLength: MIN_USERNAME_LENGTH, maxLength: MAX_USERNAME_LENGTH, }), email: Type.String({ format: "email" }), password: Type.String({ minLength: MIN_PASSWORD_LENGTH }), age: Type.Optional(Type.Integer({ minimum: MIN_AGE, maximum: MAX_AGE })), }); // Update input (all optional) export const UpdateUserSchema = Type.Partial( Type.Pick(CreateUserSchema, ["username", "email", "age"]), ); // Route params export const UserParamsSchema = Type.Object({ id: Type.String({ format: "uuid" }), }); // Query params for list export const UsersQuerySchema = Type.Object({ page: Type.Optional(Type.Integer({ minimum: 1, default: 1 })), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, default: 20 })), sort: Type.Optional( Type.Union([ Type.Literal("username"), Type.Literal("email"), Type.Literal("createdAt"), ]), ), order: Type.Optional(Type.Union([Type.Literal("asc"), Type.Literal("desc")])), }); // Derive TypeScript types export type User = Static<typeof UserSchema>; export type CreateUser = Static<typeof CreateUserSchema>; export type UpdateUser = Static<typeof UpdateUserSchema>; export type UserParams = Static<typeof UserParamsSchema>; export type UsersQuery = Static<typeof UsersQuerySchema>; ``` **Why good:** Named constants for constraints, Static<> derives types from schemas, Type.Partial for update schemas, Type.Union for enums ### Bad Example - Separate Types and Validation (DRIFT RISK) ```typescript // WRONG - Types and validation can drift interface User { id: string; username: string; email: string; } const validateUser = (data: unknown): data is User => { // Manual validation... return true; }; // Later someone adds a field to interface but not validation interface User { id: string; username: string; email: string; role: string; // Added here, not in validation! } ``` **Why bad:** Manual validation drifts from types, no JSON Schema for documentation, no Fastify integration --- ## Pattern 4: Route with Full Schema ### Good Example - Complete CRUD Routes ```typescript // src/routes/users.ts import type { FastifyPluginAsync } from "fastify"; import { Type } from "@fastify/type-provider-typebox"; import { UserSchema, CreateUserSchema, UpdateUserSchema, UserParamsSchema, UsersQuerySchema, } from "../schemas/user"; const HTTP_OK = 200; const HTTP_CREATED = 201; const HTTP_NO_CONTENT = 204; const HTTP_NOT_FOUND = 404; const ErrorSchema = Type.Object({ statusCode: Type.Integer(), error: Type.String(), message: Type.String(), }); export const userRoutes: FastifyPluginAsync = async (fastify) => { // GET /api/users fastify.get( "/", { schema: { querystring: UsersQuerySchema, response: { [HTTP_OK]: Type.Object({ data: Type.Array(UserSchema), pagination: Type.Object({ page: Type.Integer(), limit: Type.Integer(), total: Type.Integer(), totalPages: Type.Integer(), }), }), }, }, }, async (request, reply) => { // request.query is fully typed as UsersQuery const { page = 1, limit = 20, sort, order } = request.query; const offset = (page - 1) * limit; const [users, total] = await Promise.all([ fastify.userService.list({ offset, limit, sort, order }), fastify.userService.count(), ]); return reply.status(HTTP_OK).send({ data: users, pagination: { page, limit, total, totalPages: Math.ceil(total / limit), }, }); }, ); // GET /api/users/:id fastify.get( "/:id", { schema: { params: UserParamsSchema, response: { [HTTP_OK]: UserSchema, [HTTP_NOT_FOUND]: ErrorSchema, }, }, }, async (request, reply) => { // request.params.id is typed as string (uuid format) const user = await fastify.userService.findById(request.params.id); if (!user) { return reply.status(HTTP_NOT_FOUND).send({ statusCode: HTTP_NOT_FOUND, error: "Not Found", message: `User ${request.params.id} not found`, }); } return reply.status(HTTP_OK).send(user); }, ); // POST /api/users fastify.post( "/", { schema: { body: CreateUserSchema, response: { [HTTP_CREATED]: UserSchema, }, }, }, async (request, reply) => { // request.body is typed as CreateUser const user = await fastify.userService.create(request.body); return reply.status(HTTP_CREATED).send(user); }, ); // PATCH /api/users/:id fastify.patch( "/:id", { schema: { params: UserParamsSchema, body: UpdateUserSchema, response: { [HTTP_OK]: UserSchema, [HTTP_NOT_FOUND]: ErrorSchema, }, }, }, async (request, reply) => { const user = await fastify.userService.update( request.params.id, request.body, ); if (!user) { return reply.status(HTTP_NOT_FOUND).send({ statusCode: HTTP_NOT_FOUND, error: "Not Found", message: `User ${request.params.id} not found`, }); } return reply.status(HTTP_OK).send(user); }, ); // DELETE /api/users/:id fastify.delete( "/:id", { schema: { params: UserParamsSchema, response: { [HTTP_NO_CONTENT]: Type.Null(), [HTTP_NOT_FOUND]: ErrorSchema, }, }, }, async (request, reply) => { const deleted = await fastify.userService.delete(request.params.id); if (!deleted) { return reply.status(HTTP_NOT_FOUND).send({ statusCode: HTTP_NOT_FOUND, error: "Not Found", message: `User ${request.params.id} not found`, }); } return reply.status(HTTP_NO_CONTENT).send(); }, ); }; ``` **Why good:** Full schema for request (params, querystring, body) AND response, enables fast-json-stringify optimization, complete type inference --- ## Pattern 5: Reusable Schema Components ### Good Example - Shared Error and Pagination Schemas ```typescript // src/schemas/common.ts import { Type, TObject, TProperties } from "@fastify/type-provider-typebox"; // Standard error response export const ErrorSchema = Type.Object({ statusCode: Type.Integer(), error: Type.String(), message: Type.String(), details: Type.Optional(Type.Unknown()), }); // Pagination wrapper factory export const PaginatedResponse = <T extends TProperties>( itemSchema: TObject<T>, ) => Type.Object({ data: Type.Array(itemSchema), pagination: Type.Object({ page: Type.Integer(), limit: Type.Integer(), total: Type.Integer(), totalPages: Type.Integer(), }), }); // Standard query params for paginated endpoints export const PaginationQuerySchema = Type.Object({ page: Type.Optional(Type.Integer({ minimum: 1, default: 1 })), limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100, default: 20 })), }); // ID param schema (reusable) export const IdParamSchema = Type.Object({ id: Type.String({ format: "uuid" }), }); ``` **Usage:** ```typescript import { ErrorSchema, PaginatedResponse, IdParamSchema, } from "../schemas/common"; import { UserSchema } from "../schemas/user"; // Response schema const UsersListResponse = PaginatedResponse(UserSchema); fastify.get( "/", { schema: { response: { [HTTP_OK]: UsersListResponse, }, }, }, async (request, reply) => { // ... }, ); ``` **Why good:** Reusable schema components, factory function for paginated responses, consistent structure across endpoints --- ## Pattern 6: Schema Composition ### Good Example - Extending Schemas ```typescript // src/schemas/post.ts import { Type, Static } from "@fastify/type-provider-typebox"; const MIN_TITLE_LENGTH = 1; const MAX_TITLE_LENGTH = 200; const MAX_CONTENT_LENGTH = 50000; // Base content fields const PostContentSchema = Type.Object({ title: Type.String({ minLength: MIN_TITLE_LENGTH, maxLength: MAX_TITLE_LENGTH, }), content: Type.String({ maxLength: MAX_CONTENT_LENGTH }), published: Type.Boolean({ default: false }), tags: Type.Optional(Type.Array(Type.String())), }); // Timestamps (auto-generated) const TimestampSchema = Type.Object({ createdAt: Type.String({ format: "date-time" }), updatedAt: Type.String({ format: "date-time" }), }); // Full post with ID and timestamps export const PostSchema = Type.Intersect([ Type.Object({ id: Type.String({ format: "uuid" }) }), PostContentSchema, TimestampSchema, Type.Object({ authorId: Type.String({ format: "uuid" }), }), ]); // Create input (just content) export const CreatePostSchema = PostContentSchema; // Update input (partial content) export const UpdatePostSchema = Type.Partial(PostContentSchema); // Summary for list views export const PostSummarySchema = Type.Pick(PostSchema, [ "id", "title", "published", "createdAt", "authorId", ]); // Types export type Post = Static<typeof PostSchema>; export type CreatePost = Static<typeof CreatePostSchema>; export type UpdatePost = Static<typeof UpdatePostSchema>; export type PostSummary = Static<typeof PostSummarySchema>; ``` **Why good:** Type.Intersect for composition, Type.Pick for summary views, Type.Partial for update schemas, clear separation of concerns --- ## Validation Error Handling > See [core.md Pattern 3](core.md) for the full error handler with validation error mapping, custom error classes, and unexpected error handling. **Key point for schemas:** Fastify validation errors have a `.validation` array (not `.message`). Each entry contains `instancePath`, `message`, and `keyword` — use these to build client-friendly error details. --- ## Quick Reference | TypeBox Type | JSON Schema | TypeScript | | ----------------------- | --------------------------------------- | ----------------- | | `Type.String()` | `{ type: "string" }` | `string` | | `Type.Integer()` | `{ type: "integer" }` | `number` | | `Type.Boolean()` | `{ type: "boolean" }` | `boolean` | | `Type.Array(T)` | `{ type: "array", items: T }` | `T[]` | | `Type.Object({...})` | `{ type: "object", properties: {...} }` | `{ ... }` | | `Type.Optional(T)` | Property not required | `T \| undefined` | | `Type.Union([...])` | `{ anyOf: [...] }` | Union type | | `Type.Literal("x")` | `{ const: "x" }` | `"x"` | | `Type.Partial(T)` | All properties optional | `Partial<T>` | | `Type.Pick(T, [...])` | Subset of properties | `Pick<T, ...>` | | `Type.Intersect([...])` | Merged schemas | Intersection type | | Schema Location | Purpose | | ---------------- | ------------------------ | | `params` | URL path parameters | | `querystring` | Query string parameters | | `body` | Request body | | `headers` | Request headers | | `response[code]` | Response for status code |
-
-
reference.md 13.8 KB
# Fastify Reference > Decision frameworks, anti-patterns, and red flags for Fastify development. Referenced from [SKILL.md](SKILL.md). --- <decision_framework> ## Decision Framework ### When to Use Fastify ``` Need a Node.js web framework? ├─ Is performance critical (>10k req/sec)? │ ├─ YES → Fastify (2-3x faster than Express) │ └─ NO → Any framework works ├─ Need schema validation at framework level? │ ├─ YES → Fastify (built-in JSON Schema) │ └─ NO → Manual validation works ├─ Building large modular application? │ ├─ YES → Fastify (plugin encapsulation) │ └─ NO → Simpler patterns may suffice └─ Need Express middleware compatibility? ├─ YES → Consider Express or @fastify/express adapter └─ NO → Fastify native plugins preferred ``` ### Type Provider Selection ``` Which type provider? ├─ Want single source for types + validation? │ ├─ YES → TypeBox (@fastify/type-provider-typebox) │ └─ Already using Zod elsewhere? │ ├─ YES → fastify-type-provider-zod │ └─ NO → TypeBox (best integration) ├─ Have existing JSON Schemas? │ └─ YES → @fastify/type-provider-json-schema-to-ts └─ Default recommendation → TypeBox ``` ### Plugin Encapsulation Decision ``` Creating a new plugin? ├─ Is it shared infrastructure? (db, cache, logger) │ └─ YES → Use fastify-plugin (break encapsulation) ├─ Is it a domain/feature module? │ └─ YES → Keep encapsulated (default behavior) ├─ Does it add decorators other plugins need? │ └─ YES → Use fastify-plugin └─ Default → Keep encapsulated (safer) ``` ### Hook Selection ``` When should this code run? ├─ Before ANY processing (logging, request ID)? │ └─ onRequest ├─ Need to transform raw request stream? │ └─ preParsing ├─ Before schema validation? │ └─ preValidation ├─ After validation, before handler? │ └─ preHandler (authentication, authorization) ├─ Need to transform response object? │ └─ preSerialization ├─ Need to modify final payload string/buffer? │ └─ onSend (compression, encryption) ├─ After response sent (non-blocking)? │ └─ onResponse (metrics, logging) └─ On errors? └─ onError (custom error logging) ``` ### Decorator vs Hook Decision ``` Need to add functionality? ├─ Is it a utility/service available to all requests? │ └─ decorate() on FastifyInstance ├─ Is it per-request state? │ └─ decorateRequest() with hook to populate ├─ Is it response helper methods? │ └─ decorateReply() ├─ Is it cross-cutting behavior (auth, logging)? │ └─ addHook() └─ Is it one-time setup? └─ Run in plugin registration function ``` ### Response Schema Decision ``` Should I define a response schema? ├─ Is this a production API? │ └─ YES → Always define response schemas ├─ Is performance important? │ └─ YES → Response schemas enable fast-json-stringify ├─ Need to prevent accidental data leaks? │ └─ YES → Response schemas filter extra properties └─ Default → Define response schemas (best practice) ``` </decision_framework> --- ## Red Flags > See [SKILL.md](SKILL.md) for the full red flags list. --- <anti_patterns> ## Anti-Patterns to Avoid ### Missing Type Provider ```typescript // WRONG: No type provider import Fastify from "fastify"; const server = Fastify(); server.get("/users", async (request) => { const limit = request.query.limit; // Type: unknown return { users: [] }; }); ``` ```typescript // CORRECT: Type provider configured import Fastify from "fastify"; import { Type, TypeBoxTypeProvider } from "@fastify/type-provider-typebox"; const server = Fastify().withTypeProvider<TypeBoxTypeProvider>(); server.get( "/users", { schema: { querystring: Type.Object({ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })), }), }, }, async (request) => { const limit = request.query.limit; // Type: number | undefined return { users: [] }; }, ); ``` **Why it matters:** Without type provider, request.query/body/params are `unknown`, losing TypeScript benefits. --- ### Shared Plugin Without fastify-plugin ```typescript // WRONG: Decorator not accessible outside plugin import type { FastifyPluginAsync } from "fastify"; export const databasePlugin: FastifyPluginAsync = async (fastify) => { fastify.decorate("db", createDatabaseClient()); }; // Usage - THIS FAILS: server.register(databasePlugin); server.register(async (fastify) => { const users = await fastify.db.query("..."); // db is undefined! }); ``` ```typescript // CORRECT: Use fastify-plugin for shared decorators import fp from "fastify-plugin"; import type { FastifyPluginAsync } from "fastify"; declare module "fastify" { interface FastifyInstance { db: DatabaseClient; } } const databasePlugin: FastifyPluginAsync = async (fastify) => { fastify.decorate("db", createDatabaseClient()); }; export const database = fp(databasePlugin, { name: "database" }); // Usage - THIS WORKS: server.register(database); server.register(async (fastify) => { const users = await fastify.db.query("..."); // db is accessible! }); ``` **Why it matters:** Without fastify-plugin, decorators are encapsulated and invisible to sibling/parent contexts. --- ### Magic Numbers for Status Codes ```typescript // WRONG: Magic numbers scattered throughout code server.get("/:id", async (request, reply) => { const user = await findUser(request.params.id); if (!user) { return reply.status(404).send({ error: "Not found" }); } return reply.status(200).send(user); }); server.post("/", async (request, reply) => { try { const user = await createUser(request.body); return reply.status(201).send(user); } catch { return reply.status(500).send({ error: "Failed" }); } }); ``` ```typescript // CORRECT: Named constants const HTTP_OK = 200; const HTTP_CREATED = 201; const HTTP_NOT_FOUND = 404; const HTTP_INTERNAL_ERROR = 500; server.get("/:id", async (request, reply) => { const user = await findUser(request.params.id); if (!user) { return reply.status(HTTP_NOT_FOUND).send({ error: "Not found" }); } return reply.status(HTTP_OK).send(user); }); server.post("/", async (request, reply) => { try { const user = await createUser(request.body); return reply.status(HTTP_CREATED).send(user); } catch { return reply.status(HTTP_INTERNAL_ERROR).send({ error: "Failed" }); } }); ``` **Why it matters:** Named constants document intent, enable search/replace, prevent typos. --- ### Reference Types in Request Decorators ```typescript // WRONG: Shared mutable state across requests fastify.decorateRequest("userData", { name: "", permissions: [] }); fastify.addHook("preHandler", async (request) => { request.userData.name = "John"; // MUTATES SHARED OBJECT! request.userData.permissions.push("read"); // ACCUMULATES ACROSS REQUESTS! }); ``` ```typescript // CORRECT: Initialize per-request in hook fastify.decorateRequest("userData", null); fastify.addHook("preHandler", async (request) => { request.userData = { name: "John", permissions: ["read"], }; }); ``` **Why it matters:** Reference type decorators are shared across ALL requests - mutations persist and accumulate. --- ### Missing Response Schema ```typescript // WRONG: No response schema server.get("/users", async () => { const users = await db.query("SELECT * FROM users"); // May include password_hash! return { users }; }); ``` ```typescript // CORRECT: Response schema filters and optimizes server.get( "/users", { schema: { response: { 200: Type.Object({ users: Type.Array( Type.Object({ id: Type.String(), username: Type.String(), email: Type.String(), // password_hash NOT included - filtered out! }), ), }), }, }, }, async () => { const users = await db.query("SELECT * FROM users"); return { users }; // password_hash automatically removed }, ); ``` **Why it matters:** Response schemas prevent accidental data leaks AND enable fast-json-stringify (2-3x faster). --- ### God Route Files ```typescript // WRONG: All routes in one file // routes.ts - 2000+ lines server.get("/users", async () => {}); server.get("/users/:id", async () => {}); server.post("/users", async () => {}); server.get("/posts", async () => {}); server.get("/posts/:id", async () => {}); server.post("/posts", async () => {}); // ... 50+ more routes ``` ```typescript // CORRECT: Modular route plugins // server.ts server.register(userRoutes, { prefix: "/users" }); server.register(postRoutes, { prefix: "/posts" }); // routes/users.ts export const userRoutes: FastifyPluginAsync = async (fastify) => { fastify.get("/", listUsers); fastify.get("/:id", getUser); fastify.post("/", createUser); }; // routes/posts.ts export const postRoutes: FastifyPluginAsync = async (fastify) => { fastify.get("/", listPosts); fastify.get("/:id", getPost); fastify.post("/", createPost); }; ``` **Why it matters:** Modular plugins enable encapsulation, easier testing, and maintainable code organization. --- ### Express Patterns in Fastify ```typescript // WRONG: Express-style patterns server.get("/users", (req, res, next) => { // res.send() doesn't exist // next() doesn't exist res.json({ users: [] }); }); server.use((req, res, next) => { // .use() works differently in Fastify next(); }); ``` ```typescript // CORRECT: Fastify-native patterns server.get("/users", async (request, reply) => { return { users: [] }; // Auto-serialized to JSON // OR: reply.send({ users: [] }); }); server.addHook("onRequest", async (request, reply) => { // Hooks replace middleware // Return value or reply.send() short-circuits }); ``` **Why it matters:** Fastify uses different patterns - hooks instead of middleware, reply instead of res, return values for responses. --- ### Mixing Async and Callback Patterns ```typescript // WRONG: Mixing patterns causes confusion server.addHook("preHandler", async (request, reply, done) => { await someAsyncOperation(); done(); // DON'T mix async with done callback }); ``` ```typescript // CORRECT: Use async consistently server.addHook("preHandler", async (request, reply) => { await someAsyncOperation(); // No done() needed - async function completion signals done }); // OR use callback consistently (rare) server.addHook("preHandler", (request, reply, done) => { someCallbackOperation((error) => { done(error); }); }); ``` **Why it matters:** Mixing async/await with done callback can cause double-completion or hanging requests. </anti_patterns> --- ## Quick Reference Tables ### HTTP Status Constants ```typescript // Define once, use everywhere const HTTP_OK = 200; const HTTP_CREATED = 201; const HTTP_NO_CONTENT = 204; const HTTP_BAD_REQUEST = 400; const HTTP_UNAUTHORIZED = 401; const HTTP_FORBIDDEN = 403; const HTTP_NOT_FOUND = 404; const HTTP_CONFLICT = 409; const HTTP_UNPROCESSABLE_ENTITY = 422; const HTTP_TOO_MANY_REQUESTS = 429; const HTTP_INTERNAL_ERROR = 500; const HTTP_SERVICE_UNAVAILABLE = 503; ``` ### Hook Execution Order | Hook | When | Can Send Response | Common Use | | ---------------- | ------------------------ | --------------------- | -------------------------------- | | onRequest | First, before parsing | Yes | Logging, request ID | | preParsing | Before body parse | Yes | Stream transformation | | preValidation | Before schema validation | Yes | Modify body before validation | | preHandler | After validation | Yes | Auth, authorization | | handler | Route handler | Yes | Business logic | | preSerialization | Before JSON stringify | No | Transform response object | | onSend | Before sending | Yes (replace payload) | Compression, final modifications | | onResponse | After sent | No | Metrics, cleanup | | onError | On error | No | Error logging | ### Type Provider Comparison | Provider | Source | Best For | | ---------------------------------------- | --------------- | --------------------- | | @fastify/type-provider-typebox | TypeBox schemas | New projects, best DX | | @fastify/type-provider-json-schema-to-ts | JSON Schema | Existing JSON schemas | | fastify-type-provider-zod | Zod schemas | Already using Zod | --- ## Production Checklist ### Before Deploying - [ ] Type provider configured with `withTypeProvider<>()` - [ ] All routes have schema definitions (querystring, params, body, response) - [ ] Response schemas defined for all endpoints - [ ] Named constants for HTTP status codes (no magic numbers) - [ ] Shared plugins wrapped with fastify-plugin - [ ] Plugin dependencies declared in options - [ ] Error handler configured with `setErrorHandler()` - [ ] Request ID generation and propagation - [ ] Proper logging configuration (production level) - [ ] onClose hooks for cleanup (database, connections) - [ ] No sensitive data in error responses - [ ] No PII in logs ### Testing Checklist - [ ] Server factory function for test isolation - [ ] Using `server.inject()` (no network overhead) - [ ] `beforeEach`: build server, `await server.ready()` - [ ] `afterEach`: `await server.close()` - [ ] Testing validation errors (400 responses) - [ ] Testing success paths - [ ] Testing error handling - [ ] Mocking decorators when needed -
SKILL.md 13.9 KB
--- name: api-framework-fastify description: Fastify routes, JSON Schema validation, plugin system, TypeScript type providers --- # API Development with Fastify > **Quick Guide:** Use Fastify for high-performance Node.js REST APIs with built-in JSON Schema validation and powerful plugin encapsulation. Use `@fastify/type-provider-typebox` for end-to-end type safety (both `Type` and `TypeBoxTypeProvider` re-exported from it). Wrap shared plugins with `fastify-plugin` to expose decorators. Always define response schemas for serialization performance and data leak prevention. --- <critical_requirements> ## CRITICAL: Before Using This Skill > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants) **(You MUST use `withTypeProvider<>()` for type-safe request/response handling)** **(You MUST wrap shared plugins with `fastify-plugin` to expose decorators to parent scope)** **(You MUST define response schemas to enable fast-json-stringify optimization)** **(You MUST use named constants for HTTP status codes - never raw numbers)** </critical_requirements> --- **Auto-detection:** Fastify, fastify.register, fastify.decorate, fastify-plugin, TypeBox, @fastify/type-provider-typebox, @fastify/type-provider-json-schema-to-ts, fastify-type-provider-zod, preHandler, onRequest, preSerialization, JSON Schema validation, fast-json-stringify, FastifyPluginAsyncTypebox **When to use:** - Building high-performance REST APIs (45k+ req/sec benchmarks) - Need schema-based validation with automatic coercion - Want plugin encapsulation for modular architecture - Require lifecycle hooks for cross-cutting concerns - Building APIs with strict TypeScript type safety requirements **When NOT to use:** - Simple internal APIs without performance requirements (consider your existing solution) - GraphQL APIs (use dedicated GraphQL servers) - Edge/serverless with size constraints (Fastify has larger footprint than minimal frameworks) - When middleware ecosystem compatibility with Express is required **Key patterns covered:** - Server setup with TypeScript type providers - Plugin system and encapsulation patterns - JSON Schema validation for request/response - Lifecycle hooks (onRequest, preHandler, onSend, etc.) - Decorators for extending Fastify/Request/Reply - Error handling with setErrorHandler - Route organization with prefix patterns --- **Detailed Resources:** - [examples/core.md](examples/core.md) - Server setup, routes, schemas, error handling, testing - [examples/plugins.md](examples/plugins.md) - Plugin system, encapsulation, decorators - [examples/schemas.md](examples/schemas.md) - TypeBox schemas, validation, type-safe routes - [examples/hooks.md](examples/hooks.md) - Lifecycle hooks and cross-cutting concerns - [reference.md](reference.md) - Decision frameworks, anti-patterns, quick reference --- <philosophy> ## Philosophy **Schema-first, compiled validation.** Fastify compiles JSON schemas at startup into highly optimized validator functions. This provides both runtime safety and documentation from a single source of truth. **Plugin encapsulation creates microservices in a monolith.** Each plugin has its own scope for decorators and hooks. Child plugins inherit from parents, but parents cannot access child resources - enabling clean separation of concerns. **Performance without sacrifice.** Fastify achieves 2-3x throughput over Express while maintaining developer ergonomics through TypeScript integration and comprehensive hook system. </philosophy> --- <patterns> ## Core Patterns ### Pattern 1: Server Setup with Type Provider Configure Fastify with TypeBox for compile-time AND runtime type safety. `Type` is re-exported from `@fastify/type-provider-typebox`. ```typescript import Fastify from "fastify"; import { Type, TypeBoxTypeProvider } from "@fastify/type-provider-typebox"; const SERVER_PORT = 3000; const SERVER_HOST = "0.0.0.0"; const buildServer = () => { const server = Fastify({ logger: { level: process.env.LOG_LEVEL ?? "info" }, }).withTypeProvider<TypeBoxTypeProvider>(); server.setErrorHandler(errorHandler); server.register(userRoutes, { prefix: "/api/users" }); return server; }; export { buildServer }; ``` **Why good:** TypeBox provider enables type inference from schemas, factory function enables testing, `Type` imported from same package > Full example with startup, error handling, and testing: [examples/core.md](examples/core.md) --- ### Pattern 2: Schema Definition with TypeBox Define schemas that provide both TypeScript types AND runtime validation from a single source. ```typescript import { Type, Static } from "@fastify/type-provider-typebox"; const MIN_USERNAME_LENGTH = 3; const MAX_USERNAME_LENGTH = 50; export const UserSchema = Type.Object({ id: Type.String({ format: "uuid" }), username: Type.String({ minLength: MIN_USERNAME_LENGTH, maxLength: MAX_USERNAME_LENGTH, }), email: Type.String({ format: "email" }), }); // Derive TypeScript types from schemas export type User = Static<typeof UserSchema>; ``` **Why good:** Single source of truth for types and validation, `Static<>` derives TS types automatically > Full schema patterns (composition, partial updates, reusable components): [examples/schemas.md](examples/schemas.md) --- ### Pattern 3: Route Definition with Full Schema Define routes with request AND response schemas for complete type safety and serialization optimization. ```typescript import type { FastifyPluginAsync } from "fastify"; import { Type } from "@fastify/type-provider-typebox"; const HTTP_OK = 200; const HTTP_NOT_FOUND = 404; export const userRoutes: FastifyPluginAsync = async (fastify) => { fastify.get( "/:id", { schema: { params: UserParamsSchema, response: { [HTTP_OK]: UserSchema, [HTTP_NOT_FOUND]: ErrorSchema, }, }, }, async (request, reply) => { const user = await fastify.userService.findById(request.params.id); if (!user) { return reply.status(HTTP_NOT_FOUND).send({ statusCode: HTTP_NOT_FOUND, error: "Not Found", message: `User ${request.params.id} not found`, }); } return reply.status(HTTP_OK).send(user); }, ); }; ``` **Why good:** Response schemas enable fast-json-stringify (2-3x faster), full type inference on request objects, HTTP constants prevent magic numbers > Complete CRUD routes with pagination: [examples/core.md](examples/core.md) --- ### Pattern 4: Plugin Encapsulation Default plugins are **encapsulated** - decorators stay within scope. Use `fastify-plugin` (`fp`) to break encapsulation for shared infrastructure. ```typescript // ENCAPSULATED - decorators only available within this plugin export const authRoutes: FastifyPluginAsync = async (fastify) => { fastify.decorate("authConfig", { tokenExpiry: 3600 }); // authConfig only accessible in this plugin }; // SHARED - decorators exposed to parent scope import fp from "fastify-plugin"; declare module "fastify" { interface FastifyInstance { config: AppConfig; } } const configPlugin: FastifyPluginAsync = async (fastify) => { fastify.decorate("config", { apiVersion: "v1" }); }; export const appConfig = fp(configPlugin, { name: "app-config", dependencies: [], }); ``` **Why good:** Domain plugins stay isolated, shared utilities use `fp()` to expose decorators, TypeScript augmentation provides type safety > Full plugin examples with dependencies, registration order: [examples/plugins.md](examples/plugins.md) --- ### Pattern 5: Lifecycle Hooks Use hooks for cross-cutting concerns at specific lifecycle points. **Hook execution order:** 1. `onRequest` - Before parsing (request ID, timing) 2. `preParsing` - Transform request stream 3. `preValidation` - Before schema validation 4. `preHandler` - After validation (auth, authorization) 5. `preSerialization` - Transform response object 6. `onSend` - Final payload modification 7. `onResponse` - After response sent (metrics, logging) 8. `onError` - On error (error logging) ```typescript // Plugin-level: applies to ALL routes in this plugin fastify.addHook("preHandler", requireAuth); // Route-level: applies to single route fastify.delete( "/users/:id", { preHandler: [requireAuth, requireAdmin], }, async (request) => { /* ... */ }, ); ``` **Why good:** Plugin-level for consistent protection, route-level for selective application, hooks execute in array order > Full hook examples (request timing, auth, response headers, error logging): [examples/hooks.md](examples/hooks.md) --- ### Pattern 6: Error Handling Implement centralized error handling with `setErrorHandler`. Fastify validation errors have a `.validation` array (not `.message`). ```typescript import type { FastifyError, FastifyReply, FastifyRequest } from "fastify"; const HTTP_BAD_REQUEST = 400; const HTTP_INTERNAL_ERROR = 500; export const errorHandler = ( error: FastifyError, request: FastifyRequest, reply: FastifyReply, ) => { if (error.validation) { return reply.status(HTTP_BAD_REQUEST).send({ statusCode: HTTP_BAD_REQUEST, error: "Bad Request", message: "Validation failed", details: error.validation, }); } request.log.error( { error: error.message, stack: error.stack }, "Unexpected error", ); return reply.status(HTTP_INTERNAL_ERROR).send({ statusCode: HTTP_INTERNAL_ERROR, error: "Internal Server Error", message: "An unexpected error occurred", }); }; ``` **Why good:** Validation errors expose details, unexpected errors logged with stack but hidden from client > Full error handler with custom error classes: [examples/core.md](examples/core.md) --- ### Pattern 7: Decorators Extend Fastify instance, Request, and Reply with decorators. ```typescript // Instance decorator - services/utilities fastify.decorate("myService", serviceInstance); // Request decorator - per-request state (initialize with null, set in hook) fastify.decorateRequest("userId", null); fastify.addHook("preHandler", async (request) => { request.userId = decoded.userId; }); // Reply decorator - response helpers (use function for `this` binding) fastify.decorateReply( "notFound", function (this: FastifyReply, message: string) { this.status(HTTP_NOT_FOUND).send({ statusCode: HTTP_NOT_FOUND, error: "Not Found", message, }); }, ); ``` **CRITICAL:** Never use reference types (objects, arrays) as initial decorator values - they are **shared across ALL requests**. Use `null` and set per-request in hooks. > Full decorator examples: [examples/plugins.md](examples/plugins.md) --- ### Pattern 8: Testing with server.inject() Use the factory pattern for test isolation and `server.inject()` for zero-network-overhead testing. ```typescript import { buildServer } from "./server"; let server: ReturnType<typeof buildServer>; beforeEach(async () => { server = buildServer(); await server.ready(); }); afterEach(async () => { await server.close(); }); it("should list users", async () => { const response = await server.inject({ method: "GET", url: "/api/users", query: { limit: "10" }, }); expect(response.statusCode).toBe(200); expect(response.json()).toHaveProperty("users"); }); ``` **Why good:** `server.inject()` tests without network, `beforeEach`/`afterEach` ensures clean state, tests validation and success paths </patterns> --- <red_flags> ## RED FLAGS ### High Priority Issues - **No type provider configured** - Loses compile-time type safety on request/response - **Shared plugins without `fastify-plugin`** - Decorators invisible to other plugins - **Missing response schemas** - Loses 2-3x serialization performance AND risks data leaks - **Raw status code numbers** - Use named constants (`HTTP_OK`, `HTTP_NOT_FOUND`) - **Reference types in `decorateRequest`/`decorateReply`** - Shared mutable state across ALL requests (security risk) ### Medium Priority Issues - **No error handler configured** - Stack traces exposed to clients in production - **Missing `dependencies` in plugin options** - Race conditions on decorator access - **No schema for query/params** - No validation, types are `unknown` - **Inline route handlers in god files** - Use modular route plugins with prefix ### Common Mistakes - **Forgetting `await server.ready()`** - Plugins may not be fully loaded - **Not cleaning up in `onClose`** - Connection leaks on shutdown - **Mixing async/await with `done` callback** - Pick one pattern per hook (causes double-completion) - **Using Express patterns** - `res.send()` vs `reply.send()`, `next()` vs returning ### Gotchas & Edge Cases - **Hook return values:** Returning a value from hooks sends response immediately (short-circuits) - **Plugin registration order:** Later plugins can't access earlier encapsulated decorators - **Validation error shape:** Fastify validation errors have `.validation` array, not `.message` - **Route specificity:** More specific routes must be registered before wildcards - **preHandler order:** Route-level runs AFTER plugin-level hooks - **onResponse timing:** Runs after response sent, cannot modify response - **Schema compilation:** Happens at startup, errors surface during `server.ready()` - **v5 redirect order:** `reply.redirect(url, statusCode)` not `reply.redirect(statusCode, url)` (reversed from v4) - **v5 reply.sent:** Use `reply.hijack()` instead of setting `reply.sent = true` </red_flags> --- <critical_reminders> ## CRITICAL REMINDERS > **All code must follow project conventions in CLAUDE.md** **(You MUST use `withTypeProvider<>()` for type-safe request/response handling)** **(You MUST wrap shared plugins with `fastify-plugin` to expose decorators to parent scope)** **(You MUST define response schemas to enable fast-json-stringify optimization)** **(You MUST use named constants for HTTP status codes - never raw numbers)** **Failure to follow these rules will break type safety and lose performance benefits.** </critical_reminders>
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.