LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

OpenAPI and Swagger: Designing and Documenting APIs Contract-First

openapiswaggerapirestdocumentationapi-designdevopsdeveloper-experience

Every API starts the same way: someone opens a blank file (or a blank Express router) and starts writing endpoints. Six months later, the API is a reflection of implementation details no one consciously chose — query parameters named after database column names, response shapes that evolved from whatever was convenient to return, inconsistent error formats across endpoints, and documentation that’s already out of date.

Contract-first API design is the discipline of defining the API before writing implementation code — treating the API spec as the primary artifact, not an afterthought. The OpenAPI Specification is the industry standard for doing exactly that.

This post is a practical deep dive: what OpenAPI is, how to structure a complete spec, and how to build a workflow where your spec drives documentation, mock servers, client generation, and CI validation automatically.


OpenAPI vs Swagger: Clearing Up the Naming Confusion

If you’ve spent any time in API tooling, you’ve run into both “Swagger” and “OpenAPI” and probably assumed they were the same thing. They mostly are, but the distinction matters.

The history: Swagger was created in 2010 by Tony Tam at Wordnik as a way to describe their REST API and automatically generate documentation and client SDKs. It gained wide adoption through Swagger 2.0, which became the de facto standard for REST API description.

In 2016, SmartBear (which had acquired the Swagger project) donated the specification to the Linux Foundation, which created the OpenAPI Initiative (OAI). The spec was renamed the OpenAPI Specification (OAS), and version 3.0 was released under the new name.

The current state:

  • “OpenAPI” refers to the specification itself — the YAML/JSON format for describing REST APIs
  • “Swagger” now refers to the tooling from SmartBear: Swagger UI, Swagger Editor, Swagger Codegen, and Swagger Hub
  • OpenAPI 2.0 is what used to be called “Swagger 2.0” — still common in legacy systems
  • OpenAPI 3.0.x — the first major redesign; widely supported across all major frameworks
  • OpenAPI 3.1 — the current recommended version; achieves full JSON Schema compatibility (3.0 used a modified subset of JSON Schema that caused subtle incompatibilities)

When someone says “Swagger spec,” they usually mean an OpenAPI 2.0 document. When they say “OpenAPI,” they could mean 3.0 or 3.1. For new projects, use 3.1.


Why Contract-First API Design?

The Code-First Problem

When you write implementation code first and generate (or write) documentation afterward, several things happen:

  • The API shape reflects implementation details, not user needs. Routes are named after internal services. Response fields are whatever the ORM returns. Pagination works the way the database layer made easiest.
  • Frontend and backend can’t develop in parallel. Frontend is blocked waiting for real endpoints to exist.
  • Breaking changes are invisible until something breaks. There’s no canonical artifact to diff.
  • Documentation lags reality. By the time someone writes the docs, the API has already changed three times.
  • Design review is expensive. Changing an API after code is written means code changes, migration scripts, and client updates.

Contract-First Fixes This

The contract-first workflow flips the order: you design the API in OpenAPI YAML first, review it with stakeholders, then implement against it. The spec is the source of truth.

Frontend and backend develop in parallel. Once the spec exists, frontend can run a mock server (prism mock openapi.yaml) and start building UI against it immediately, while backend implements the real endpoints.

Breaking changes are visible in git diffs. If someone removes a field from a response schema, that’s a one-line change in YAML that shows up in a PR diff and can be caught by automated tooling.

Client SDKs exist before implementation. Generate a typed TypeScript client from the spec on day one. The frontend is using the correct types before a single real endpoint is deployed.

Design review happens before code is written. Reviewing an OpenAPI spec takes 30 minutes. Reviewing the same changes after they’re implemented takes hours. Changing a field name in YAML is a one-second edit. Changing it after implementation involves migrations, client updates, and a deprecation cycle.

Documentation is always accurate. If the spec is the source of truth and you validate requests against it in production, the docs can’t drift from reality.

When Code-First Is Fine

Not every API needs contract-first rigor:

  • Internal tools and scripts — small surface area, single consumer, can change freely
  • Rapid prototyping — you’re still figuring out what the API should be
  • Evolving APIs — early-stage products where the shape changes weekly

In these cases, code-first with a framework like FastAPI (which auto-generates OpenAPI from type hints) is perfectly reasonable. Just plan to clean up the generated spec before treating it as a published contract.


