API Documentation with Swagger/OpenAPI

An API without documentation is an API that no one can use confidently. OpenAPI (formerly Swagger) is the industry standard for describing REST APIs in a machine-readable format. From a single OpenAPI specification, you can generate interactive documentation that lets developers test your API directly in the browser, generate client code for Angular TypeScript services, and power contract testing between frontend and backend teams. This lesson covers writing OpenAPI 3.0 specifications, integrating Swagger UI into Express with swagger-ui-express, and using swagger-jsdoc to keep documentation in sync with your code by writing specs as JSDoc comments.

OpenAPI 3.0 Document Structure

Section Purpose
openapi Version string โ€” '3.0.3'
info API title, description, version, contact
servers Base URLs for development and production
paths All API endpoints โ€” method, parameters, request body, responses
components Reusable schemas, responses, parameters, security schemes
security Global security requirement (e.g. Bearer JWT)
tags Group endpoints โ€” Auth, Tasks, Users โ€” for organised documentation

OpenAPI Data Types

Type Format Example
string โ€” "hello"
string email "user@example.com"
string date-time "2025-01-15T10:30:00Z"
string password Hidden in Swagger UI
integer int32 / int64 42
number float / double 9.99
boolean โ€” true
array โ€” ["a", "b"]
object โ€” { "key": "value" }
Note: The swagger-jsdoc approach โ€” writing OpenAPI specs as JSDoc comments directly in your route or controller files โ€” keeps documentation close to the code that implements it. When you change a route’s parameters or response shape, you update the JSDoc comment in the same file. This is significantly better than maintaining a separate specification file that drifts out of sync as the codebase evolves.
Tip: Define your schemas once in components.schemas and reference them with $ref: '#/components/schemas/Task' everywhere they are used. This ensures the same schema definition is used for both the request body and the response body, and changes only need to be made in one place. It also makes the generated documentation cleaner because Swagger UI shows the schema name rather than an inline object.
Warning: In production, restrict access to the Swagger UI endpoint. Your API documentation reveals the complete structure of your API โ€” all endpoints, parameters, and data shapes โ€” which is useful for legitimate developers but also for attackers. Either disable the Swagger route in production (if (process.env.NODE_ENV !== 'production')), protect it behind authentication, or limit access to internal network IPs.

Basic Example โ€” swagger-jsdoc + swagger-ui-express

npm install swagger-jsdoc swagger-ui-express
// config/swagger.js โ€” OpenAPI specification and Swagger UI setup
const swaggerJsdoc      = require('swagger-jsdoc');
const swaggerUi         = require('swagger-ui-express');

const definition = {
    openapi: '3.0.3',
    info: {
        title:       'Task Manager API',
        description: 'Complete REST API for the MEAN Stack Task Manager application',
        version:     '1.0.0',
        contact: {
            name:  'API Support',
            email: 'support@taskmanager.io',
        },
    },
    servers: [
        { url: 'http://localhost:3000',     description: 'Development server' },
        { url: 'https://api.taskmanager.io', description: 'Production server' },
    ],
    components: {
        securitySchemes: {
            BearerAuth: {
                type:         'http',
                scheme:       'bearer',
                bearerFormat: 'JWT',
                description:  'Enter your JWT access token',
            },
        },
        schemas: {
            Task: {
                type: 'object',
                properties: {
                    _id:         { type: 'string', example: '64a1f2b3c8e4d5f6a7b8c9d0' },
                    title:       { type: 'string', example: 'Review pull request' },
                    description: { type: 'string', example: 'Review the authentication PR' },
                    priority: {
                        type: 'string',
                        enum: ['low', 'medium', 'high'],
                        example: 'high',
                    },
                    status: {
                        type: 'string',
                        enum: ['pending', 'in-progress', 'completed'],
                        example: 'pending',
                    },
                    dueDate:   { type: 'string', format: 'date-time' },
                    user:      { type: 'string', example: '64a1f2b3c8e4d5f6a7b8c9d1' },
                    createdAt: { type: 'string', format: 'date-time' },
                    updatedAt: { type: 'string', format: 'date-time' },
                },
            },
            CreateTaskDto: {
                type:     'object',
                required: ['title'],
                properties: {
                    title: {
                        type: 'string', minLength: 1, maxLength: 200,
                        example: 'Review pull request',
                    },
                    description: { type: 'string', maxLength: 2000 },
                    priority: {
                        type: 'string', enum: ['low', 'medium', 'high'],
                        default: 'medium',
                    },
                    dueDate: { type: 'string', format: 'date-time' },
                },
            },
            PaginationMeta: {
                type: 'object',
                properties: {
                    total:       { type: 'integer', example: 42 },
                    page:        { type: 'integer', example: 1 },
                    limit:       { type: 'integer', example: 10 },
                    totalPages:  { type: 'integer', example: 5 },
                    hasNextPage: { type: 'boolean', example: true },
                    hasPrevPage: { type: 'boolean', example: false },
                },
            },
            Error: {
                type: 'object',
                properties: {
                    success: { type: 'boolean', example: false },
                    message: { type: 'string',  example: 'Resource not found' },
                    errors:  { type: 'array', items: {
                        type: 'object',
                        properties: {
                            field:   { type: 'string' },
                            message: { type: 'string' },
                        },
                    }},
                },
            },
        },
    },
    security: [{ BearerAuth: [] }],
    tags: [
        { name: 'Auth',  description: 'Authentication and user management' },
        { name: 'Tasks', description: 'Task CRUD operations' },
    ],
};

