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.
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"]
|
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]
# ...
|
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