OpenAPI 3.1 Document Structure

An OpenAPI document is a single YAML (or JSON) file that describes your entire API. Here’s the top-level structure:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
openapi: "3.1.0"
info:
  title: LunarOps API
  version: "1.0.0"
  description: |
    The LunarOps API provides access to user management, content,
    and operational resources.

    ## Authentication
    Most endpoints require a Bearer token. Obtain one via `POST /auth/token`.

    ## Rate Limiting
    All endpoints are rate-limited to 1000 requests per minute per API key.
    See response headers `X-RateLimit-Remaining` and `X-RateLimit-Reset`.
  contact:
    name: API Support
    email: api@lunarops.example.com
    url: https://lunarops.example.com/support
  license:
    name: MIT
    url: https://opensource.org/licenses/MIT

servers:
  - url: https://api.lunarops.example.com/v1
    description: Production
  - url: https://staging-api.lunarops.example.com/v1
    description: Staging
  - url: http://localhost:8080/v1
    description: Local development

tags:
  - name: users
    description: User account management
  - name: posts
    description: Blog post operations
  - name: auth
    description: Authentication and token management

paths: {}        # your endpoints go here

components: {}   # reusable schemas, parameters, responses, security schemes

The four sections you’ll spend most of your time in:

  • paths — every endpoint and its supported HTTP methods
  • components — reusable building blocks: schemas, parameters, responses, security schemes, examples
  • info — metadata: title, version, description, contact, license
  • servers — base URLs for different environments (production, staging, local)

Defining Paths and Operations

Path Item Structure

Each key under paths is a URL template. Under each URL, you define one or more HTTP method operations. Parameters shared across all methods on a path (like a path parameter) can be defined at the path level.

Operation Object Anatomy

Each operation (a specific HTTP method on a path) has:

  • operationId — a unique, machine-readable identifier used in code generation
  • summary — one-line description (shows in navigation)
  • description — longer description with Markdown support
  • tags — groups the endpoint in documentation
  • parameters — path, query, header, and cookie parameters
  • requestBody — the request payload (POST, PUT, PATCH)
  • responses — every possible response, keyed by status code
  • security — overrides the global security requirement

Here’s a complete CRUD example for a /users resource:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
paths:
  /users:
    get:
      operationId: listUsers
      summary: List all users
      tags: [users]
      parameters:
        - name: page
          in: query
          description: Page number (1-indexed)
          schema:
            type: integer
            minimum: 1
            default: 1
        - name: limit
          in: query
          description: Number of results per page
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: role
          in: query
          description: Filter by user role
          schema:
            type: string
            enum: [admin, editor, viewer]
      responses:
        "200":
          description: Paginated list of users
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

    post:
      operationId: createUser
      summary: Create a new user
      tags: [users]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateUserRequest"
            example:
              email: "alice@example.com"
              password: "s3cur3P@ssword"
              displayName: "Alice Smith"
      responses:
        "201":
          description: User created successfully
          headers:
            Location:
              description: URL of the newly created user
              schema:
                type: string
                format: uri
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
        "400":
          $ref: "#/components/responses/BadRequest"
        "409":
          $ref: "#/components/responses/Conflict"

  /users/{userId}:
    parameters:
      - name: userId
        in: path
        required: true
        description: The unique identifier of the user
        schema:
          type: string
          format: uuid
          example: "123e4567-e89b-12d3-a456-426614174000"

    get:
      operationId: getUser
      summary: Get a user by ID
      tags: [users]
      responses:
        "200":
          description: User found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"

    put:
      operationId: updateUser
      summary: Update a user
      tags: [users]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateUserRequest"
      responses:
        "200":
          description: User updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"

    delete:
      operationId: deleteUser
      summary: Delete a user
      tags: [users]
      responses:
        "204":
          description: User deleted successfully
        "404":
          $ref: "#/components/responses/NotFound"

Note the $ref syntax throughout — it’s how you reference reusable components defined elsewhere in the spec, keeping things DRY.


Schemas: The Heart of Your API Contract

Schemas define the shape of your data — request bodies, response payloads, and the types of individual fields. OpenAPI 3.1 uses full JSON Schema draft 2020-12, which means any valid JSON Schema is valid in your OpenAPI spec.

Primitive Types and Formats

OpenAPI supports these primitive types: string, integer, number, boolean, array, object, and (in 3.1) null.