const options = {
    definition,
    apis: ['./src/routes/*.js', './src/controllers/*.js'],  // scan these for JSDoc
};

const spec = swaggerJsdoc(options);

function setupSwagger(app) {
    if (process.env.NODE_ENV === 'production') return;  // disable in production

    app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(spec, {
        customSiteTitle: 'Task Manager API Docs',
        swaggerOptions: {
            persistAuthorization: true,   // keep JWT token across page refreshes
        },
    }));

    app.get('/api-docs.json', (req, res) => res.json(spec));  // raw spec endpoint

    console.log('Swagger UI: http://localhost:3000/api-docs');
}

module.exports = { setupSwagger };

JSDoc Comments in Route Files

// routes/task.routes.js โ€” with OpenAPI JSDoc comments

const express    = require('express');
const router     = express.Router();
const controller = require('../controllers/task.controller');
const auth       = require('../middleware/authenticate');

router.use(auth);

/**
 * @openapi
 * /api/v1/tasks:
 *   get:
 *     summary: Get all tasks
 *     description: Returns paginated list of tasks for the authenticated user with optional filtering and sorting.
 *     tags: [Tasks]
 *     security:
 *       - BearerAuth: []
 *     parameters:
 *       - in: query
 *         name: status
 *         schema:
 *           type: string
 *           enum: [pending, in-progress, completed]
 *         description: Filter by task status
 *       - in: query
 *         name: priority
 *         schema:
 *           type: string
 *           enum: [low, medium, high]
 *         description: Filter by priority level
 *       - in: query
 *         name: q
 *         schema:
 *           type: string
 *         description: Full-text search query (searches title and description)
 *       - in: query
 *         name: page
 *         schema:
 *           type: integer
 *           minimum: 1
 *           default: 1
 *       - in: query
 *         name: limit
 *         schema:
 *           type: integer
 *           minimum: 1
 *           maximum: 100
 *           default: 10
 *       - in: query
 *         name: sort
 *         schema:
 *           type: string
 *           example: -createdAt
 *         description: Sort field. Prefix with - for descending.
 *     responses:
 *       200:
 *         description: Paginated task list
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: true
 *                 data:
 *                   type: array
 *                   items:
 *                     $ref: '#/components/schemas/Task'
 *                 meta:
 *                   $ref: '#/components/schemas/PaginationMeta'
 *       401:
 *         description: Unauthorized โ€” JWT missing or expired
 *         content:
 *           application/json:
 *             schema:
 *               $ref: '#/components/schemas/Error'
 */
router.get('/', controller.getAll);

/**
 * @openapi
 * /api/v1/tasks:
 *   post:
 *     summary: Create a new task
 *     tags: [Tasks]
 *     security:
 *       - BearerAuth: []
 *     requestBody:
 *       required: true
 *       content:
 *         application/json:
 *           schema:
 *             $ref: '#/components/schemas/CreateTaskDto'
 *           example:
 *             title: "Review pull request"
 *             priority: "high"
 *             dueDate: "2025-12-31T23:59:59Z"
 *     responses:
 *       201:
 *         description: Task created successfully
 *         content:
 *           application/json:
 *             schema:
 *               type: object
 *               properties:
 *                 success:
 *                   type: boolean
 *                   example: true
 *                 data:
 *                   $ref: '#/components/schemas/Task'
 *       400:
 *         description: Validation error
 *         content:
 *           application/json:
 *             schema:
 *               $ref: '#/components/schemas/Error'
 *       401:
 *         description: Unauthorized
 */
router.post('/', controller.create);

/**
 * @openapi
 * /api/v1/tasks/{id}:
 *   delete:
 *     summary: Delete a task
 *     tags: [Tasks]
 *     security:
 *       - BearerAuth: []
 *     parameters:
 *       - in: path
 *         name: id
 *         required: true
 *         schema:
 *           type: string
 *         description: MongoDB ObjectId of the task
 *     responses:
 *       204:
 *         description: Task deleted โ€” no content
 *       404:
 *         description: Task not found
 *         content:
 *           application/json:
 *             schema:
 *               $ref: '#/components/schemas/Error'
 */
router.delete('/:id', controller.remove);

module.exports = router;

How It Works

Step 1 โ€” swagger-jsdoc Scans Files and Parses JSDoc Comments

