API Documentation with OpenAPI
An API is only useful if other people can call it correctly. The front-end developer in Pune, the mobile team in Kochi and the partner company integrating with you all need the same answers: which URLs exist, what to send, what comes back, and which errors are possible. Explaining this over chat messages does not scale.
The industry standard for describing REST APIs is the OpenAPI Specification (older versions were called Swagger). You write one file that describes your API in a fixed format. Tools then turn that file into interactive documentation, generate client code and check requests. In this lesson you will write an OpenAPI file for an orders API, serve it as a web page with Swagger UI, and learn how to keep it from going out of date.
What an OpenAPI document contains
An OpenAPI document is a YAML or JSON file, usually named openapi.yaml. YAML is a text format that uses indentation instead of brackets, which makes long documents easier to read. The main parts are:
| Part | What it describes |
|---|---|
openapi | Which version of the specification the file follows |
info | Title, version and description of your API |
servers | Base URLs, for example local and production |
paths | Every URL and HTTP method, with parameters, request body and responses |
components | Reusable pieces: data schemas, common responses, security schemes |
The data shapes are written in JSON Schema, a standard vocabulary for saying "this is an object with a required string customer of 2 to 60 characters". OpenAPI 3.1 uses JSON Schema directly. The newest version of the specification is 3.2, but 3.1 has the widest tool support, so this lesson uses it.
Writing the document
Here is the description of two operations: placing an order and reading one. Read it top to bottom; the indentation shows what belongs to what.
openapi: 3.1.0
info:
title: Orders API
version: 1.0.0
description: Place and track orders for a small online shop.
servers:
- url: http://localhost:3000/api
paths:
/orders:
post:
summary: Place an order
tags: [Orders]
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/NewOrder"
example:
customer: Meera
items:
- { sku: SAREE-SILK, qty: 1 }
responses:
"201":
description: Order created
content:
application/json:
schema:
$ref: "#/components/schemas/Order"
"400":
$ref: "#/components/responses/BadRequest"
"401":
description: Missing or invalid token
/orders/{id}:
get:
summary: Get one order
tags: [Orders]
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema: { type: integer, minimum: 1 }
responses:
"200":
description: The order
content:
application/json:
schema:
$ref: "#/components/schemas/Order"
"404":
description: No order with this id
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
schemas:
NewOrder:
type: object
required: [customer, items]
properties:
customer: { type: string, minLength: 2, maxLength: 60 }
items:
type: array
minItems: 1
items:
type: object
required: [sku, qty]
properties:
sku: { type: string }
qty: { type: integer, minimum: 1, maximum: 20 }
Order:
allOf:
- $ref: "#/components/schemas/NewOrder"
- type: object
properties:
id: { type: integer, example: 1042 }
total: { type: integer, description: Total in rupees including GST, example: 1061 }
status: { type: string, enum: [placed, shipped, delivered, cancelled] }
responses:
BadRequest:
description: The request body failed validation
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: Invalid input }Some details worth noticing:
- Path parameters use curly braces:
/orders/{id}in OpenAPI is the same route as/orders/:idin Express. $refpoints to a reusable piece undercomponents, so theNewOrdershape is written once and used in many places.allOfcombines schemas: anOrderis aNewOrderplusid,totalandstatus.- Response codes are quoted strings (
"201"), because YAML would otherwise read them as numbers. securitySchemessays how clients log in.bearerAuthmeans "sendAuthorization: Bearer <token>", which matches the JWT Authentication lesson. Thesecuritylist on each operation says which scheme it needs.- Document the errors too. A client developer needs to know about
400,401and404as much as about201.
Serving interactive documentation
Swagger UI turns the file into a web page where people can read every endpoint and even send test requests from the browser. The swagger-ui-express package serves it from your Express app, and the yaml package reads the YAML file:
npm install swagger-ui-express yamlimport express from "express";
import swaggerUi from "swagger-ui-express";
import { parse } from "yaml";
import { readFile } from "node:fs/promises";
const spec = parse(await readFile(new URL("./openapi.yaml", import.meta.url), "utf8"));
const app = express();
app.get("/openapi.json", (req, res) => res.json(spec));
app.use("/docs", swaggerUi.serve, swaggerUi.setup(spec));
app.listen(3000, () => console.log("Docs at http://localhost:3000/docs"));Docs at http://localhost:3000/docs
Open http://localhost:3000/docs and you see a page titled "Swagger UI" with an "Orders" group containing POST /orders and GET /orders/{id}. Each one expands to show its parameters, the example body and every response. The Authorize button lets you paste a JWT, and Try it out sends a real request. The raw document at /openapi.json is what other tools and teams download. Redoc and Scalar are popular alternatives to Swagger UI that read the same file.
Using the document in code
Because the document is plain data, small scripts can use it. This one prints a summary of every operation, which is handy in code review:
// list-endpoints.js: print every documented operation
import { readFile } from "node:fs/promises";
import { parse } from "yaml";
const spec = parse(await readFile("openapi.yaml", "utf8"));
console.log(`${spec.info.title} v${spec.info.version} (OpenAPI ${spec.openapi})`);
for (const [path, methods] of Object.entries(spec.paths)) {
for (const [method, op] of Object.entries(methods)) {
const codes = Object.keys(op.responses).join(", ");
const auth = op.security ? "token" : "public";
console.log(`${method.toUpperCase().padEnd(6)} ${path.padEnd(14)} ${auth.padEnd(7)} ${codes} ${op.summary}`);
}
}Orders API v1.0.0 (OpenAPI 3.1.0)
POST /orders token 201, 400, 401 Place an order
GET /orders/{id} token 200, 404 Get one orderTeams also feed the document to linters (for example Redocly CLI or Spectral) that catch mistakes such as a missing response description, and to code generators that produce typed client functions for the front end, so nobody hand-writes fetch calls with the wrong field names.
Keeping docs in sync with Zod
The biggest problem with API docs is that they go stale: a developer adds a field to the validation schema and forgets the YAML. One fix is to generate the schema part from the same Zod schema that validates requests. Zod 4 has z.toJSONSchema() built in:
import { z } from "zod";
const NewOrder = z.object({
customer: z.string().min(2).max(60),
pincode: z.string().regex(/^\d{6}$/),
qty: z.number().int().min(1).max(20),
note: z.string().max(200).optional(),
});
const jsonSchema = z.toJSONSchema(NewOrder);
console.log(JSON.stringify(jsonSchema.properties, null, 1));
console.log("required:", jsonSchema.required.join(", "));{
"customer": {
"type": "string",
"minLength": 2,
"maxLength": 60
},
"pincode": {
"type": "string",
"pattern": "^\\d{6}$"
},
"qty": {
"type": "integer",
"minimum": 1,
"maximum": 20
},
"note": {
"type": "string",
"maxLength": 200
}
}
required: customer, pincode, qtyz.number().int() became "type": "integer", the regex became pattern, and the optional note is left out of required. You can put this output under components.schemas when your app builds its spec object, so validation and docs come from one source. Community libraries go further and build the whole OpenAPI document from Zod schemas and route definitions. Frameworks such as Fastify and NestJS can produce OpenAPI documents from their route schemas too.
Common mistakes
- Writing
/orders/:idin the OpenAPI file. OpenAPI uses{id}; the colon form is Express syntax. - Documenting only the happy path. List error responses and their body shape too.
- Unquoted status codes. Write
"200":so YAML keeps them as strings. - Docs that nobody updates. Generate schemas from Zod, lint the file in CI, and review docs in the same pull request as the code.
- Publishing internal or admin endpoints to the internet. Protect or hide the docs for private APIs.
Key points
- OpenAPI is the standard file format for describing REST APIs; Swagger is its older name and the name of popular tools.
- A document has
openapi,info,servers,pathsand reusablecomponents(schemas, responses, security schemes). - Data shapes are JSON Schema; use
$refto reuse them and document every response code. swagger-ui-expressserves interactive docs; also expose the raw document for tools and other teams.- Generate schemas from your Zod validation (
z.toJSONSchema()) so docs and code do not drift apart.
Exercise
Add a PATCH /orders/{id}/status operation to the OpenAPI document. It takes a path id (integer), requires a JSON body with one property status whose value must be shipped, delivered or cancelled, needs the bearerAuth scheme, and can respond 200 (the updated Order), 400, 403 ("Only staff can change status") and 404.
Show solution
/orders/{id}/status:
patch:
summary: Change an order's status
tags: [Orders]
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema: { type: integer, minimum: 1 }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [status]
properties:
status: { type: string, enum: [shipped, delivered, cancelled] }
responses:
"200":
description: The updated order
content:
application/json:
schema:
$ref: "#/components/schemas/Order"
"400":
$ref: "#/components/responses/BadRequest"
"403":
description: Only staff can change status
"404":
description: No order with this idThe new path goes under paths, at the same indentation as /orders/{id}. It reuses the Order schema and the shared BadRequest response with $ref.
Quick Quiz
1. In an OpenAPI document, how is the Express route /orders/:id written?
OpenAPI path templates use curly braces.
2. What does $ref: "#/components/schemas/Order" do?
$ref points to a reusable definition so you write it once.
3. Which package serves interactive docs from an Express app?
swaggerUi.serve and swaggerUi.setup(spec) are mounted on a path such as /docs.
4. What is a good way to stop docs from drifting away from validation rules?
One source of truth keeps validation and docs in step.
Learn Node.js with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
