Skip to content
Codeloom
REST APIs

REST API Documentation with OpenAPI 3.1

Write machine-readable API documentation with OpenAPI 3.1. Learn the spec structure, Swagger UI setup, code generation, and best practices for keeping docs in sync.

·7 min read · By Codeloom
Intermediate 13 min read

What you'll learn

  • The structure of an OpenAPI 3.1 specification document
  • How to define paths, parameters, request bodies, and responses
  • How to set up Swagger UI to serve interactive docs
  • How to generate client SDKs and server stubs from your spec
  • Strategies for keeping documentation in sync with code

Prerequisites

  • Basic REST API design knowledge
  • Familiarity with JSON or YAML

Good API documentation is the difference between developers adopting your API in an afternoon or abandoning it after an hour. OpenAPI (formerly Swagger) gives you a machine-readable specification that powers interactive docs, client SDK generation, and automated testing — all from a single YAML file.

What is OpenAPI?

OpenAPI is a specification format for describing REST APIs. The current version is 3.1, which aligns with JSON Schema 2020-12. Your OpenAPI document describes every endpoint, parameter, request body, response, and authentication scheme your API supports.

The spec can be written in YAML or JSON. YAML is more readable, so it is the standard choice.

Anatomy of an OpenAPI 3.1 document

openapi: 3.1.0
info:
  title: Bookstore API
  description: A REST API for managing books and authors.
  version: 1.2.0
  contact:
    name: API Support
    email: api@example.com
  license:
    name: MIT
    url: https://opensource.org/licenses/MIT

servers:
  - url: https://api.bookstore.example.com/v1
    description: Production
  - url: https://staging-api.bookstore.example.com/v1
    description: Staging

paths:
  /books:
    get:
      summary: List all books
      operationId: listBooks
      tags: [Books]
      parameters:
        - $ref: '#/components/parameters/PageLimit'
        - $ref: '#/components/parameters/PageCursor'
        - name: genre
          in: query
          schema:
            type: string
            enum: [fiction, non-fiction, science, history]
      responses:
        '200':
          description: A paginated list of books
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BookList'
        '401':
          $ref: '#/components/responses/Unauthorized'

    post:
      summary: Create a new book
      operationId: createBook
      tags: [Books]
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateBookRequest'
            example:
              title: "The Pragmatic Programmer"
              author_id: "auth_001"
              isbn: "978-0135957059"
              genre: "non-fiction"
              price: 49.99
      responses:
        '201':
          description: Book created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Book'
        '422':
          $ref: '#/components/responses/ValidationError'

  /books/{bookId}:
    get:
      summary: Get a book by ID
      operationId: getBook
      tags: [Books]
      parameters:
        - name: bookId
          in: path
          required: true
          schema:
            type: string
            pattern: '^book_[a-z0-9]{8}$'
      responses:
        '200':
          description: A single book
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Book'
        '404':
          $ref: '#/components/responses/NotFound'

Components: reusable schemas

The components section holds reusable definitions. This is where you define your data models:

components:
  schemas:
    Book:
      type: object
      required: [id, title, author_id, isbn, genre, price, created_at]
      properties:
        id:
          type: string
          example: "book_a1b2c3d4"
        title:
          type: string
          maxLength: 500
          example: "The Pragmatic Programmer"
        author_id:
          type: string
          example: "auth_001"
        isbn:
          type: string
          pattern: '^\d{3}-\d{10}$'
          example: "978-0135957059"
        genre:
          type: string
          enum: [fiction, non-fiction, science, history]
        price:
          type: number
          minimum: 0
          example: 49.99
        created_at:
          type: string
          format: date-time

    CreateBookRequest:
      type: object
      required: [title, author_id, isbn, genre, price]
      properties:
        title:
          type: string
          maxLength: 500
        author_id:
          type: string
        isbn:
          type: string
          pattern: '^\d{3}-\d{10}$'
        genre:
          type: string
          enum: [fiction, non-fiction, science, history]
        price:
          type: number
          minimum: 0

    BookList:
      type: object
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Book'
        pagination:
          $ref: '#/components/schemas/CursorPagination'
        links:
          $ref: '#/components/schemas/PaginationLinks'

    CursorPagination:
      type: object
      properties:
        has_more:
          type: boolean
        next_cursor:
          type: string
          nullable: true

    PaginationLinks:
      type: object
      properties:
        self:
          type: string
          format: uri
        next:
          type: string
          format: uri
          nullable: true
        first:
          type: string
          format: uri

    ProblemDetail:
      type: object
      required: [type, title, status]
      properties:
        type:
          type: string
          format: uri
        title:
          type: string
        status:
          type: integer
        detail:
          type: string
        instance:
          type: string

  parameters:
    PageLimit:
      name: limit
      in: query
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20

    PageCursor:
      name: cursor
      in: query
      schema:
        type: string

  responses:
    Unauthorized:
      description: Authentication required
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'

    NotFound:
      description: Resource not found
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'

    ValidationError:
      description: Validation failed
      content:
        application/problem+json:
          schema:
            allOf:
              - $ref: '#/components/schemas/ProblemDetail'
              - type: object
                properties:
                  errors:
                    type: array
                    items:
                      type: object
                      properties:
                        field:
                          type: string
                        message:
                          type: string
                        code:
                          type: string

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

