API Docs

Express.js Intermediate/Advanced
Course 2 · Chapter 7 · API Docs

An undocumented API forces every consumer to read the source code. OpenAPI 3.0 is a standard format for describing REST APIs — what routes exist, what they accept, what they return, and how to authenticate. swagger-jsdoc reads JSDoc comments in your route files and produces a spec JSON automatically. swagger-ui-express serves an interactive browser UI from that JSON so clients can explore and test the API without touching a terminal.

OpenAPI vs Swagger

"Swagger" and "OpenAPI" are often used interchangeably, which causes confusion. The history: Swagger was the original spec + tooling project. In 2016 it was donated to the OpenAPI Initiative and renamed OpenAPI Specification (OAS). The Swagger brand now refers only to the tooling (Swagger UI, swagger-jsdoc, Swagger Editor). So:

TermWhat it is
OpenAPI 3.0 / OAS The vendor-neutral specification for describing REST APIs (a JSON/YAML document)
Swagger UI A browser app that renders an OpenAPI spec as an interactive docs page
swagger-jsdoc An npm package that extracts @openapi JSDoc blocks from source files and produces the spec JSON
swagger-ui-express An npm package that serves Swagger UI at an Express route (e.g. /api-docs)
route files
@openapi JSDoc
swagger-jsdoc
spec = swaggerJsdoc(options)
OpenAPI JSON/YAML
/api-docs.json
swagger-ui-express
/api-docs

OpenAPI Document Anatomy

Top-level keyPurposeRequired
openapi Version string — always "3.0.0" (or "3.1.0")
info API metadata: title, version, description, contact
servers Array of base URLs (dev, staging, prod). Swagger UI uses the first one for "Try it out"
paths Every route, keyed by path (e.g. /api/products/{id}). Each path has method objects: get, post, etc.
components Reusable definitions: schemas, responses, parameters, securitySchemes
tags Named groups with descriptions — routes reference them to appear under a heading in the UI
security Default security applied to every path (can be overridden per-operation)

Setup — swagger-jsdoc + swagger-ui-express

// npm install swagger-jsdoc swagger-ui-express

const swaggerJsdoc = require('swagger-jsdoc');
const swaggerUi    = require('swagger-ui-express');
const path         = require('path');

const options = {
  definition: {
    openapi: '3.0.0',
    info: {
      title:       'Products API',
      version:     '1.0.0',
      description: 'CRUD API for the products catalogue',
    },
    servers: [
      { url: 'http://localhost:3000', description: 'Development' },
    ],
    tags: [
      { name: 'Products', description: 'Product catalogue endpoints' },
    ],
    components: {
      schemas: { /* defined here — see Components section */ },
      securitySchemes: {
        BearerAuth: {
          type:         'http',
          scheme:       'bearer',
          bearerFormat: 'JWT',
        },
      },
    },
  },
  // Glob patterns pointing at files with @openapi JSDoc blocks
  apis: [path.join(__dirname, './src/routes/*.js')],
};

const spec = swaggerJsdoc(options);  // generates the OpenAPI object

// Serve the interactive UI
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(spec));

// Expose raw JSON for client generators and tests
app.get('/api-docs.json', (_req, res) => res.json(spec));
The apis glob is the most common source of bugs
swagger-jsdoc resolves globs relative to process.cwd() (where you run node / jest), not relative to the file. Using path.join(__dirname, '...') makes the path absolute and immune to where you run the process from.

Annotating Routes with @openapi

swagger-jsdoc scans the files in the apis glob for JSDoc comment blocks that contain the @openapi tag (also accepted: @swagger). Everything after the tag is parsed as YAML. The YAML path must use curly-brace params ({id}), not the Express colon syntax (:id).

// src/routes/products.js

const router = require('express').Router();

/**
 * @openapi
 * /api/products:
 *   get:
 *     summary: List all products
 *     tags: [Products]
 *     parameters:
 *       - in: query
 *         name: search
 *         schema:
 *           type: string
 *         description: Filter by product name
 *       - in: query
 *         name: limit
 *         schema:
 *           type: integer
 *           default: 20
 *         description: Maximum number of results
 *     responses:
 *       200:
 *         description: Array of products
 *         content:
 *           application/json:
 *             schema:
 *               type: array
 *               items:
 *                 $ref: '#/components/schemas/Product'
 */
