EthicsComputerCourses
Join training +91 90532 10052

API Documentation with OpenAPI

Node.js · Lesson 23 of 43 Intermediate 8 min read Updated

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:

PartWhat it describes
openapiWhich version of the specification the file follows
infoTitle, version and description of your API
serversBase URLs, for example local and production
pathsEvery URL and HTTP method, with parameters, request body and responses
componentsReusable 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.

YAML
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/:id in Express.
  • $ref points to a reusable piece under components, so the NewOrder shape is written once and used in many places. allOf combines schemas: an Order is a NewOrder plus id, total and status.
  • Response codes are quoted strings ("201"), because YAML would otherwise read them as numbers.
  • securitySchemes says how clients log in. bearerAuth means "send Authorization: Bearer <token>", which matches the JWT Authentication lesson. The security list on each operation says which scheme it needs.
  • Document the errors too. A client developer needs to know about 400, 401 and 404 as much as about 201.

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:

Terminal
npm install swagger-ui-express yaml
JavaScript
import 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"));
Output
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:

JavaScript
// 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}`);
  }
}
Output
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 order

Teams 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:

JavaScript
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(", "));
Output
{
 "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, qty

z.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/:id in 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, paths and reusable components (schemas, responses, security schemes).
  • Data shapes are JSON Schema; use $ref to reuse them and document every response code.
  • swagger-ui-express serves 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
YAML
/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 id

The 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?

2. What does $ref: "#/components/schemas/Order" do?

3. Which package serves interactive docs from an Express app?

4. What is a good way to stop docs from drifting away from validation rules?

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