swagger-jsdoc reads the files specified in the apis array, extracts all JSDoc blocks starting with @openapi or @swagger, parses the embedded YAML, and merges them with your base definition object. The result is a complete, valid OpenAPI JSON specification. This happens once at startup โ€” the spec is generated and cached in memory.

Step 2 โ€” swagger-ui-express Serves the Interactive Documentation

swaggerUi.serve serves Swagger UI’s static HTML, CSS, and JavaScript files. swaggerUi.setup(spec) generates the configuration endpoint that tells Swagger UI which spec to load. When you navigate to /api-docs, Swagger UI loads in the browser, fetches the spec, and renders the interactive documentation with expandable endpoint sections and a “Try it out” button for each operation.

Step 3 โ€” $ref References Keep Schemas DRY

When a schema defined in components.schemas is referenced with $ref: '#/components/schemas/Task', both the documentation and the generated client code (if using OpenAPI code generators) use the same definition. If you add a new field to the Task schema definition, it automatically appears in all endpoints that reference it โ€” in the request body validation section, the response body section, and any example objects.

Step 4 โ€” The Try It Out Feature Enables Live API Testing

Swagger UI’s “Try it out” button lets developers fill in parameters and request bodies directly in the browser and execute real HTTP requests against your API. By configuring persistAuthorization: true and the BearerAuth security scheme, developers can enter a JWT token once and have it automatically included in every test request โ€” replacing the need for Postman during development and onboarding.

Step 5 โ€” The Raw Spec Endpoint Powers Code Generation

Serving the raw OpenAPI JSON at /api-docs.json enables automated tooling. The Angular team can use tools like openapi-generator or @openapitools/openapi-generator-cli to generate fully-typed Angular services from your spec โ€” eliminating hand-written HTTP service code entirely. Every time the API changes, regenerating the client ensures the TypeScript types stay in sync with the actual API contract.

Real-World Example: app.js Integration

// app.js โ€” complete integration
const express          = require('express');
const { setupSwagger } = require('./config/swagger');

function createApp() {
    const app = express();

    app.use(require('helmet')());
    app.use(require('cors')({ origin: process.env.FRONTEND_URL }));
    app.use(express.json());

    // API routes
    app.use('/api/v1/auth',  require('./routes/auth.routes'));
    app.use('/api/v1/tasks', require('./routes/task.routes'));

    // Swagger docs โ€” development only
    setupSwagger(app);

    app.use(require('./middleware/notFound').notFound);
    app.use(require('./middleware/errorHandler'));

    return app;
}

module.exports = createApp;

// After starting the server, visit:
// http://localhost:3000/api-docs         โ€” interactive UI
// http://localhost:3000/api-docs.json    โ€” raw OpenAPI spec

Common Mistakes

Mistake 1 โ€” Leaving Swagger UI enabled in production

โŒ Wrong โ€” exposes full API structure to attackers:

app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(spec));
// In production โ€” anyone can see all your endpoints, parameters, and examples

✅ Correct โ€” disable in production or protect behind auth:

if (process.env.NODE_ENV !== 'production') {
    app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(spec));
}

Mistake 2 โ€” Duplicating schemas inline instead of using $ref

โŒ Wrong โ€” Task schema defined inline in 10 different places โ€” painful to update:

// Inline schema in GET /tasks response AND POST /tasks requestBody AND...
content: { 'application/json': {
    schema: {
        type: 'object',
        properties: { _id: {...}, title: {...}, priority: {...} }
    }
}}

✅ Correct โ€” define once in components, reference everywhere:

content: { 'application/json': {
    schema: { $ref: '#/components/schemas/Task' }
}}

Mistake 3 โ€” Not documenting error responses

โŒ Wrong โ€” only 200 response documented, errors are a mystery to consumers:

responses:
  200:
    description: Success

✅ Correct โ€” document all relevant status codes:

responses:
  200:
    description: Task updated
  400:
    description: Validation error
  401:
    description: Token missing or expired
  403:
    description: Not authorised to update this task
  404:
    description: Task not found
  500:
    description: Internal server error

Quick Reference

Task Code / Config
Install packages npm install swagger-jsdoc swagger-ui-express
Create spec swaggerJsdoc({ definition, apis: ['./src/routes/*.js'] })
Serve Swagger UI app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(spec))
Annotate route /** @openapi /path: get: summary: ... */
Reuse schema $ref: '#/components/schemas/Task'
JWT security security: [{ BearerAuth: [] }]
Path parameter in: path, name: id, required: true, schema: { type: string }
Query parameter in: query, name: page, schema: { type: integer, default: 1 }
Serve raw spec app.get('/api-docs.json', (req, res) => res.json(spec))

🧠 Test Yourself

The Task schema is defined in components.schemas. It is used in the response of GET /tasks, POST /tasks, GET /tasks/:id, and PUT /tasks/:id. If you add a new tags field to the Task schema, how many places in the spec need to be updated?