router.get('/', controller.list);

/**
 * @openapi
 * /api/products/{id}:
 *   get:
 *     summary: Get a single product by ID
 *     tags: [Products]
 *     parameters:
 *       - in: path
 *         name: id
 *         required: true
 *         schema:
 *           type: integer
 *         description: Product ID
 *     responses:
 *       200:
 *         description: A single product
 *         content:
 *           application/json:
 *             schema:
 *               $ref: '#/components/schemas/Product'
 *       404:
 *         description: Product not found
 *         content:
 *           application/json:
 *             schema:
 *               $ref: '#/components/schemas/ErrorResponse'
 */
router.get('/:id', controller.show);

module.exports = router;
Express :id → OpenAPI {id}
Express routes use /api/products/:id but OpenAPI paths use /api/products/{id}. swagger-jsdoc does not convert them — if you write :id in the YAML, the UI will show it literally as a colon-param and path parameter "Try it out" will be broken. Always use {id} in your @openapi blocks.

Parameters

OpenAPI parameters are declared with an in field that specifies their location. Each has a name, schema, and optional description and required flag.

in value Location Required by default? Example
path /products/{id} Yes — must be required: true {id}
query ?search=widget&limit=10 No ?search=
header Authorization: Bearer ... No (use securitySchemes instead) X-Request-ID
body Request body (JSON) N/A — use requestBody, not parameters POST body

Request Body

/**
 * @openapi
 * /api/products:
 *   post:
 *     summary: Create a product
 *     tags: [Products]
 *     security:
 *       - BearerAuth: []
 *     requestBody:
 *       required: true
 *       content:
 *         application/json:
 *           schema:
 *             $ref: '#/components/schemas/ProductInput'
 *     responses:
 *       201:
 *         description: Product created
 *         headers:
 *           Location:
 *             schema:
 *               type: string
 *             description: URL of the new resource
 *         content:
 *           application/json:
 *             schema:
 *               $ref: '#/components/schemas/Product'
 *       422:
 *         description: Validation error
 *         content:
 *           application/json:
 *             schema:
 *               $ref: '#/components/schemas/ValidationError'
 */
router.post('/', authenticate, controller.create);

Components and $ref

Defining a schema inline in every route response leads to duplication and drift. The components/schemas section lets you define a shape once and reference it with $ref: '#/components/schemas/Product' anywhere in the spec. swagger-jsdoc merges whatever is in definition.components.schemas with any schemas defined in @openapi blocks.

Define once (in options.definition)
components: {
  schemas: {
    Product: {
      type: 'object',
      properties: {
        id:    { type: 'integer' },
        name:  { type: 'string' },
        price: { type: 'number' },
        stock: { type: 'integer' },
      },
    },
    ProductInput: {
      type: 'object',
      required: ['name', 'price'],
      properties: {
        name:  { type: 'string', maxLength: 200 },
        price: { type: 'number', minimum: 0 },
        stock: { type: 'integer', minimum: 0 },
      },
    },
    ErrorResponse: {
      type: 'object',
      properties: {
        error:  { type: 'string' },
        status: { type: 'integer' },
      },
    },
  },
}
Reference everywhere (in @openapi blocks)
/**
 * @openapi
 * /api/products/{id}:
 *   get:
 *     responses:
 *       200:
 *         content:
 *           application/json:
 *             schema:
 *               $ref: '#/components/schemas/Product'
 *       404:
 *         content:
 *           application/json:
 *             schema:
 *               $ref: '#/components/schemas/ErrorResponse'
 *   patch:
 *     requestBody:
 *       content:
 *         application/json:
 *           schema:
 *             $ref: '#/components/schemas/ProductInput'
 *     responses:
 *       200:
 *         content:
 *           application/json:
 *             schema:
 *               $ref: '#/components/schemas/Product'
 */

Security Schemes

Security schemes are declared once in components/securitySchemes and then applied to individual operations with a security array. An empty array (- BearerAuth: []) means the scheme is required but no specific scopes are needed.