String formats carry semantic meaning for validators and code generators:

Format Meaning
date-time ISO 8601 datetime: 2026-03-25T14:30:00Z
date ISO 8601 date: 2026-03-25
time ISO 8601 time: 14:30:00Z
email Valid email address
uuid UUID v4
uri Full URI
byte Base64-encoded binary
binary Raw binary (for file uploads)
password Hints to UIs to hide the value

Complete Schema Examples

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
components:
  schemas:
    User:
      type: object
      required: [id, email, role, createdAt]
      properties:
        id:
          type: string
          format: uuid
          readOnly: true
          description: Unique identifier, assigned by the server
          example: "123e4567-e89b-12d3-a456-426614174000"
        email:
          type: string
          format: email
          example: "alice@example.com"
        displayName:
          type: string
          minLength: 1
          maxLength: 100
          example: "Alice Smith"
        role:
          type: string
          enum: [admin, editor, viewer]
          default: viewer
          description: Access level for the user
        avatarUrl:
          type: ["string", "null"]   # OpenAPI 3.1 null handling
          format: uri
          readOnly: true
        createdAt:
          type: string
          format: date-time
          readOnly: true
          example: "2026-01-15T09:00:00Z"
        updatedAt:
          type: string
          format: date-time
          readOnly: true

    CreateUserRequest:
      type: object
      required: [email, password]
      properties:
        email:
          type: string
          format: email
        password:
          type: string
          format: password
          minLength: 8
          writeOnly: true
          description: Must be at least 8 characters
        displayName:
          type: string
          minLength: 1
          maxLength: 100

    UpdateUserRequest:
      type: object
      minProperties: 1
      properties:
        displayName:
          type: string
          minLength: 1
          maxLength: 100
        role:
          type: string
          enum: [admin, editor, viewer]

    UserList:
      type: object
      required: [data, pagination]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/User"
        pagination:
          $ref: "#/components/schemas/Pagination"

    Pagination:
      type: object
      required: [total, page, limit, hasNext]
      properties:
        total:
          type: integer
          description: Total number of records
          example: 143
        page:
          type: integer
          example: 2
        limit:
          type: integer
          example: 20
        hasNext:
          type: boolean
          example: true

Composition: allOf, oneOf, anyOf

These keywords enable schema composition and polymorphism.

allOf merges schemas together, similar to inheritance:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
AdminUser:
  allOf:
    - $ref: "#/components/schemas/User"
    - type: object
      properties:
        permissions:
          type: array
          items:
            type: string
        lastLoginAt:
          type: string
          format: date-time

oneOf means exactly one of the listed schemas must match. Pair it with discriminator for polymorphic responses:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Notification:
  oneOf:
    - $ref: "#/components/schemas/EmailNotification"
    - $ref: "#/components/schemas/PushNotification"
    - $ref: "#/components/schemas/SMSNotification"
  discriminator:
    propertyName: type
    mapping:
      email: "#/components/schemas/EmailNotification"
      push: "#/components/schemas/PushNotification"
      sms: "#/components/schemas/SMSNotification"

EmailNotification:
  type: object
  required: [type, to, subject]
  properties:
    type:
      type: string
      const: email
    to:
      type: string
      format: email
    subject:
      type: string

readOnly and writeOnly

  • readOnly: true — the field appears in responses but is ignored in request bodies (e.g., id, createdAt)
  • writeOnly: true — the field appears in request bodies but is never returned in responses (e.g., password)

This is important for code generators — they’ll create separate types for request and response payloads.

OpenAPI 3.0 vs 3.1 Nullable Handling

In OpenAPI 3.0, nullable fields required a vendor extension:

1
2
3
4
# 3.0 style — awkward
avatarUrl:
  type: string
  nullable: true

In OpenAPI 3.1, you use standard JSON Schema:

1
2
3
# 3.1 style — clean
avatarUrl:
  type: ["string", "null"]

Parameters, Request Bodies, and Responses

Parameter Locations

Parameters can live in four places:

  • in: path — part of the URL path (always required: true)
  • in: query — after the ? in the URL
  • in: header — HTTP request headers
  • in: cookie — HTTP cookies

