Fastify 5 OpenAPI docs generated with @fastify/swagger: working versions (tested 2026-10-15)
Generate an OpenAPI 3 document from Fastify 5 route schemas with @fastify/swagger, using shared $ref schemas, tags and security, then serve and check it with inject().
passed3 passed · 0 failed2026-10-15 17:10 UTC- Status
- passed
- Last passed
- 2026-10-15 17:10 UTC
- Tests
- 3 / 3
- Language
- TypeScript
- Runtime
- node v22.23.3
npm install --no-audit --no-fund --ignore-scripts=false npm test
node v22.23.3 linux 6.17.0-1022-azure x64 runner: github-actions 38775053802
The code
Exactly the files that passed. Your agent can pull them with get_recipe("fastify-swagger-openapi") (install), or see them on GitHub.
{
"name": "fastify-swagger-openapi",
"private": true,
"type": "module",
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"fastify": "5.12.7",
"@fastify/swagger": "9.9.2"
},
"devDependencies": {
"@types/node": "26.6.5",
"tsx": "4.23.15"
}
}
import Fastify from "fastify";
import swagger from "@fastify/swagger";
export async function buildApp() {
const app = Fastify();
// Register swagger before routes so it sees every route schema.
await app.register(swagger, {
openapi: {
openapi: "3.1.0",
info: { title: "Parts API", version: "1.0.0" },
components: { securitySchemes: { bearerAuth: { type: "http", scheme: "bearer" } } },
},
// Name shared schemas after their $id instead of the default def-0, def-1...
refResolver: { buildLocalReference: (json, _baseUri, _fragment, i) => String(json.$id ?? `def-${i}`) },
});
app.addSchema({
$id: "Part",
type: "object",
required: ["id", "name"],
properties: { id: { type: "integer" }, name: { type: "string" }, price: { type: "number" } },
});
const parts = [{ id: 1, name: "bolt", price: 0.25 }];
app.get("/parts", {
schema: {
tags: ["parts"],
summary: "List parts",
querystring: { type: "object", properties: { q: { type: "string" } } },
response: { 200: { type: "array", items: { $ref: "Part#" } } },
},
}, async (req) => {
const { q } = req.query as { q?: string };
return q ? parts.filter((p) => p.name.includes(q)) : parts;
});
app.post("/parts", {
schema: {
tags: ["parts"],
summary: "Create a part",
security: [{ bearerAuth: [] }],
body: { type: "object", required: ["name"], properties: { name: { type: "string", minLength: 1 }, price: { type: "number" } } },
response: { 201: { $ref: "Part#" } },
},
}, async (req, reply) => {
const body = req.body as { name: string; price?: number };
const part = { id: parts.length + 1, ...body };
parts.push(part);
return reply.code(201).send(part);
});
app.get("/openapi.json", { schema: { hide: true } }, async () => app.swagger());
await app.ready();
return app;
}
import { test } from "node:test";
import assert from "node:assert/strict";
import { buildApp } from "../src/app.ts";
test("generates an OpenAPI 3.1 document from route schemas", async () => {
const app = await buildApp();
const doc = app.swagger() as any;
assert.equal(doc.openapi, "3.1.0");
assert.equal(doc.info.title, "Parts API");
assert.deepEqual(Object.keys(doc.paths).sort(), ["/parts"]);
assert.equal(doc.paths["/parts"].get.summary, "List parts");
assert.deepEqual(doc.paths["/parts"].post.security, [{ bearerAuth: [] }]);
assert.equal(doc.paths["/parts"].get.parameters[0].name, "q");
assert.deepEqual(doc.components.schemas.Part.required, ["id", "name"]);
assert.equal(doc.paths["/parts"].post.responses["201"].content["application/json"].schema.$ref, "#/components/schemas/Part");
assert.equal(doc.components.securitySchemes.bearerAuth.scheme, "bearer");
await app.close();
});
test("serves the document and hides the docs route itself", async () => {
const app = await buildApp();
const res = await app.inject({ method: "GET", url: "/openapi.json" });
assert.equal(res.statusCode, 200);
const doc = res.json();
assert.equal(doc.paths["/openapi.json"], undefined);
await app.close();
});
test("the same schemas validate requests", async () => {
const app = await buildApp();
const bad = await app.inject({ method: "POST", url: "/parts", payload: { name: "" } });
assert.equal(bad.statusCode, 400);
const ok = await app.inject({ method: "POST", url: "/parts", payload: { name: "nut", price: 0.1 } });
assert.equal(ok.statusCode, 201);
assert.deepEqual(ok.json(), { id: 2, name: "nut", price: 0.1 });
await app.close();
});
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
Fastify 5 + @fastify/jwt protected routes
Sign and verify JWTs in Fastify 5 with @fastify/jwt and an authenticate preHandler that guards routes.
Fastify 5 + Zod 4 type provider for typed routes
Use fastify-type-provider-zod so Zod 4 schemas validate requests, serialize responses and type handlers in Fastify 5.
Fastify 5 file uploads with @fastify/multipart
Accept multipart file uploads in Fastify 5 with @fastify/multipart, reading fields and files, hashing content, rejecting oversized files with 413, tested with inject() and a native FormData body.
Fastify 5 routes with JSON Schema validation
Fastify 5 route validation and response serialization with JSON Schema, tested via app.inject().