// In options.definition:
components: {
  securitySchemes: {
    BearerAuth: {
      type:         'http',
      scheme:       'bearer',
      bearerFormat: 'JWT',
    },
    ApiKeyAuth: {
      type: 'apiKey',
      in:   'header',
      name: 'X-API-Key',
    },
  },
}

// In a route's @openapi block:
/**
 *   delete:
 *     summary: Delete a product
 *     security:
 *       - BearerAuth: []    ← name must match the key in securitySchemes
 *     responses:
 *       204:
 *         description: Product deleted
 *       401:
 *         description: Unauthorized
 */
Global vs per-operation security
Setting security: [{ BearerAuth: [] }] at the document root applies to all operations. Individual operations can override this — including opting out with security: [] (empty array = no security required). Most APIs set no global security and mark individual routes explicitly so it's obvious which routes are public.

Testing the Spec

Because swaggerJsdoc(options) returns a plain JavaScript object, you can test the spec's structure directly without HTTP — fast, no server needed. Test coverage goals: every path exists, every method has at least one response, all $refs point to defined schemas, security is wired to protected routes.

// openapi.test.js
const spec = require('./src/openapi');  // exports swaggerJsdoc(options)

describe('OpenAPI spec structure', () => {
  test('is a valid OpenAPI 3.0 document', () => {
    expect(spec.openapi).toMatch(/^3\./);
    expect(spec.info.title).toBeTruthy();
    expect(spec.info.version).toBeTruthy();
  });

  test('contains all expected product paths', () => {
    expect(spec.paths).toHaveProperty('/api/products');
    expect(spec.paths).toHaveProperty('/api/products/{id}');
  });

  test('every operation has at least one response', () => {
    for (const [, methods] of Object.entries(spec.paths)) {
      for (const [method, op] of Object.entries(methods)) {
        if (method === 'parameters') continue;  // path-level params array
        expect(Object.keys(op.responses).length).toBeGreaterThan(0);
      }
    }
  });

  test('all $refs point to defined schemas', () => {
    const specStr = JSON.stringify(spec);
    const refRe   = /#\/components\/schemas\/(\w+)/g;
    let match;
    while ((match = refRe.exec(specStr)) !== null) {
      expect(spec.components?.schemas).toHaveProperty(match[1]);
    }
  });

  test('BearerAuth scheme is defined', () => {
    expect(spec.components.securitySchemes).toHaveProperty('BearerAuth');
  });

  test('POST /api/products requires BearerAuth', () => {
    const postOp   = spec.paths['/api/products'].post;
    const security = postOp.security ?? spec.security ?? [];
    expect(security.some((s) => 'BearerAuth' in s)).toBe(true);
  });
});
Also test via HTTP
For a full integration check, use supertest to call GET /api-docs.json and assert res.status === 200 and res.body.openapi is set. This catches config wiring errors (wrong glob path, missing app.use) that the unit-style spec tests above can't see.

Coding Challenges

Challenge 1 — Basic Setup

Wire up swagger-jsdoc and swagger-ui-express for a products CRUD API (GET /api/products, GET /api/products/{id}, POST, PATCH /api/products/{id}, DELETE). Annotate all five routes with @openapi blocks — each route needs at least two response codes. Expose /api-docs (UI) and /api-docs.json (raw spec). Write tests that verify all five paths exist, each operation has responses, and the correct HTTP status codes are present.

View solution

Challenge 2 — Schemas & $ref

Define four reusable schemas in components/schemas: Product, ProductInput, ErrorResponse, and ValidationError (which has a details array of {field, message} objects). Update all route annotations to use $ref instead of inline schemas. Write a test that scans the entire spec for $ref strings, extracts the schema name from each, and asserts it exists in components/schemas — so a broken reference is caught automatically.

View solution

Challenge 3 — Security & Full Validation

Add a BearerAuth security scheme and mark POST, PATCH, and DELETE as requiring it. Add query parameters to GET /api/products (search, limit, offset) and a path parameter to GET /api/products/{id}. Write a comprehensive spec test suite: openapi version check, all paths present, every operation has responses, all $refs resolve, protected routes have BearerAuth in their security array, query params are defined on GET /products, path param is defined on /products/{id}. End with an HTTP test via supertest.

View solution