Serving interactive docs with Swagger UI

Swagger UI renders your OpenAPI spec as an interactive HTML page where developers can read docs and test endpoints directly in the browser.

Option 1: Express middleware

import express from 'express';
import swaggerUi from 'swagger-ui-express';
import YAML from 'yaml';
import fs from 'fs';

const app = express();
const spec = YAML.parse(fs.readFileSync('./openapi.yaml', 'utf-8'));

app.use('/docs', swaggerUi.serve, swaggerUi.setup(spec, {
  customCss: '.swagger-ui .topbar { display: none }',
  customSiteTitle: 'Bookstore API Docs',
}));

// Serve the raw spec for code generators
app.get('/openapi.yaml', (req, res) => {
  res.type('text/yaml').sendFile('./openapi.yaml', { root: process.cwd() });
});

Option 2: Static HTML

If you do not want a runtime dependency, use the Swagger UI CDN:

<!DOCTYPE html>
<html>
<head>
  <title>API Docs</title>
  <link rel="stylesheet"
    href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css" />
</head>
<body>
  <div id="swagger-ui"></div>
  <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
  <script>
    SwaggerUIBundle({
      url: '/openapi.yaml',
      dom_id: '#swagger-ui',
      deepLinking: true,
    });
  </script>
</body>
</html>

Code generation

One of the biggest benefits of OpenAPI is automated code generation. The spec drives client SDKs, server stubs, and even mock servers.

Generate a TypeScript client

npx @openapitools/openapi-generator-cli generate \
  -i openapi.yaml \
  -g typescript-fetch \
  -o ./generated/client \
  --additional-properties=supportsES6=true,typescriptThreePlus=true

This generates a fully typed client:

import { BooksApi, Configuration } from './generated/client';

const api = new BooksApi(new Configuration({
  basePath: 'https://api.bookstore.example.com/v1',
  accessToken: 'your-jwt-token',
}));

// Fully typed — IDE autocomplete for parameters and response
const bookList = await api.listBooks({ genre: 'fiction', limit: 10 });
console.log(bookList.data[0].title);

Generate a mock server

npx @stoplight/prism-cli mock openapi.yaml --port 4010

Prism reads your spec and returns example responses. Frontend teams can start building before the backend is ready.

Keeping docs in sync

The biggest challenge with OpenAPI is keeping the spec accurate as the code evolves. Three strategies:

Write the OpenAPI spec first, then generate server stubs and client code. The spec is the source of truth.

Pros: Forces design review before implementation. Generated code always matches the spec. Cons: Requires discipline to update the spec before changing code.

2. Code-first with annotations

Use decorators or annotations in your code to generate the spec automatically:

// Using express-openapi or tsoa
/**
 * @openapi
 * /books:
 *   get:
 *     summary: List all books
 *     responses:
 *       200:
 *         description: Success
 */
app.get('/books', listBooks);

Pros: Spec cannot drift from code. Cons: Comments are hard to review and can get stale.

3. Contract testing

Write tests that validate your API responses against the OpenAPI spec:

import { describe, it, expect } from 'vitest';
import OpenAPIValidator from 'openapi-response-validator';
import spec from './openapi.json';

describe('GET /books', () => {
  it('response matches OpenAPI spec', async () => {
    const res = await fetch('http://localhost:3000/api/books');
    const body = await res.json();

    const validator = new OpenAPIValidator({
      responses: spec.paths['/books'].get.responses,
    });

    const errors = validator.validateResponse(200, body);
    expect(errors).toBeUndefined();
  });
});

Best practices

  1. Use operationId on every path. It drives code generation naming.
  2. Add examples to schemas. They appear in Swagger UI and power mock servers.
  3. Use $ref aggressively. Reuse schemas, parameters, and responses.
  4. Tag your endpoints. Tags group operations in Swagger UI navigation.
  5. Version your spec. Use info.version and keep a changelog.
  6. Validate your spec in CI. Use spectral lint openapi.yaml to catch errors before merge.
# Add to your CI pipeline
npx @stoplight/spectral-cli lint openapi.yaml

Summary

OpenAPI 3.1 is the standard for REST API documentation. Write your spec in YAML, serve it with Swagger UI for interactive exploration, and use code generators to create typed clients. Choose spec-first development when possible, and add contract tests as a safety net. A well-maintained OpenAPI spec is not just documentation — it is an executable contract that keeps your API, your clients, and your team aligned.