Define commonly reused parameters in components/parameters:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
components:
  parameters:
    PageParam:
      name: page
      in: query
      schema:
        type: integer
        minimum: 1
        default: 1
    LimitParam:
      name: limit
      in: query
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20
    UserIdParam:
      name: userId
      in: path
      required: true
      schema:
        type: string
        format: uuid

Then reference them anywhere:

1
2
3
4
5
/users/{userId}/posts:
  parameters:
    - $ref: "#/components/parameters/UserIdParam"
    - $ref: "#/components/parameters/PageParam"
    - $ref: "#/components/parameters/LimitParam"

Reusable Responses

Standardize your error responses in components/responses:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
components:
  responses:
    Unauthorized:
      description: Authentication required or token invalid
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            code: "UNAUTHORIZED"
            message: "A valid Bearer token is required"

    Forbidden:
      description: Insufficient permissions for this operation
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

    NotFound:
      description: The requested resource was not found
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

    Conflict:
      description: Resource already exists or state conflict
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"

    BadRequest:
      description: Invalid request data
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ValidationError"

  schemas:
    Error:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
          example: "NOT_FOUND"
        message:
          type: string
          example: "User not found"
        requestId:
          type: string
          format: uuid
          description: Use this ID when contacting support

    ValidationError:
      allOf:
        - $ref: "#/components/schemas/Error"
        - type: object
          properties:
            fields:
              type: object
              description: Field-level validation errors
              additionalProperties:
                type: array
                items:
                  type: string
              example:
                email: ["Must be a valid email address"]
                password: ["Must be at least 8 characters"]

Response Headers

Use the headers field on a response to document important response headers:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
"201":
  description: Resource created
  headers:
    Location:
      description: URL of the newly created resource
      schema:
        type: string
        format: uri
    X-Resource-Id:
      description: ID of the newly created resource
      schema:
        type: string
        format: uuid
  content:
    application/json:
      schema:
        $ref: "#/components/schemas/User"

The default response key is a catch-all for any status code not explicitly listed — useful for unexpected server errors:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
responses:
  "200":
    description: Success
    content:
      application/json:
        schema:
          $ref: "#/components/schemas/User"
  default:
    description: Unexpected error
    content:
      application/json:
        schema:
          $ref: "#/components/schemas/Error"

Authentication and Security Schemes

Defining Security Schemes

Security schemes are defined in components/securitySchemes and then applied globally or per-operation.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        JWT token obtained from `POST /auth/token`.
        Include as: `Authorization: Bearer <token>`

    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-API-Key
      description: Service-to-service API key

    OAuth2:
      type: oauth2
      flows:
        authorizationCode:
          authorizationUrl: https://auth.lunarops.example.com/oauth/authorize
          tokenUrl: https://auth.lunarops.example.com/oauth/token
          scopes:
            "users:read": Read user information
            "users:write": Create and update users
            "posts:read": Read posts
            "posts:write": Create and update posts
        clientCredentials:
          tokenUrl: https://auth.lunarops.example.com/oauth/token
          scopes:
            "users:read": Read user information
            "posts:read": Read posts

Applying Security

Set a global security requirement at the top level:

1
2
security:
  - BearerAuth: []

Override per-operation to require specific OAuth scopes, use a different scheme, or mark an endpoint as public:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
paths:
  /posts:
    get:
      operationId: listPosts
      summary: List published posts
      security: []   # Public endpoint — no auth required
      tags: [posts]
      responses:
        "200":
          description: List of published posts
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PostList"

  /posts/{postId}:
    put:
      operationId: updatePost
      summary: Update a post
      security:
        - OAuth2: ["posts:write"]   # Require specific OAuth scope
      tags: [posts]
      # ...

The Tooling Ecosystem

Documentation Renderers

Swagger UI is the classic: a self-hosted interactive documentation page where users can read your API docs and try endpoints directly in the browser. Every major framework has built-in Swagger UI support. You can also self-host it with Docker:

1
2
3
docker run -p 8080:8080 \
  -e SWAGGER_JSON_URL=https://api.example.com/openapi.yaml \
  swaggerapi/swagger-ui

Redoc renders a cleaner, three-panel layout (navigation, content, code samples) that many teams prefer for published documentation:

1
2
3
docker run -p 8080:80 \
  -e SPEC_URL=https://api.example.com/openapi.yaml \
  redocly/redoc

Scalar is the modern newcomer — beautiful API docs with a built-in API client. If you’re choosing a documentation renderer for a new project today, Scalar is worth serious consideration. It’s replacing Swagger UI in many teams’ stacks.

