Skip to main content
Node.js · lesson 15 of 20
Node.js intermediate Lesson 15 of 20

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

nodejs fastify openapi swagger

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.

Frequently Asked Questions

Does @fastify/swagger write a spec file, or generate it on every request?
Neither exactly — it builds the OpenAPI document once, from the schema objects already attached to every registered route, and caches it. GET /documentation/json just serves that cached object; it isn't re-read from a file, and it isn't rebuilt per request. A route added to the source only shows up in that document after the process restarts and re-registers it, since Fastify itself refuses to add a route once app.listen() has already resolved.
Why does the schema-less route in this lesson still show a parameters array?
Fastify can read a path parameter's name straight off the route string itself (/users/:id) without any schema at all, so @fastify/swagger documents its existence and defaults its type to string. What it cannot infer without a schema is a description, a real type like integer, or anything about the response — those stay as the same empty { description: "Default Response" } boilerplate every undocumented route gets.
Is the response schema stripping the password field an authorization check?
No. It's serialization, not access control — fast-json-stringify, which Fastify builds from the route's response schema, only emits properties the schema lists, so a property like password that was never added to the schema never reaches the wire. A typo in a property name, a schema that's missing from one route but not another, or a stray additionalProperties: true on the schema would let the same field through with no error at all.
Does a route's 400 response schema apply to Fastify's own validation error, or only to errors the handler returns itself?
Both. Fastify serializes any response for a given status code through that status's response schema, regardless of whether the handler produced the body or Fastify's own validation layer did. Adding a 400 response schema that lists statusCode, error, and message but not code will silently drop code from Fastify's own FST_ERR_VALIDATION body too — the body schema still rejects the same invalid requests exactly as before, only the shape of the error response itself changes.
What Fastify and @fastify/swagger versions does this lesson use?
Fastify 5.12.8 with @fastify/swagger 9.9.3 and @fastify/swagger-ui 6.1.2, on Node.js 26.13.0 (Docker node:26.13). @fastify/swagger 9.x targets Fastify 5.x specifically; the plugin's own README pins that compatibility.