OpenAPI in Practice: Docs, Clients, and Validation

Table of Contents
Introduction
OpenAPI promises a single specification file that generates accurate documentation, type-safe client SDKs, and request validation middleware. Write once, generate everywhere. The reality is more nuanced — specs drift from implementations, generated code can be awkward, validation catches schema errors but misses business logic. The difference between OpenAPI delivering value and becoming another artifact to maintain comes down to one question: is the spec the source of truth, or is it describing code that already exists?
I’ve seen teams maintain REST APIs with hand-written documentation in Confluence, manually coded client SDKs in three languages, and validation logic duplicated across services. Documentation is perpetually out of date. SDK bugs emerge when the API changes. Validation differs between the gateway and the backend. When they adopt spec-first OpenAPI — where the spec drives the implementation rather than documenting it after the fact — something changes. Documentation generates automatically. SDKs regenerate on spec change. Validation middleware uses the same spec the docs reference. The three artifacts are guaranteed consistent because they come from the same source. As a side effect, time spent on API maintenance drops significantly — but the real win is confidence that your docs, clients, and validation all agree on what the API actually does.
The biggest OpenAPI mistake: generating a spec from existing code and calling it “documentation.” That’s archaeology, not specification. The spec should drive the code, not describe it after the fact.
OpenAPI Fundamentals
If you’ve worked with OpenAPI specs before, you know they can get unwieldy fast. A moderately complex API generates a YAML file that scrolls forever, and finding anything requires a mental map of where things live. Understanding the structure matters because it determines whether your spec stays maintainable or becomes a 3,000-line file nobody wants to touch.
The Building Blocks
An OpenAPI spec has four top-level sections that matter:
- info for metadata
- servers for environment URLs
- paths for your actual endpoints
- components for reusable definitions
The first two are straightforward — title, version, contact info, and a list of base URLs for production, staging, and local development.
The paths section is where you define what your API does. Each path (like /orders or /orders/{orderId}) contains operations — GET, POST, PUT, DELETE — with their parameters, request bodies, and responses. Here’s where things get interesting: instead of defining the same Order schema inline on every endpoint that returns one, you define it once in components/schemas and reference it with $ref: "#/components/schemas/Order".
This applies to everything. Parameters that appear on multiple endpoints? Define them in components/parameters. Error responses that every endpoint might return? components/responses. Security schemes? components/securitySchemes. One change propagates everywhere.
# Reusable components keep your spec DRY
components:
parameters:
PageParam:
name: page
in: query
schema:
type: integer
minimum: 1
default: 1
responses:
NotFound:
description: "Resource not found"
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
paths:
/orders:
get: # HTTP verb being specified
operationId: listOrders # Becomes client.listOrders()
parameters:
- $ref: "#/components/parameters/PageParam"
responses:
"404":
$ref: "#/components/responses/NotFound"Notice the operationId field in that example. It seems like metadata, but it becomes the method name in generated SDKs. Name your operation listOrders and you get client.listOrders(). Name it get_api_v1_orders_list and your SDK consumers will hate you. Spend the thirty seconds to pick good names — future you will appreciate it.
Schema Design That Doesn’t Fight You
Here’s a mistake I see frequently: using the same schema for create, update, and read operations. It seems efficient at first. You have an Order schema, you use it everywhere, done. Then reality hits.
When creating an order, clients don’t send id, createdAt, or status—the server assigns those. When updating, they can only change certain fields. When reading, they get everything back. One schema can’t cleanly express all three cases.
The workaround is readOnly and writeOnly annotations scattered throughout the schema, plus nullable fields that aren’t really nullable in certain contexts. Generated SDKs end up with properties that sometimes exist and sometimes don’t, depending on the operation. It’s a mess.
The cleaner approach: separate schemas for each operation.
# Separate schemas for create vs response
components:
schemas:
CreateOrderRequest:
type: object
required: [customerId, items]
properties:
customerId:
type: string
format: uuid
items:
type: array
minItems: 1
notes:
type: string
maxLength: 500
# No id, status, timestamps - server assigns these
Order:
type: object
required: [id, customerId, items, status, createdAt]
properties:
id:
type: string
format: uuid
customerId:
type: string
format: uuid
status:
type: string
enum: [pending, confirmed, shipped, delivered, cancelled]
createdAt:
type: string
format: date-time
UpdateOrderRequest:
type: object
properties:
notes:
type: string
maxLength: 500
status:
type: string
enum: [confirmed, cancelled]
# Only fields the client can modify - no id, createdAt, customerIdYes, there’s duplication. customerId appears in both schemas. That’s fine — it’s explicit about what each operation accepts and returns. Generated code becomes cleaner, validation becomes tighter, and nobody has to reason about which fields apply in which context.
Composition Patterns
When you do have legitimate shared structure, OpenAPI provides composition through allOf. If every entity in your system has id, createdAt, and updatedAt, define a BaseEntity and compose it:
components:
schemas:
BaseEntity:
type: object
properties:
id:
type: string
format: uuid
createdAt:
type: string
format: date-time
Customer:
allOf:
- $ref: "#/components/schemas/BaseEntity"
- type: object
required: [email, name]
properties:
email:
type: string
format: email
name:
type: stringFor polymorphic types — where a field can be one of several shapes — use oneOf with a discriminator. Payment methods are the classic example: credit cards have last4 and expiryMonth, bank transfers have routingNumber and accountNumber, PayPal has email. The discriminator tells parsers which schema applies based on a type field.
| # | Pattern | When to Use |
|---|---|---|
| 1 | Separate CRUD schemas | Create/update/read operations have different field requirements |
| 2 | allOf composition | Multiple entities share identical base fields (audit timestamps, IDs) |
| 3 | oneOf polymorphism | A field can be one of several distinct types with different structures |
The oneOf pattern in practice:
components:
schemas:
PaymentMethod:
oneOf:
- $ref: "#/components/schemas/CreditCard"
- $ref: "#/components/schemas/BankTransfer"
discriminator:
propertyName: type
CreditCard:
type: object
required: [type, last4, expiryMonth]
properties:
type:
type: string
enum: [credit_card]
last4:
type: string
expiryMonth:
type: integer
BankTransfer:
type: object
required: [type, routingNumber]
properties:
type:
type: string
enum: [bank_transfer]
routingNumber:
type: stringA good rule of thumb: if you’re adding readOnly, writeOnly, or complex nullable logic to make one schema work everywhere, you probably need separate schemas instead.
Spec-First vs Code-First
The spec-first vs code-first debate comes down to one question: what’s your source of truth? In spec-first, the OpenAPI document is the contract — you design the API there, then generate server stubs and client SDKs from it. In code-first, your implementation is the contract — you add annotations to your code, then generate the spec from those annotations.
I’ve worked with both, and my bias is toward spec-first for any API with multiple consumers. Here’s why.
The Case for Spec-First
When the spec is the source of truth, several good things happen. API design becomes a distinct activity that happens before you start coding. You can share the spec with frontend teams, partner integrators, or product managers and get feedback before you’ve written a line of implementation code. Breaking changes become visible in the spec diff, not hidden in a code change that looks innocuous.
The workflow looks like this: design the API in your spec editor, review with stakeholders, generate server stubs (which give you the interface to implement), fill in the business logic, then generate client SDKs and documentation from the same spec. Everything stays in sync because everything comes from the same source.
You’ll want a decent editor for spec-first work. Here are the main options:
| Editor | Best For |
|---|---|
| Swagger Editor Next | Browser-based editing with real-time validation; supports OpenAPI 3.1 and AsyncAPI |
| Stoplight Studio | Visual, GUI-driven design without memorizing YAML syntax |
| 42Crunch OpenAPI Editor (VS Code) | IDE-integrated editing with IntelliSense, visual navigation, and "Try it" testing |
| IntelliJ OpenAPI Plugin | Teams already in JetBrains IDEs; can generate specs from existing controllers |
| Postman | Teams already using Postman for testing who want spec editing in the same workspace |
The downsides are real too. Generated code rarely matches your team’s style preferences perfectly. Every change requires editing the spec first, which adds friction when you’re iterating quickly. And spec-first requires discipline — it’s tempting to make a quick fix in code and “update the spec later,” which is how drift starts.
Spec-first works best for public APIs, APIs consumed by multiple teams, and anywhere you need a formal contract that outlives any particular implementation.
The Case for Code-First
Code-first feels more natural to most developers. You build the API in your language and framework of choice, sprinkle in some annotations or decorators, and out comes a spec. The code is always accurate because the code is the specification.
Frameworks like FastAPI (Python), tsoa (TypeScript), and springdoc (Java) make this almost friction-free. You write type hints or interfaces, add decorators for additional metadata, and the spec generates automatically on build.
# FastAPI: code-first with automatic spec generation
from fastapi import FastAPI
from pydantic import BaseModel
from uuid import UUID
from datetime import datetime
app = FastAPI(title="Order API", version="1.0.0")
class Order(BaseModel):
id: UUID
customer_id: UUID
status: str
created_at: datetime
@app.get("/orders/{order_id}", response_model=Order)
async def get_order(order_id: UUID) -> Order:
# Visit /docs for auto-generated Swagger UI, /openapi.json for the spec
return await fetch_order(order_id)The tradeoffs: spec quality depends entirely on how thorough your annotations are. It’s easy to ship an endpoint with minimal documentation because “it works.” API design happens during coding, which means design decisions get made under time pressure. And breaking changes are easier to introduce accidentally because there’s no separate spec review step.
Code-first works well for internal APIs, rapid prototypes, and situations where you’re the primary consumer of your own API.
Keeping Spec and Code in Sync
Whichever approach you choose, drift is the enemy. The spec says one thing, the implementation does another, and now your documentation is lying to consumers.
The solution is CI validation. On every pull request, automatically lint the spec, check for breaking changes against the main branch, and run contract tests that verify the implementation matches the spec.
#!/bin/bash
# The key CI steps for catching drift
npx @stoplight/spectral-cli lint openapi.yaml # Spec quality
npx openapi-diff main:openapi.yaml openapi.yaml # Breaking changes
npx dredd openapi.yaml http://localhost:3000 # Contract testsThe key tools here: Spectral for linting (catches spec-level issues like missing descriptions or inconsistent naming), openapi-diff for detecting breaking changes (removed endpoints, required fields added, type changes), and Dredd or Schemathesis for contract testing (actually hits your running server and validates responses against the spec).
If you don’t validate in CI, spec and implementation will drift. It’s not a matter of discipline — it’s that small, well-intentioned changes accumulate into documentation that actively misleads consumers.
Documentation Generation
Once you have an OpenAPI spec, generating documentation is almost trivially easy. Point a tool at your YAML file, run a command, and out comes a browsable API reference. The hard part isn’t generation — it’s making documentation that developers actually want to read.
Choosing a Documentation Tool
The three main options cover different use cases:
- RedocGenerates static HTML from your spec. You get a three-panel layout (navigation, content, code samples), search, and nested schema expansion. It's a single HTML file you can host anywhere — GitHub Pages, S3, your CDN. No backend required. This is my default choice for most projects because it's zero-maintenance once deployed.
- Swagger UIProvides interactive documentation where developers can make live requests against your API. The "Try it out" button is genuinely useful for exploration and debugging. The tradeoff: you need to think about authentication, CORS, and whether you want people hitting your production API from the docs.
- Stoplight ElementsA React component library for embedding documentation in an existing portal or documentation site. If you're building a developer portal with tutorials, guides, and API reference together, Elements lets you integrate the API docs natively rather than linking out to a separate site.
#!/bin/bash
# Redoc: generate static HTML
npx @redocly/cli build-docs openapi.yaml -o docs/api-reference.html
# Swagger UI: serve with Docker
docker run -p 8080:8080 \
-e SWAGGER_JSON=/spec/openapi.yaml \
-v ./openapi.yaml:/spec/openapi.yaml \
swaggerapi/swagger-uiMaking Documentation Worth Reading
Generated documentation is only as good as your spec. A minimal spec produces minimal docs — endpoint names, parameter lists, response schemas. Functional, but not helpful.
The difference between documentation that gets ignored and documentation that developers bookmark comes down to three things: descriptions that explain why not just what, examples that show realistic data, and error responses that help with debugging.
Descriptions support Markdown, so use it. Don’t just write “Gets an order”—explain what permissions are required, what the caching behavior is, what the rate limits are. This context belongs in the spec itself, not in a separate wiki that gets out of sync.
# Descriptions that actually help developers
paths:
/orders/{orderId}:
get:
summary: Get order by ID
description: |
Retrieves a single order by its unique identifier.
**Authorization**: Requires `orders:read` scope.
**Rate limiting**: 100 requests/minute per API key.
**Caching**: Responses cached for 60 seconds.
Use `Cache-Control: no-cache` to bypass.Examples matter even more than descriptions. A schema tells you the shape of data; an example shows you what it actually looks like. OpenAPI supports multiple named examples per response, so you can show different scenarios — a pending order vs. a shipped order, a successful response vs. an error.
# Multiple examples show different scenarios
responses:
"200":
description: Order retrieved successfully
content:
application/json:
examples:
pending_order:
summary: Pending order
value:
id: "550e8400-e29b-41d4-a716-446655440000"
status: pending
items: [{productId: "prod-001", quantity: 2}]
shipped_order:
summary: Shipped order
value:
id: "660e8400-e29b-41d4-a716-446655440001"
status: shipped
trackingNumber: "1Z999AA10123456784"These individual techniques — rich descriptions, multiple examples, error scenarios — compound when applied consistently across your spec. The table below summarizes which documentation elements to prioritize and what each should contain, so you can audit an existing spec or build a new one with the right coverage from the start.
| # | Documentation Element | What to Include |
|---|---|---|
| 1 | Operation description | Auth requirements, rate limits, caching behavior, business context |
| 2 | Parameter descriptions | Format constraints, valid ranges, default behavior |
| 3 | Response examples | Multiple scenarios (success variants, common errors) |
| 4 | Schema descriptions | Field meanings, valid values, relationships to other entities |
The time you spend on descriptions and examples pays off in reduced support requests. Developers who can self-serve from good docs don’t need to ask you questions.
Good documentation tells developers what your API does. But they still have to write the code to call it — and that’s where SDK generation comes in.
SDK Generation
Generating client SDKs from OpenAPI specs sounds like magic until you actually try it. The promise: run a command, get type-safe clients in TypeScript, Python, Java, whatever your consumers use. The reality: generated code that’s technically correct but awkward to use, with naming conventions that don’t match your codebase and patterns that feel foreign.
The good news is that SDK generation has matured significantly. With the right generator choice and some post-processing, you can produce SDKs that feel handwritten.
Picking a Generator
The SDK generator landscape breaks down into three categories: comprehensive multi-language tools, language-specific tools with better output, and types-only generators.
- OpenAPI Generator (the open-source fork of Swagger Codegen)Supports 50+ languages and is the default choice for teams that need SDKs in multiple languages from the same spec. The TypeScript output is decent, the Python output is usable, and you get consistent behavior across languages. The tradeoff is that "decent" and "usable" aren't "great"—the generated code tends toward verbosity and doesn't always follow language idioms.
- OrvalFocuses exclusively on TypeScript but produces notably better output. It generates React Query hooks, Zod validation schemas, and MSW mock handlers out of the box. If your consumers are React developers, Orval's output feels like code they'd actually write.
- openapi-typescriptTakes a different approach: it generates only TypeScript types, no runtime code. You get interfaces for your request/response bodies and use your own HTTP client. This is the lightest-weight option and works well when you want type safety without adopting a generated SDK wholesale.
These tools solve different problems, so the right choice depends less on raw feature count and more on who will consume the generated code. A frontend-heavy team may prioritize React Query integration and ergonomic TypeScript output, while a platform team supporting multiple languages may accept less idiomatic clients in exchange for broader coverage. The comparison below makes those tradeoffs explicit.
| Generator | Languages | Best For |
|---|---|---|
| OpenAPI Generator | 50+ (TypeScript, Python, Java, Go, etc.) | Multi-language SDK needs, polyglot organizations |
| Orval | TypeScript only | React apps, teams wanting React Query/Zod integration |
| openapi-typescript | TypeScript types only | Type safety without generated runtime code |
| AutoRest | TypeScript, Python, Java, C#, Go | Azure integrations, Microsoft ecosystem |
Generating Your First SDK
The basic workflow is straightforward. Install the generator, point it at your spec, choose an output target:
#!/bin/bash
# OpenAPI Generator: TypeScript with Axios
npx @openapitools/openapi-generator-cli generate \
-i openapi.yaml \
-g typescript-axios \
-o ./sdk/typescript \
--additional-properties=supportsES6=true,npmName=@company/orders-client
# Orval: TypeScript with React Query
npx orval --input openapi.yaml --output ./src/api/generated.tsThe additional-properties flag is where customization happens. Each generator has dozens of options controlling output structure, naming conventions, and feature flags. The -g typescript-axios in the command above tells OpenAPI Generator to use its TypeScript-with-Axios template (one of many available templates — there’s also typescript-fetch, typescript-node, python, java, etc.). For the TypeScript-Axios template specifically, the useful properties are supportsES6 (modern syntax), withSeparateModelsAndApi (splits models and API classes into separate files), and useSingleRequestParameter (bundles parameters into a single options object).
Making Generated Code Usable
Generated code rarely matches your team’s standards out of the box. Rather than fighting the generator’s templates, embrace post-processing. Generate the code, then transform it.
#!/bin/bash
# Post-process generated SDK
# Generate raw SDK
npx @openapitools/openapi-generator-cli generate \
-i openapi.yaml \
-g typescript-axios \
-o ./sdk/typescript
# Format with your project's prettier config
npx prettier --write ./sdk/typescript/**/*.ts
# Run ESLint with auto-fix
npx eslint --fix ./sdk/typescript/**/*.ts
# Remove generator metadata files
rm -rf ./sdk/typescript/.openapi-generator
rm -f ./sdk/typescript/.openapi-generator-ignoreFor deeper customization, generators support template overrides. You can export the default Mustache templates, modify them, and pass your custom template directory during generation. This is useful for adding custom headers, changing error handling patterns, or injecting wrapper code. But I’d recommend exhausting post-processing options first — template maintenance becomes a burden when the generator updates.
The pattern I’ve found works best: generate into a dedicated directory, post-process aggressively, then wrap the generated code with a thin facade that exposes the interface your consumers actually want. The generated code handles serialization, HTTP mechanics, and type definitions. Your wrapper handles configuration, error normalization, and any convenience methods.
Treat generated SDKs as build artifacts, not source code. Regenerate them in CI, don’t commit them to version control, and wrap them with a stable interface your consumers depend on.
Request Validation
Validation is where OpenAPI specs earn their keep. Instead of writing validation logic by hand — checking types, verifying formats, ensuring required fields exist — you can derive it directly from the spec. The same schema that generates your documentation and SDKs also enforces that incoming requests match the contract.
The catch: OpenAPI validation handles schema correctness, not business correctness. It’ll reject a request where quantity is a string instead of an integer, or where email doesn’t match email format. It won’t reject a request where customerId is a valid UUID that doesn’t exist in your database.
Adding Validation Middleware
In Node.js/Express, express-openapi-validator is the standard choice. You point it at your spec file, and it automatically validates request bodies, query parameters, path parameters, and headers against your schemas. Invalid requests get rejected before they reach your route handlers.
// Express.js with OpenAPI request validation
import express from 'express';
import * as OpenApiValidator from 'express-openapi-validator';
const app = express();
app.use(express.json());
// Add validation middleware - validates against openapi.yaml
app.use(
OpenApiValidator.middleware({
apiSpec: './openapi.yaml',
validateRequests: true,
validateResponses: true, // Optional: also validate your responses
})
);
// Route handlers only see validated requests
app.post('/orders', async (req, res) => {
// req.body already validated against CreateOrderRequest schema
// If validation failed, a 400 was returned before reaching here
const order = await createOrder(req.body);
res.status(201).json(order);
});The validateResponses: true option is worth enabling in development and staging. It catches cases where your implementation returns data that doesn’t match your spec — a useful early warning that your spec and code have drifted apart. You might disable it in production for performance reasons, but keeping it on during development catches a lot of bugs.
For Python, FastAPI handles this automatically if you’re using Pydantic models. Your type hints are your validation schema, and FastAPI generates the OpenAPI spec from them. If you’re using a different Python framework, libraries like openapi-core provide similar middleware.
Other ecosystems have their own solutions:
| Framework | Library | Approach |
|---|---|---|
| Rails | committee | Rack middleware that validates against OpenAPI specs |
| Laravel | spectator | Request/response validation with PHPUnit integration |
| .NET | Microsoft.AspNetCore.OpenApi | Built-in validation in ASP.NET Core 9+; earlier versions use NSwag |
| Go | kin-openapi | Middleware for Echo, Chi, Gin; validates requests and responses |
| Spring Boot | springdoc-openapi | Code-first with automatic spec generation and validation |
Handling Validation Errors
Raw validation errors from middleware tend to be developer-friendly but user-hostile. You’ll get JSON Pointer paths like /body/items/0/quantity and technical messages like must be >= 1. These are fine for debugging but not great for API consumers trying to fix their requests.
Transform validation errors into something more useful:
// Transform validation errors into user-friendly format
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
if (err.status === 400 && err.errors) {
const formatted = err.errors.map((e: any) => ({
field: e.path.replace('/body/', '').replace(/\//g, '.'),
message: humanizeError(e),
}));
return res.status(400).json({
code: 'VALIDATION_ERROR',
message: 'Request validation failed',
errors: formatted,
});
}
next(err);
});
function humanizeError(error: { path: string; message: string; params?: Record<string, unknown> }): string {
const field = error.path.replace('/body/', '').replace(/\/(\d+)\//g, '[$1].').replace(/\//g, '.');
// Convert technical messages to readable ones
if (error.message.includes('must be >= ')) {
return `${field} must be at least ${error.params?.limit ?? 'the minimum'}`;
}
if (error.message.includes('required')) {
return `${field} is required`;
}
return `${field}: ${error.message}`;
}
// /body/items/0/quantity -> "items[0].quantity must be at least 1"What Validation Catches (and Doesn’t)
It’s important to understand the boundaries of schema validation:
| Validation Catches | Validation Misses |
|---|---|
| Wrong types (string instead of integer) | Valid types with invalid business meaning |
| Missing required fields | Fields that are conditionally required |
| Format violations (invalid UUID, email) | Valid formats that don't exist (nonexistent user ID) |
| Out-of-range values (quantity < 1) | Domain-specific constraints (can't order discontinued items) |
| Invalid enum values | Contextually invalid values (can't cancel a shipped order) |
Schema validation is your first line of defense — it rejects obviously malformed requests before they hit your business logic. But you still need business validation after schema validation passes. A request with {"customerId": "valid-uuid", "productId": "valid-uuid"} passes schema validation perfectly, but both IDs might reference entities that don’t exist or aren’t accessible to the requesting user.
Don’t confuse schema validation with authorization or business rules. A valid request body doesn’t mean an authorized request. Always layer business validation and permission checks on top of schema validation.
CI/CD Integration
Everything we’ve discussed — documentation generation, SDK generation, validation — becomes reliable only when it’s automated. If generating docs requires someone to remember to run a command, docs will fall out of date. If SDK generation is a manual step, it’ll be skipped when someone’s in a hurry. CI/CD is what turns OpenAPI from “nice to have” into “always accurate.”
The Core Pipeline
A solid OpenAPI pipeline has four stages: lint the spec, check for breaking changes, run contract tests, and generate artifacts. Here’s what that looks like in GitHub Actions:
name: API Pipeline
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Lint OpenAPI spec
run: npx @stoplight/spectral-cli lint openapi.yaml
breaking-changes:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check for breaking changes
run: |
git show origin/${{ github.base_ref }}:openapi.yaml > base.yaml
npx openapi-diff base.yaml openapi.yaml --fail-on-incompatible
contract-test:
runs-on: ubuntu-latest
needs: [lint]
steps:
- uses: actions/checkout@v4
- run: npm ci && npm start &
- run: sleep 10 # Wait for server to be ready
- run: npx dredd openapi.yaml http://localhost:3000
generate:
runs-on: ubuntu-latest
needs: [lint, contract-test]
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Generate SDK
run: npx @openapitools/openapi-generator-cli generate -i openapi.yaml -g typescript-axios -o ./sdk
- name: Generate docs
run: npx @redocly/cli build-docs openapi.yaml -o ./docs/api.htmlThe key insight: the breaking-changes job only runs on pull requests and compares against the base branch. This gives reviewers visibility into API changes before they merge. The generate job only runs on main after tests pass, ensuring published artifacts always reflect tested code.
Linting with Spectral
Spectral is the standard linter for OpenAPI specs. It ships with sensible defaults (valid spec structure, required fields, consistent naming) and lets you add custom rules for your organization’s standards.
The built-in spectral:oas ruleset catches structural issues — invalid syntax, missing required fields, unreferenced schemas. For most teams, that’s enough to start:
# Start with the built-in ruleset
extends: ["spectral:oas"]Once you have that in CI, add custom rules to enforce your organization’s conventions — naming patterns, required documentation, security definitions:
# Extended config with custom rules
extends: ["spectral:oas"]
rules:
# Require descriptions on all operations
operation-description:
severity: warn
given: "$.paths.*[get,post,put,delete]"
then:
field: description
function: truthy
# Enforce kebab-case paths
path-keys-kebab-case:
severity: error
given: "$.paths"
then:
function: pattern
functionOptions:
match: "^(/[a-z][a-z0-9-]*)+$"Detecting Breaking Changes
Breaking changes are the silent killer of API reliability. Someone adds a required field to a request body, thinking it’s a small change. Three downstream services start failing in production because they don’t send that field.
openapi-diff compares two spec versions and flags incompatible changes:
- Removed endpoints
- Removed or renamed fields
- New required fields in request bodies
- Changed field types
- Narrowed enum values
Run it in PR checks with --fail-on-incompatible to block merges that would break consumers. When breaking changes are intentional (major version bumps), you can override the check — but at least it’s a conscious decision, not an accident.
Contract Testing
Contract tests verify your implementation actually matches your spec. Dredd and Schemathesis are the main tools:
- DreddReads your spec, generates requests for each endpoint, hits your running server, and validates responses against the schema. It's deterministic — same spec produces same tests.
- SchemathesisTakes a property-based testing approach. It generates random valid inputs based on your schemas and looks for edge cases that break your implementation. It's better at finding bugs but less predictable.
I typically use Dredd in CI for consistent, fast feedback and Schemathesis periodically for deeper testing.
The combination of Spectral (spec quality), openapi-diff (breaking change detection), and Dredd (contract verification) catches most API issues before they reach production. If you only automate one thing, automate this.
Conclusion
The difference between OpenAPI that delivers value and OpenAPI that becomes shelfware comes down to one thing: is the spec the source of truth, or is it an artifact you generate and forget?
When the spec drives everything — documentation, SDKs, validation, contract tests — you get consistency for free. Change the spec, regenerate artifacts, deploy. Documentation can’t drift because it’s generated. SDKs can’t disagree with the server because they come from the same source. Validation can’t miss edge cases the spec covers because it reads the spec directly.
The setup cost is real. You’ll spend time choosing generators, configuring linters, wiring up CI pipelines. But that cost is paid once. The alternative — manually maintaining documentation, hand-coding SDKs, duplicating validation logic — is paid continuously, and it compounds as your API grows and your consumer base expands.
If you take one thing from this article: treat your OpenAPI spec as infrastructure, not documentation. The spec isn’t describing your API — it is your API contract, and everything else flows from that.
Sharethisarticle
Enjoyed the read? Share it with your network.