Editors

editor.swagger.io is the browser-based Swagger Editor — paste in your YAML and see a live preview with validation. Good for quick edits.

Stoplight Studio is the GUI-based OpenAPI editor. It provides form-based editing (no YAML required), live documentation preview, built-in mock servers, and style guide enforcement. For teams that aren’t comfortable with raw YAML, it’s an excellent entry point.

Code Generation

This is where contract-first pays off most visibly. Generate typed clients and server stubs before writing a single line of implementation code.

openapi-generator (Java-based, 50+ languages):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# Generate a Python client
openapi-generator generate \
  -i openapi.yaml \
  -g python \
  -o ./client-python \
  --additional-properties=packageName=lunarops_client

# Generate a TypeScript fetch client
openapi-generator generate \
  -i openapi.yaml \
  -g typescript-fetch \
  -o ./client-ts

oapi-codegen (Go) — generates idiomatic Go server interfaces and types:

1
oapi-codegen -package api openapi.yaml > api/api.gen.go

Your server then implements the generated interface, and the framework validates that all endpoints are covered at compile time.

orval (TypeScript + React Query) — generates typed fetchers and React Query hooks:

1
orval --config orval.config.ts

The result is type-safe API calls with automatic cache invalidation — your frontend code uses useGetUser(userId) and gets back a properly typed User object.

kiota (Microsoft) — generates API clients for any language from an OpenAPI spec. Designed for the Microsoft Graph API pattern but works with any OpenAPI 3.x spec.

Linting and Validation

spectral (Stoplight) is the standard for OpenAPI linting. It supports custom rulesets for enforcing your team’s API style guide:

1
2
npm install -g @stoplight/spectral-cli
spectral lint openapi.yaml

Create a .spectral.yaml to extend the built-in ruleset:

1
2
3
4
5
6
extends: ["spectral:oas"]
rules:
  operation-operationId: error        # Require operationId on all operations
  operation-success-response: error   # Require at least one 2xx response
  info-contact: warn                  # Warn if contact info is missing
  no-$ref-siblings: error             # Siblings of $ref are ignored

vacuum is a fast Rust-based OpenAPI linter that produces spectral-compatible results but runs significantly faster — useful in CI where you’re linting on every push.

Redocly CLI bundles, lints, and previews your spec:

1
2
3
npx @redocly/cli lint openapi.yaml
npx @redocly/cli preview-docs openapi.yaml  # Live preview in browser
npx @redocly/cli bundle openapi.yaml -o bundled.yaml  # Resolve all $refs into one file

Mock Servers

Prism (Stoplight) is the go-to tool for spinning up a mock server from a spec file:

1
2
npm install -g @stoplight/prism-cli
prism mock openapi.yaml

Prism reads your spec, and instantly serves mock responses based on the example values you’ve defined in your schemas. It also validates incoming requests against the spec — if a frontend sends a request that violates the contract, Prism rejects it with a clear error message.

WireMock is a more heavyweight option (Java-based) but supports persistent stubs, stateful mocking, and scenario-based testing. Good for integration test environments.


Contract-First Workflow in Practice

Here’s the full workflow from blank file to deployed API:

Step 1: Design the API in YAML Use Stoplight Studio for a GUI experience or editor.swagger.io for quick drafts. Start with the info and servers blocks, then define your schemas in components/schemas before writing any paths — it’s easier to design your data model first.

Step 2: Lint with Spectral

1
spectral lint openapi.yaml

Catch missing operationId values, undocumented responses, inconsistent naming, and spec errors before they propagate downstream.

Step 3: Start the Mock Server

1
prism mock openapi.yaml

Share the mock server URL with the frontend team. They can start building immediately. Prism validates requests against the spec, so the frontend learns about contract violations early.

Step 4: Generate Server Stubs Use openapi-generator or oapi-codegen to generate server interfaces. Implement the generated interfaces — the types are already correct.

Step 5: Add OpenAPI Validation Middleware Add request/response validation middleware to your server that validates incoming requests against the spec at runtime. Most frameworks have libraries for this:

  • Node.js/Express: express-openapi-validator
  • Python/FastAPI: validation is built-in
  • Go: kin-openapi or libopenapi
  • Spring Boot: openapi-request-validator

