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:
| Term | What 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) |
@openapi JSDoc → swagger-jsdoc
spec = swaggerJsdoc(options) → OpenAPI JSON/YAML
/api-docs.json → swagger-ui-express
/api-docs
OpenAPI Document Anatomy
| Top-level key | Purpose | Required |
|---|---|---|
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));
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;
/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.
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' },
},
},
},
}
/**
* @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
*/
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);
});
});
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.
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.
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.