Node.js · lesson 15 of 20
Beginner
6Intermediate
10- 7 Structured Logging with Pino
- 8 Async Route Handlers and Errors in Express 5
- 9 Testing an HTTP API with node:test and fetch
- 10 The Same API in Fastify
- 11 JWT Auth Middleware
- 12 Graceful Shutdown and Health Checks
- 13 File Uploads with multer
- 14 A WebSocket Server with ws
- 15 OpenAPI Docs from Fastify Schemas
- 16 PostgreSQL with pg: Pools and Transactions
Advanced
4Also part of Node.js
10- + Files, Streams, and the Command Line in Node.js from JavaScript
- + JavaScript Event Loop Explained from JavaScript
- + JavaScript Modules: ESM and CommonJS from JavaScript
- + Introduction to Docker from Docker
- + Managing Docker Containers from Docker
- + Multi-Stage Builds from Docker
- + Docker Compose from Docker
- + Node.js with TypeScript from TypeScript
- + Unsafe File Uploads: secure_filename, Allowlists and Size Limits from Web Security (OWASP Top 10, fix-side)
- + Decorators in TypeScript from TypeScript
OpenAPI Docs from Fastify Schemas
Generate OpenAPI docs from Fastify route schemas with @fastify/swagger, see a schema-less route's empty entry, and watch a response schema strip a field.
By SkillByExample · Updated
The Same API in Fastify showed Fastify validating a request body and serializing a response, both against JSON Schema objects already sitting on the route. @fastify/swagger reads those exact same schema objects and turns them into an OpenAPI document — nothing new to write, and nothing stops a route from having no schema at all.
Generating an OpenAPI Document from Route Schemas
One route, POST /users, with a body schema and a 201 response schema — the same shape the Fastify lesson used, plus the two plugins that turn it into a spec:
// server.js
import Fastify from "fastify";
import swagger from "@fastify/swagger";
import swaggerUi from "@fastify/swagger-ui";
const app = Fastify({ logger: false });
await app.register(swagger, {
openapi: { info: { title: "Users API", version: "1.0.0" } },
});
await app.register(swaggerUi, { routePrefix: "/documentation" });
let nextId = 1;
const userSchema = {
type: "object",
properties: {
id: { type: "integer" },
name: { type: "string" },
email: { type: "string" },
},
};
const createUserSchema = {
body: {
type: "object",
required: ["name", "email"],
properties: {
name: { type: "string", minLength: 2 },
email: { type: "string", format: "email" },
},
},
response: { 201: userSchema },
};
app.post("/users", { schema: createUserSchema }, async (req, reply) => {
const user = { id: nextId++, ...req.body, password: "hunter2" };
reply.code(201);
return user;
});
app.listen({ port: 3000, host: "0.0.0.0" }, (err) => {
if (err) {
app.log.error(err);
process.exit(1);
}
console.log("listening on :3000");
});
The handler deliberately builds user with a password field too — that’s the field the second section comes back to. package.json pins every version this lesson uses, with no ^ range:
{
"name": "openapi-from-fastify-schemas",
"version": "1.0.0",
"type": "module",
"dependencies": {
"fastify": "5.12.8",
"@fastify/swagger": "9.9.3",
"@fastify/swagger-ui": "6.1.2"
}
}
node_modules lives in its own named Docker volume, nodejs-openapi-from-fastify-schemas-modules, for the same reason every lesson in this topic uses one — a bind mount shares files with the Linux VM too slowly for node_modules’ thousands of small files:
$ docker run --rm -v "$(pwd)":/app -v nodejs-openapi-from-fastify-schemas-modules:/app/node_modules -w /app -e NPM_CONFIG_UPDATE_NOTIFIER=false node:26.13 npm install --no-audit --no-fund
added 78 packages in 8s
The image has no jq, so pretty-printing the spec uses node -e instead, run inside the same container the server is already running in — curl saves the raw JSON into the bind-mounted project directory first, where that docker exec can read it back:
$ docker run -d --name nodejs-openapi-demo -p 3000:3000 -v "$(pwd)":/app -v nodejs-openapi-from-fastify-schemas-modules:/app/node_modules -w /app node:26.13 node server.js
ff1a19c3af42d06fc269da43ba9a15af74b423aa8b99bfcc6278c505e5fa04a7
$ until docker logs nodejs-openapi-demo 2>&1 | grep -q listening; do sleep 0.2; done
$ curl -s http://localhost:3000/documentation/json -o spec.json
$ docker exec nodejs-openapi-demo node -e "console.log(JSON.stringify(JSON.parse(require('fs').readFileSync('spec.json','utf8')),null,2))"
{
"openapi": "3.0.3",
"info": {
"title": "Users API",
"version": "1.0.0"
},
"components": {
"schemas": {}
},
"paths": {
"/users": {
"post": {
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"name",
"email"
],
"properties": {
"name": {
"type": "string",
"minLength": 2
},
"email": {
"type": "string",
"format": "email"
}
}
}
}
}
},
"responses": {
"201": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"name": {
"type": "string"
},
"email": {
"type": "string"
}
}
}
}
}
}
}
}
}
}
}
$ docker rm -f nodejs-openapi-demo
nodejs-openapi-demo
@fastify/swagger never wrote a separate OpenAPI file, and nothing in server.js describes the API in English — every piece of this document came straight off createUserSchema. requestBody.content["application/json"].schema is createUserSchema.body, copied verbatim, required array and minLength/format constraints included. responses["201"].schema is userSchema, the same object the route’s own response: { 201: userSchema } points at. "openapi": "3.0.3" is the document version @fastify/swagger 9.9.3 defaults to; components.schemas stays empty because this route never registered a named, reusable schema with $id — only the one anonymous object inline on the route.
A Route Without a Schema Gets an Empty Entry
Add a second route, GET /ping, with no schema option at all — a shortcut every real API picks up sooner or later for something that doesn’t seem worth describing yet:
// server.js (added /ping, no schema)
import Fastify from "fastify";
import swagger from "@fastify/swagger";
import swaggerUi from "@fastify/swagger-ui";
const app = Fastify({ logger: false });
await app.register(swagger, {
openapi: { info: { title: "Users API", version: "1.0.0" } },
});
await app.register(swaggerUi, { routePrefix: "/documentation" });
let nextId = 1;
const userSchema = {
type: "object",
properties: {
id: { type: "integer" },
name: { type: "string" },
email: { type: "string" },
},
};
const createUserSchema = {
body: {
type: "object",
required: ["name", "email"],
properties: {
name: { type: "string", minLength: 2 },
email: { type: "string", format: "email" },
},
},
response: { 201: userSchema },
};
app.post("/users", { schema: createUserSchema }, async (req, reply) => {
const user = { id: nextId++, ...req.body, password: "hunter2" };
reply.code(201);
return user;
});
app.get("/ping", async () => ({ pong: true }));
app.listen({ port: 3000, host: "0.0.0.0" }, (err) => {
if (err) {
app.log.error(err);
process.exit(1);
}
console.log("listening on :3000");
});
This container stays up for the rest of the lesson — every section from here on reuses it, since none of the remaining examples edit the file again:
$ docker run -d --name nodejs-openapi-demo2 -p 3000:3000 -v "$(pwd)":/app -v nodejs-openapi-from-fastify-schemas-modules:/app/node_modules -w /app node:26.13 node server.js
57e8b9e43a63519d04031fc99df45a1d98d7d2c39e4e57b675911bea4fcd6109
$ until docker logs nodejs-openapi-demo2 2>&1 | grep -q listening; do sleep 0.2; done
$ curl -s http://localhost:3000/documentation/json -o spec.json
$ docker exec nodejs-openapi-demo2 node -e "console.log(JSON.stringify(JSON.parse(require('fs').readFileSync('spec.json','utf8')).paths['/ping'],null,2))"
{
"get": {
"responses": {
"200": {
"description": "Default Response"
}
}
}
}
GET /ping really does return {"pong":true} — but nothing in this entry says so. No request body section (there’s nothing to validate), no parameters, and the one response entry carries nothing but @fastify/swagger’s own placeholder text, "Default Response", with no content key at all describing the shape. A reader of this spec has no way to tell /ping apart from any other undocumented route except its path and method; the actual behavior only exists in server.js, not in the document a client-generation tool or a curl /documentation/json consumer would read.
The Response Schema Drops the password Field
server.js’s handler built user with password: "hunter2" on it, on purpose — the 201 response schema never lists a password property, and that’s the only thing deciding whether it leaves the server:
$ curl -s -X POST http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"Bob","email":"bob@example.com"}'
{"id":1,"name":"Bob","email":"bob@example.com"}
password is nowhere in that body, even though the handler’s return user statement returned an object that had it. Fastify compiles userSchema into a dedicated serializer (fast-json-stringify) for the 201 response and runs every returned value through it before writing bytes to the socket — a property the schema doesn’t list is never read out of the object, let alone sent. This is not an authorization check: it’s a side effect of how the JSON gets written, so a typo in a property name, a response schema that’s missing from one route but present on another, or a stray additionalProperties: true added later would let the exact same field straight through with no error at all.
A Failing Validation Is a Real 400
The same route’s body schema still runs before any of this — a request with a valid name but no email never reaches the handler, so there’s no password field to even consider dropping:
$ curl -s -X POST http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"Bob"}'
{"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":"body must have required property 'email'"}
Same { statusCode, code, error, message } shape The Same API in Fastify already showed for a missing field — createUserSchema.body’s required: ["name", "email"] rejects this request before app.post’s own async function ever starts running.
Swagger UI Is Served, No Browser Needed
@fastify/swagger itself never registers a single route — it only builds the document object and exposes app.swagger() to read it back in code. A small script that registers it alone, then adds @fastify/swagger-ui, shows Fastify’s own route tree both times:
// print-routes.js
import Fastify from "fastify";
import swagger from "@fastify/swagger";
import swaggerUi from "@fastify/swagger-ui";
const app = Fastify({ logger: false });
await app.register(swagger, {
openapi: { info: { title: "Users API", version: "1.0.0" } },
});
console.log("--- routes with only @fastify/swagger ---");
console.log(app.printRoutes());
await app.register(swaggerUi, { routePrefix: "/documentation" });
await app.ready();
console.log("--- routes with @fastify/swagger-ui added ---");
console.log(app.printRoutes());
process.exit(0);
$ docker run --rm -v "$(pwd)":/app -v nodejs-openapi-from-fastify-schemas-modules:/app/node_modules -w /app node:26.13 node print-routes.js
--- routes with only @fastify/swagger ---
(empty tree)
--- routes with @fastify/swagger-ui added ---
└── /
└── documentation (GET, HEAD)
└── / (GET, HEAD)
├── static/
│ ├── index.html (GET, HEAD)
│ ├── swagger-initializer.js (GET, HEAD)
│ └── * (HEAD, GET)
├── json (GET, HEAD)
└── yaml (GET, HEAD)
@fastify/swagger alone registers nothing — Fastify’s own printRoutes() reports (empty tree). Every route under /documentation, including /documentation/json, comes from @fastify/swagger-ui registering an actual HTML page a browser could render; without that plugin, the only way to read the generated document is calling app.swagger() in code. Confirmed on the running server with nothing but curl and a status line:
$ curl -s -i http://localhost:3000/documentation | grep -E "^HTTP|<title>"
HTTP/1.1 200 OK
<title>Swagger UI</title>
$ docker rm -f nodejs-openapi-demo2
nodejs-openapi-demo2
200 OK and <title>Swagger UI</title> are enough to confirm the page is really there without opening a browser — that <title> line is inside the actual HTML @fastify/swagger-ui serves, the same bundled interface that would call back into /documentation/json for its data if loaded in a real browser. Registering swaggerUi with no further configuration exposes every route’s full schema — every field name, every validation rule, every status code — to anyone who can reach /documentation; a production deployment usually disables it outright or puts it behind its own authentication, rather than registering it unconditionally the way this lesson does for the sake of a reachable demo.
Practice
Each solution below is a complete, standalone server.js built on the version from “A Route Without a Schema Gets an Empty Entry” (the one with both /users and /ping), not a diff against it.
1. Add GET /users/:id with no schema option, and compare its generated entry to /ping’s.
Solution
// server.js (added /users/:id, no schema)
import Fastify from "fastify";
import swagger from "@fastify/swagger";
import swaggerUi from "@fastify/swagger-ui";
const app = Fastify({ logger: false });
await app.register(swagger, {
openapi: { info: { title: "Users API", version: "1.0.0" } },
});
await app.register(swaggerUi, { routePrefix: "/documentation" });
let nextId = 1;
const userSchema = {
type: "object",
properties: {
id: { type: "integer" },
name: { type: "string" },
email: { type: "string" },
},
};
const createUserSchema = {
body: {
type: "object",
required: ["name", "email"],
properties: {
name: { type: "string", minLength: 2 },
email: { type: "string", format: "email" },
},
},
response: { 201: userSchema },
};
app.post("/users", { schema: createUserSchema }, async (req, reply) => {
const user = { id: nextId++, ...req.body, password: "hunter2" };
reply.code(201);
return user;
});
app.get("/ping", async () => ({ pong: true }));
app.get("/users/:id", async (req, reply) => ({ id: Number(req.params.id) }));
app.listen({ port: 3000, host: "0.0.0.0" }, (err) => {
if (err) {
app.log.error(err);
process.exit(1);
}
console.log("listening on :3000");
});
$ docker run -d --name nodejs-openapi-practice-noschema -p 3000:3000 -v "$(pwd)":/app -v nodejs-openapi-from-fastify-schemas-modules:/app/node_modules -w /app node:26.13 node server.js
4f2731a2802baf285137644e8e3f83adcbc2e47b91a22931dea164ff73b0d97d
$ until docker logs nodejs-openapi-practice-noschema 2>&1 | grep -q listening; do sleep 0.2; done
$ curl -s http://localhost:3000/documentation/json -o spec.json
$ docker exec nodejs-openapi-practice-noschema node -e "console.log(JSON.stringify(JSON.parse(require('fs').readFileSync('spec.json','utf8')).paths['/users/{id}'],null,2))"
{
"get": {
"parameters": [
{
"schema": {
"type": "string"
},
"in": "path",
"name": "id",
"required": true
}
],
"responses": {
"200": {
"description": "Default Response"
}
}
}
}
$ docker rm -f nodejs-openapi-practice-noschema
nodejs-openapi-practice-noschema
Not quite as empty as /ping’s entry: Fastify can read the parameter’s name straight off the route string /users/:id, with no schema involved at all, so @fastify/swagger still documents a parameters entry for id — defaulted to type: "string", since nothing told it otherwise. What a schema would have added — a real type: "integer", a description, anything about the 200 response’s actual shape — is still missing; the responses section is the exact same { "200": { "description": "Default Response" } } placeholder /ping got.
2. Add additionalProperties: true to the 201 response schema and confirm password now reaches the client.
Solution
// server.js (additionalProperties: true on the response schema)
import Fastify from "fastify";
import swagger from "@fastify/swagger";
import swaggerUi from "@fastify/swagger-ui";
const app = Fastify({ logger: false });
await app.register(swagger, {
openapi: { info: { title: "Users API", version: "1.0.0" } },
});
await app.register(swaggerUi, { routePrefix: "/documentation" });
let nextId = 1;
const userSchema = {
type: "object",
additionalProperties: true,
properties: {
id: { type: "integer" },
name: { type: "string" },
email: { type: "string" },
},
};
const createUserSchema = {
body: {
type: "object",
required: ["name", "email"],
properties: {
name: { type: "string", minLength: 2 },
email: { type: "string", format: "email" },
},
},
response: { 201: userSchema },
};
app.post("/users", { schema: createUserSchema }, async (req, reply) => {
const user = { id: nextId++, ...req.body, password: "hunter2" };
reply.code(201);
return user;
});
app.get("/ping", async () => ({ pong: true }));
app.listen({ port: 3000, host: "0.0.0.0" }, (err) => {
if (err) {
app.log.error(err);
process.exit(1);
}
console.log("listening on :3000");
});
$ docker run -d --name nodejs-openapi-practice-leak -p 3000:3000 -v "$(pwd)":/app -v nodejs-openapi-from-fastify-schemas-modules:/app/node_modules -w /app node:26.13 node server.js
878632d47816363dd5d386cfcbc1efd43e4e7af25f6cf4dc3fce7b607407c98f
$ until docker logs nodejs-openapi-practice-leak 2>&1 | grep -q listening; do sleep 0.2; done
$ curl -s -X POST http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"Nora","email":"nora@example.com"}'
{"id":1,"name":"Nora","email":"nora@example.com","password":"hunter2"}
$ docker rm -f nodejs-openapi-practice-leak
nodejs-openapi-practice-leak
password is back, in the clear, with the exact one-line change from the main example’s schema. Nothing about the request changed, nothing about the handler changed — additionalProperties: true just told the serializer to pass through any property it finds on the returned object, including ones nobody meant to document, which is precisely why response-schema filtering alone was never a safety guarantee to begin with.
3. Lower the body schema’s minLength on name from 2 to 1, and confirm a one-character name that the original schema rejects is accepted once it’s lowered.
Solution
First, the unmodified schema on its own — "name":"B" is one character, so minLength: 2 should reject it even though every required field is present:
// server.js (unmodified, same as "A Route Without a Schema Gets an Empty Entry")
import Fastify from "fastify";
import swagger from "@fastify/swagger";
import swaggerUi from "@fastify/swagger-ui";
const app = Fastify({ logger: false });
await app.register(swagger, {
openapi: { info: { title: "Users API", version: "1.0.0" } },
});
await app.register(swaggerUi, { routePrefix: "/documentation" });
let nextId = 1;
const userSchema = {
type: "object",
properties: {
id: { type: "integer" },
name: { type: "string" },
email: { type: "string" },
},
};
const createUserSchema = {
body: {
type: "object",
required: ["name", "email"],
properties: {
name: { type: "string", minLength: 2 },
email: { type: "string", format: "email" },
},
},
response: { 201: userSchema },
};
app.post("/users", { schema: createUserSchema }, async (req, reply) => {
const user = { id: nextId++, ...req.body, password: "hunter2" };
reply.code(201);
return user;
});
app.get("/ping", async () => ({ pong: true }));
app.listen({ port: 3000, host: "0.0.0.0" }, (err) => {
if (err) {
app.log.error(err);
process.exit(1);
}
console.log("listening on :3000");
});
$ docker run -d --name nodejs-openapi-practice-minlength-before -p 3000:3000 -v "$(pwd)":/app -v nodejs-openapi-from-fastify-schemas-modules:/app/node_modules -w /app node:26.13 node server.js
59da3fb6deb04defd94253329e5e7c844132e6f463db5dbd307a03942b14669c
$ until docker logs nodejs-openapi-practice-minlength-before 2>&1 | grep -q listening; do sleep 0.2; done
$ curl -s -X POST http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"B","email":"b@example.com"}'
{"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":"body/name must NOT have fewer than 2 characters"}
$ docker rm -f nodejs-openapi-practice-minlength-before
nodejs-openapi-practice-minlength-before
That message names minLength specifically, not a missing field — a different violation from the missing-email case earlier in this lesson, even though both come back as FST_ERR_VALIDATION. Lowering the limit to 1 is the only change needed:
// server.js (name minLength lowered to 1)
import Fastify from "fastify";
import swagger from "@fastify/swagger";
import swaggerUi from "@fastify/swagger-ui";
const app = Fastify({ logger: false });
await app.register(swagger, {
openapi: { info: { title: "Users API", version: "1.0.0" } },
});
await app.register(swaggerUi, { routePrefix: "/documentation" });
let nextId = 1;
const userSchema = {
type: "object",
properties: {
id: { type: "integer" },
name: { type: "string" },
email: { type: "string" },
},
};
const createUserSchema = {
body: {
type: "object",
required: ["name", "email"],
properties: {
name: { type: "string", minLength: 1 },
email: { type: "string", format: "email" },
},
},
response: { 201: userSchema },
};
app.post("/users", { schema: createUserSchema }, async (req, reply) => {
const user = { id: nextId++, ...req.body, password: "hunter2" };
reply.code(201);
return user;
});
app.get("/ping", async () => ({ pong: true }));
app.listen({ port: 3000, host: "0.0.0.0" }, (err) => {
if (err) {
app.log.error(err);
process.exit(1);
}
console.log("listening on :3000");
});
$ docker run -d --name nodejs-openapi-practice-minlength -p 3000:3000 -v "$(pwd)":/app -v nodejs-openapi-from-fastify-schemas-modules:/app/node_modules -w /app node:26.13 node server.js
0184ada79709cf81923e574e85cdece3b780eacb90a89872cf035bc75b9e7384
$ until docker logs nodejs-openapi-practice-minlength 2>&1 | grep -q listening; do sleep 0.2; done
$ curl -s -w '\nSTATUS:%{http_code}\n' -X POST http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"B","email":"b@example.com"}'
{"id":1,"name":"B","email":"b@example.com"}
STATUS:201
$ docker rm -f nodejs-openapi-practice-minlength
nodejs-openapi-practice-minlength
Same request, same server, only minLength changed — STATUS:201 this time. The validation and the generated OpenAPI document read the exact same object, so GET /documentation/json would now also advertise minLength: 1 for this field — the spec is never out of sync with what the server actually enforces, because there’s only one schema object feeding both.
4. Add a 400 entry to the route’s response schema and confirm it appears in the generated document under responses.400 — then check what that same change does to the route’s own validation-error response.
Solution
Before the change, a request missing email already gets Fastify’s own validation error, code and all — the unmodified route, no 400 entry in response yet:
// server.js (unmodified, same as "A Route Without a Schema Gets an Empty Entry")
import Fastify from "fastify";
import swagger from "@fastify/swagger";
import swaggerUi from "@fastify/swagger-ui";
const app = Fastify({ logger: false });
await app.register(swagger, {
openapi: { info: { title: "Users API", version: "1.0.0" } },
});
await app.register(swaggerUi, { routePrefix: "/documentation" });
let nextId = 1;
const userSchema = {
type: "object",
properties: {
id: { type: "integer" },
name: { type: "string" },
email: { type: "string" },
},
};
const createUserSchema = {
body: {
type: "object",
required: ["name", "email"],
properties: {
name: { type: "string", minLength: 2 },
email: { type: "string", format: "email" },
},
},
response: { 201: userSchema },
};
app.post("/users", { schema: createUserSchema }, async (req, reply) => {
const user = { id: nextId++, ...req.body, password: "hunter2" };
reply.code(201);
return user;
});
app.get("/ping", async () => ({ pong: true }));
app.listen({ port: 3000, host: "0.0.0.0" }, (err) => {
if (err) {
app.log.error(err);
process.exit(1);
}
console.log("listening on :3000");
});
$ docker run -d --name nodejs-openapi-practice-400resp-before -p 3000:3000 -v "$(pwd)":/app -v nodejs-openapi-from-fastify-schemas-modules:/app/node_modules -w /app node:26.13 node server.js
752721c4fb7749b8b254fab705d9fc0bbe1746f62a5f79983221328a4c256afd
$ until docker logs nodejs-openapi-practice-400resp-before 2>&1 | grep -q listening; do sleep 0.2; done
$ curl -s -X POST http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"Bob"}'
{"statusCode":400,"code":"FST_ERR_VALIDATION","error":"Bad Request","message":"body must have required property 'email'"}
$ docker rm -f nodejs-openapi-practice-400resp-before
nodejs-openapi-practice-400resp-before
Now add 400: errorSchema to the route’s own response object — errorSchema lists statusCode, error, and message, but not code:
// server.js (added a 400 response schema)
import Fastify from "fastify";
import swagger from "@fastify/swagger";
import swaggerUi from "@fastify/swagger-ui";
const app = Fastify({ logger: false });
await app.register(swagger, {
openapi: { info: { title: "Users API", version: "1.0.0" } },
});
await app.register(swaggerUi, { routePrefix: "/documentation" });
let nextId = 1;
const userSchema = {
type: "object",
properties: {
id: { type: "integer" },
name: { type: "string" },
email: { type: "string" },
},
};
const errorSchema = {
type: "object",
properties: {
statusCode: { type: "integer" },
error: { type: "string" },
message: { type: "string" },
},
};
const createUserSchema = {
body: {
type: "object",
required: ["name", "email"],
properties: {
name: { type: "string", minLength: 2 },
email: { type: "string", format: "email" },
},
},
response: { 201: userSchema, 400: errorSchema },
};
app.post("/users", { schema: createUserSchema }, async (req, reply) => {
const user = { id: nextId++, ...req.body, password: "hunter2" };
reply.code(201);
return user;
});
app.get("/ping", async () => ({ pong: true }));
app.listen({ port: 3000, host: "0.0.0.0" }, (err) => {
if (err) {
app.log.error(err);
process.exit(1);
}
console.log("listening on :3000");
});
$ docker run -d --name nodejs-openapi-practice-400resp -p 3000:3000 -v "$(pwd)":/app -v nodejs-openapi-from-fastify-schemas-modules:/app/node_modules -w /app node:26.13 node server.js
25a03b0284b82358c4af0fb3ab30b635b5cb03849a2fe423c4d55a5ec70895b9
$ until docker logs nodejs-openapi-practice-400resp 2>&1 | grep -q listening; do sleep 0.2; done
$ curl -s -X POST http://localhost:3000/users -H 'content-type: application/json' -d '{"name":"Bob"}'
{"statusCode":400,"error":"Bad Request","message":"body must have required property 'email'"}
$ curl -s http://localhost:3000/documentation/json -o spec.json
$ docker exec nodejs-openapi-practice-400resp node -e "console.log(JSON.stringify(JSON.parse(require('fs').readFileSync('spec.json','utf8')).paths['/users'].post.responses,null,2))"
{
"201": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"name": {
"type": "string"
},
"email": {
"type": "string"
}
}
}
}
}
},
"400": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"statusCode": {
"type": "integer"
},
"error": {
"type": "string"
},
"message": {
"type": "string"
}
}
}
}
}
}
}
$ docker rm -f nodejs-openapi-practice-400resp
nodejs-openapi-practice-400resp
Same request, same validation failure — but "code":"FST_ERR_VALIDATION" is gone from the body. Fastify doesn’t just document the 400 response schema, it enforces it: the moment the route has one, fast-json-stringify serializes every 400 response through it, including Fastify’s own internal validation error, and errorSchema never listed code as a property. responses in the generated document now has both 201 and 400 entries, errorSchema copied into the second one the same way userSchema was copied into the first — but that same schema object is also quietly reshaping a response this lesson never touched the code of.