This ensures that the live API can never diverge from the spec. If the spec says a field is required, your server will reject requests missing that field automatically.

Step 6: Generate Client SDKs Once the implementation is live, publish the generated client packages. Frontend consumes useListUsers() hooks with correct TypeScript types. Breaking changes in the spec produce TypeScript compiler errors in the client — before anything is deployed.

Step 7: CI Checks Add these checks to your CI pipeline:

1
2
3
4
5
6
7
8
9
# .github/workflows/api.yml
- name: Lint OpenAPI spec
  run: spectral lint openapi.yaml

- name: Check for breaking changes
  run: |
    oasdiff breaking \
      https://api.example.com/openapi.yaml \
      openapi.yaml

Breaking change detection with oasdiff:

1
2
3
4
5
6
# Compare the live spec against the proposed spec
oasdiff breaking old.yaml new.yaml

# Example output:
# GET /users deleted the response property 'email' from '200' response
# POST /users deleted the required request property 'displayName'

Fail the CI build if breaking changes are introduced without a version bump. This creates a forcing function: breaking changes require a deliberate decision and a major version bump, not an accidental field removal.


Generating OpenAPI from Code (Code-First with Docs)

When you have an existing API or you’re in rapid prototyping mode, code-first can work well — especially with frameworks that generate high-quality specs from type annotations.

Python / FastAPI

FastAPI generates OpenAPI 3.1 automatically from Pydantic models and type hints:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from fastapi import FastAPI
from pydantic import BaseModel, EmailStr
from typing import Optional
import uuid

app = FastAPI(title="LunarOps API", version="1.0.0")

class User(BaseModel):
    id: uuid.UUID
    email: EmailStr
    display_name: Optional[str] = None

class CreateUserRequest(BaseModel):
    email: EmailStr
    password: str
    display_name: Optional[str] = None

@app.get("/users/{user_id}", response_model=User, tags=["users"])
async def get_user(user_id: uuid.UUID):
    """Get a user by ID."""
    # ... implementation
    pass

@app.post("/users", response_model=User, status_code=201, tags=["users"])
async def create_user(body: CreateUserRequest):
    """Create a new user account."""
    pass

Access the generated spec at /openapi.json, Swagger UI at /docs, and Redoc at /redoc. The spec quality is excellent — field types, validation constraints from Pydantic, and examples are all captured automatically.

Go with swaggo/swag

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// @Summary Get user by ID
// @Description Returns a single user by their UUID
// @Tags users
// @Produce json
// @Param userId path string true "User ID" format(uuid)
// @Success 200 {object} User
// @Failure 404 {object} Error
// @Router /users/{userId} [get]
func GetUser(c *gin.Context) {
    // ... implementation
}

Run swag init to generate docs/swagger.yaml from the annotations.

Node.js / Express with swagger-jsdoc

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
 * @openapi
 * /users/{userId}:
 *   get:
 *     operationId: getUser
 *     summary: Get a user by ID
 *     tags: [users]
 *     parameters:
 *       - name: userId
 *         in: path
 *         required: true
 *         schema:
 *           type: string
 *           format: uuid
 *     responses:
 *       200:
 *         description: User found
 *         content:
 *           application/json:
 *             schema:
 *               $ref: '#/components/schemas/User'
 */
app.get('/users/:userId', async (req, res) => {
  // ... implementation
});

The Code-First Caveat

Generated specs from annotation-based tools often include noise: implementation-specific field names, generated schema names like InlineObject3, missing examples, and inconsistent descriptions. Before treating a generated spec as a published contract, clean it up:

  • Export the generated spec
  • Rename schemas to meaningful names
  • Add examples everywhere
  • Normalize error responses
  • Remove internal fields that shouldn’t be exposed

Use x- extension fields for tooling-specific metadata that you don’t want to lose when regenerating:

1
2
3
x-internal: true          # Mark endpoints as internal, hide from public docs
x-stability: experimental # Communicate stability contract
x-codegen-request-body-name: body  # Hint to code generators

Complete Reference: Users and Posts API

Here’s a complete, realistic OpenAPI 3.1 document you can use as a starting template:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
openapi: "3.1.0"

info:
  title: LunarOps Blog API
  version: "1.0.0"
  description: |
    REST API for managing users and blog posts.

    ## Authentication
    Use `POST /auth/token` to obtain a JWT token, then include it as:
    `Authorization: Bearer <token>`

    Public endpoints (marked with a lock-open icon) do not require authentication.
  contact:
    name: API Support
    email: api@lunarops.example.com

servers:
  - url: https://api.lunarops.example.com/v1
    description: Production
  - url: http://localhost:8080/v1
    description: Local development

tags:
  - name: auth
    description: Authentication
  - name: users
    description: User management
  - name: posts
    description: Blog posts

security:
  - BearerAuth: []

paths:
  /auth/token:
    post:
      operationId: createToken
      summary: Obtain an access token
      tags: [auth]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TokenRequest"
      responses:
        "200":
          description: Token issued
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TokenResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /users:
    get:
      operationId: listUsers
      summary: List all users
      tags: [users]
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/LimitParam"
      responses:
        "200":
          description: Paginated list of users
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UserList"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      operationId: createUser
      summary: Create a new user
      tags: [users]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateUserRequest"
      responses:
        "201":
          description: User created
          headers:
            Location:
              schema:
                type: string
                format: uri
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
        "400":
          $ref: "#/components/responses/BadRequest"
        "409":
          $ref: "#/components/responses/Conflict"

  /users/{userId}:
    parameters:
      - $ref: "#/components/parameters/UserIdParam"
    get:
      operationId: getUser
      summary: Get a user by ID
      tags: [users]
      responses:
        "200":
          description: User found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      operationId: updateUser
      summary: Update a user
      tags: [users]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateUserRequest"
      responses:
        "200":
          description: User updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/User"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      operationId: deleteUser
      summary: Delete a user
      tags: [users]
      responses:
        "204":
          description: User deleted
        "404":
          $ref: "#/components/responses/NotFound"

  /posts:
    get:
      operationId: listPosts
      summary: List published posts
      tags: [posts]
      security: []
      parameters:
        - $ref: "#/components/parameters/PageParam"
        - $ref: "#/components/parameters/LimitParam"
        - name: authorId
          in: query
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Paginated list of posts
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PostList"
    post:
      operationId: createPost
      summary: Create a new post
      tags: [posts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreatePostRequest"
      responses:
        "201":
          description: Post created
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Post"
        "400":
          $ref: "#/components/responses/BadRequest"

  /posts/{postId}:
    parameters:
      - name: postId
        in: path
        required: true
        schema:
          type: string
          format: uuid
    get:
      operationId: getPost
      summary: Get a post by ID
      tags: [posts]
      security: []
      responses:
        "200":
          description: Post found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Post"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      operationId: updatePost
      summary: Update a post
      tags: [posts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdatePostRequest"
      responses:
        "200":
          description: Post updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Post"
        "400":
          $ref: "#/components/responses/BadRequest"
        "404":
          $ref: "#/components/responses/NotFound"
    delete:
      operationId: deletePost
      summary: Delete a post
      tags: [posts]
      responses:
        "204":
          description: Post deleted
        "404":
          $ref: "#/components/responses/NotFound"

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

  parameters:
    PageParam:
      name: page
      in: query
      schema:
        type: integer
        minimum: 1
        default: 1
    LimitParam:
      name: limit
      in: query
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20
    UserIdParam:
      name: userId
      in: path
      required: true
      schema:
        type: string
        format: uuid

  responses:
    Unauthorized:
      description: Authentication required
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            code: UNAUTHORIZED
            message: A valid Bearer token is required
    Forbidden:
      description: Insufficient permissions
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    Conflict:
      description: Resource conflict
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
    BadRequest:
      description: Invalid request
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ValidationError"

  schemas:
    # --- Auth ---
    TokenRequest:
      type: object
      required: [email, password]
      properties:
        email:
          type: string
          format: email
        password:
          type: string
          writeOnly: true

    TokenResponse:
      type: object
      required: [accessToken, expiresIn]
      properties:
        accessToken:
          type: string
          description: JWT access token
        expiresIn:
          type: integer
          description: Token lifetime in seconds
          example: 3600

    # --- Users ---
    User:
      type: object
      required: [id, email, role, createdAt]
      properties:
        id:
          type: string
          format: uuid
          readOnly: true
        email:
          type: string
          format: email
        displayName:
          type: ["string", "null"]
          maxLength: 100
        role:
          type: string
          enum: [admin, editor, viewer]
          default: viewer
        createdAt:
          type: string
          format: date-time
          readOnly: true

    CreateUserRequest:
      type: object
      required: [email, password]
      properties:
        email:
          type: string
          format: email
        password:
          type: string
          minLength: 8
          writeOnly: true
        displayName:
          type: string
          maxLength: 100

    UpdateUserRequest:
      type: object
      minProperties: 1
      properties:
        displayName:
          type: string
          maxLength: 100
        role:
          type: string
          enum: [admin, editor, viewer]

    UserList:
      type: object
      required: [data, pagination]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/User"
        pagination:
          $ref: "#/components/schemas/Pagination"

    # --- Posts ---
    Post:
      type: object
      required: [id, title, status, authorId, createdAt]
      properties:
        id:
          type: string
          format: uuid
          readOnly: true
        title:
          type: string
          maxLength: 200
        slug:
          type: string
          readOnly: true
          pattern: "^[a-z0-9-]+$"
        body:
          type: string
          description: Markdown content
        status:
          type: string
          enum: [draft, published, archived]
          default: draft
        authorId:
          type: string
          format: uuid
          readOnly: true
        publishedAt:
          type: ["string", "null"]
          format: date-time
          readOnly: true
        createdAt:
          type: string
          format: date-time
          readOnly: true
        updatedAt:
          type: string
          format: date-time
          readOnly: true

    CreatePostRequest:
      type: object
      required: [title, body]
      properties:
        title:
          type: string
          maxLength: 200
        body:
          type: string
        status:
          type: string
          enum: [draft, published]
          default: draft

    UpdatePostRequest:
      type: object
      minProperties: 1
      properties:
        title:
          type: string
          maxLength: 200
        body:
          type: string
        status:
          type: string
          enum: [draft, published, archived]

    PostList:
      type: object
      required: [data, pagination]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Post"
        pagination:
          $ref: "#/components/schemas/Pagination"

    # --- Common ---
    Pagination:
      type: object
      required: [total, page, limit, hasNext]
      properties:
        total:
          type: integer
        page:
          type: integer
        limit:
          type: integer
        hasNext:
          type: boolean

    Error:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
        message:
          type: string
        requestId:
          type: string
          format: uuid

    ValidationError:
      allOf:
        - $ref: "#/components/schemas/Error"
        - type: object
          properties:
            fields:
              type: object
              additionalProperties:
                type: array
                items:
                  type: string

Practical Tips Before You Ship

Version your API in the URL, not the spec. The version field in info tracks your spec version. The URL path (/v1) tracks your API’s major version. These serve different purposes — don’t conflate them.

Write examples for everything. Swagger UI and Scalar render examples inline. Prism uses your examples for mock responses. The more examples you provide, the more useful your tooling becomes.

Keep the spec in the same repo as the code. Treat openapi.yaml like a first-class source file. It gets reviewed in PRs, linted in CI, and versioned with git. If it lives in a separate wiki or Confluence page, it will drift.

Use Redocly CLI’s bundler before publishing. If you split your spec across multiple files with external $ref paths (common on large APIs), bundle them into a single file before publishing to Swagger Hub or serving from your API:

1
npx @redocly/cli bundle openapi.yaml -o dist/openapi.yaml

Don’t publish writeOnly fields. When you generate documentation, confirm that writeOnly: true fields like password don’t appear in response schema examples. Most renderers handle this correctly, but verify.

Define your error contract first. Before you write a single endpoint, define your Error and ValidationError schemas and your standard reusable responses. Consistent error shapes are one of the highest-value things you can give API consumers.


Summary

OpenAPI is the lingua franca of REST API design. The contract-first workflow it enables — design the spec, review it, mock it, generate clients from it, validate against it in production — addresses every major failure mode of the old code-first approach: documentation drift, broken contracts, blocked frontend teams, and expensive late-stage API changes.

The tooling ecosystem around OpenAPI is mature and extensive. Spectral for linting, Prism for mocking, openapi-generator and orval for client generation, oasdiff for breaking change detection — each tool in the chain adds leverage. The spec becomes the hub from which documentation, SDKs, mock servers, and validation middleware all radiate.

Start with the schemas. Get the data model right before you think about endpoints. Then define your error responses. Then write your paths. Lint before you commit. Everything downstream gets better when the spec is clean.

If you’re starting a new API today, write the YAML first.

Comments