Building Internal APIs with OpenAPI and Swagger: Design-First Approach
Design and build internal APIs using OpenAPI 3.1 specification. Code generation, validation middleware, interactive documentation, and contract-first...
Design-First API Development
Most teams build APIs code-first: write the handler, then generate docs. The design-first approach reverses this: write the API specification first, then generate code, documentation, and client SDKs from it.
<div style="margin:2.5rem auto;max-width:600px;width:100%;text-align:center;"><svg viewBox="0 0 600 180" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"><rect width="600" height="180" rx="12" fill="#1a1a2e"/><rect x="20" y="20" width="70" height="35" rx="6" fill="#3b82f6" opacity="0.8"/><text x="55" y="42" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">Web</text><rect x="20" y="65" width="70" height="35" rx="6" fill="#3b82f6" opacity="0.8"/><text x="55" y="87" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">Mobile</text><rect x="20" y="110" width="70" height="35" rx="6" fill="#3b82f6" opacity="0.8"/><text x="55" y="132" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">IoT</text><rect x="150" y="20" width="120" height="130" rx="10" fill="#6366f1" opacity="0.9"/><text x="210" y="50" text-anchor="middle" fill="#ffffff" font-size="12" font-family="system-ui" font-weight="bold">Gateway</text><line x1="165" y1="60" x2="255" y2="60" stroke="#ffffff" stroke-width="0.5" opacity="0.3"/><text x="210" y="80" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">Rate Limit</text><text x="210" y="95" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">Auth</text><text x="210" y="110" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">Load Balance</text><text x="210" y="125" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">Transform</text><text x="210" y="140" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">Cache</text><rect x="340" y="15" width="95" height="35" rx="6" fill="#a855f7" opacity="0.8"/><text x="387" y="37" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">Service A</text><rect x="340" y="60" width="95" height="35" rx="6" fill="#2dd4bf" opacity="0.8"/><text x="387" y="82" text-anchor="middle" fill="#1a1a2e" font-size="10" font-family="system-ui">Service B</text><rect x="340" y="105" width="95" height="35" rx="6" fill="#f59e0b" opacity="0.8"/><text x="387" y="127" text-anchor="middle" fill="#1a1a2e" font-size="10" font-family="system-ui">Service C</text><rect x="490" y="55" width="80" height="45" rx="6" fill="none" stroke="#e2e8f0" stroke-width="1"/><text x="530" y="82" text-anchor="middle" fill="#e2e8f0" font-size="10" font-family="system-ui">DB / Cache</text><defs><marker id="arrow7" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6" fill="#e2e8f0"/></marker></defs><line x1="92" y1="37" x2="148" y2="55" stroke="#e2e8f0" stroke-width="1" marker-end="url(#arrow7)"/><line x1="92" y1="82" x2="148" y2="85" stroke="#e2e8f0" stroke-width="1" marker-end="url(#arrow7)"/><line x1="92" y1="127" x2="148" y2="115" stroke="#e2e8f0" stroke-width="1" marker-end="url(#arrow7)"/><line x1="272" y1="55" x2="338" y2="32" stroke="#e2e8f0" stroke-width="1" marker-end="url(#arrow7)"/><line x1="272" y1="85" x2="338" y2="77" stroke="#e2e8f0" stroke-width="1" marker-end="url(#arrow7)"/><line x1="272" y1="115" x2="338" y2="122" stroke="#e2e8f0" stroke-width="1" marker-end="url(#arrow7)"/><line x1="437" y1="77" x2="488" y2="77" stroke="#e2e8f0" stroke-width="1" marker-end="url(#arrow7)"/></svg><p style="margin-top:0.75rem;font-size:0.85rem;color:#94a3b8;font-style:italic;line-height:1.4;">API gateway pattern: a single entry point handles auth, rate limiting, and routing to backend services.</p></div>
Design-first benefits:
OpenAPI 3.1 Specification
# openapi.yaml
openapi: 3.1.0
info:
title: TechSaaS User API
version: 1.0.0
description: Internal user management API
contact:
name: TechSaaS API Team
email: [email protected]
servers:
- url: https://api.techsaas.cloud/v1
description: Production
- url: http://localhost:3000/v1
description: Development
tags:
- name: Users
description: User management operations
- name: Teams
description: Team management
paths:
/users:
get:
operationId: listUsers
tags: [Users]
summary: List all users
description: Returns a paginated list of users
parameters:
- name: page
in: query
schema:
type: integer
minimum: 1
default: 1
- name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 100
default: 20
- name: search
in: query
description: Search by name or email
schema:
type
- name: role
in: query
schema:
type
enum: [admin, member, viewer]
responses:
'200':
description: Successful response
content:
application/json:
schema:
type: object
required: [data, pagination]
properties:
data:
type: array
items:
$ref: '#/components/schemas/User'
pagination:
$ref: '#/components/schemas/Pagination'
'401':
$ref: '#/components/responses/Unauthorized'
post:
operationId: createUser
tags: [Users]
summary: Create a new user
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateUserRequest'
example:
name: Jane Doe
email: [email protected]
role: member
responses:
'201':
description: User created
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'400':
$ref: '#/components/responses/ValidationError'
'409':
description: Email already exists
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
/users/{userId}:
get:
operationId: getUser
tags: [Users]
summary: Get user by ID
parameters:
- name: userId
in: path
required: true
schema:
type
format: uuid
responses:
'200':
description: Successful response
content:
application/json:
schema:
$ref: '#/components/schemas/User'
'404':
$ref: '#/components/responses/NotFound'
patch:
operationId: updateUser
tags: [Users]
summary: Update user
parameters:
- name: userId
in: path
required: true
schema:
type
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/UpdateUserRequest'
responses:
'200':
description: User updated
content:
application/json:
schema:
$ref: '#/components/schemas/User'
delete:
operationId: deleteUser
tags: [Users]
summary: Delete user
parameters:
- name: userId
in: path
required: true
schema:
type
format: uuid
responses:
'204':
description: User deleted
'404':
$ref: '#/components/responses/NotFound'
components:
schemas:
User:
type: object
required: [id, name, email, role, createdAt]
properties:
id:
type
format: uuid
name:
type
minLength: 1
maxLength: 255
email:
type
format: email
role:
type
enum: [admin, member, viewer]
avatar:
type
format: uri
nullable: true
createdAt:
type
format: date-time
updatedAt:
type
format: date-time
CreateUserRequest:
type: object
required: [name, email]
properties:
name:
type
minLength: 1
maxLength: 255
email:
type
format: email
role:
type
enum: [admin, member, viewer]
default: member
UpdateUserRequest:
type: object
minProperties: 1
properties:
name:
type
minLength: 1
maxLength: 255
email:
type
format: email
role:
type
enum: [admin, member, viewer]
Pagination:
type: object
properties:
page:
type: integer
limit:
type: integer
total:
type: integer
totalPages:
type: integer
Error:
type: object
required: [code, message]
properties:
code:
type
message:
type
details:
type: object
responses:
Unauthorized:
description: Authentication required
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
example:
code: UNAUTHORIZED
message: Authentication required
NotFound:
description: Resource not found
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
ValidationError:
description: Validation error
content:
application/json:
schema:
$ref: '#/components/schemas/Error'
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
security:
- bearerAuth: []Code Generation
Generate TypeScript Types
# Using openapi-typescript
npx openapi-typescript openapi.yaml -o src/api-types.tsThis generates fully typed interfaces:
// Generated: src/api-types.ts
export interface paths {
"/users": {
get: operations["listUsers"];
post: operations["createUser"];
};
"/users/{userId}": {
get: operations["getUser"];
patch: operations["updateUser"];
delete: operations["deleteUser"];
};
}
export interface components {
schemas: {
User: {
id
name
email
role: "admin" | "member" | "viewer";
avatar? | null;
createdAt
updatedAt?
};
// ... more types
};
}Generate Type-Safe Client SDK
# Using openapi-fetch (type-safe, minimal)
npm install openapi-fetch// api-client.ts
import createClient from 'openapi-fetch';
import type { paths } from './api-types';
const client = createClient<paths>({
baseUrl: 'https://api.techsaas.cloud/v1',
headers: {
Authorization: 'Bearer ' + getAccessToken(),
},
});
// Fully type-safe API calls
const { data, error } = await client.GET('/users', {
params: {
query: { page: 1, limit: 20, role: 'admin' },
},
});
if (data) {
// data is typed as { data: User[], pagination: Pagination }
data.data.forEach(user => console.log(user.name));
}
// Create user (request body is type-checked)
const { data: newUser } = await client.POST('/users', {
body: {
name: 'Jane Doe',
email: '[email protected]',
role: 'member',
},
});Request Validation Middleware
Automatically validate requests against the OpenAPI spec:
// Express middleware using express-openapi-validator
import * as OpenApiValidator from 'express-openapi-validator';
app.use(
OpenApiValidator.middleware({
apiSpec: './openapi.yaml',
validateRequests: true,
validateResponses: true, // Also validate outgoing responses
})
);
// Route handlers - no manual validation needed
app.get('/v1/users', async (req, res) => {
// req.query is already validated:
// - page is integer >= 1
// - limit is integer 1-100
// - role is one of admin/member/viewer
const users = await userService.list(req.query);
res.json(users);
});
app.post('/v1/users', async (req, res) => {
// req.body is already validated:
// - name is required, 1-255 chars
// - email is required, valid format
// - role is optional, valid enum value
const user = await userService.create(req.body);
res.status(201).json(user);
});
// Validation errors return 400 automatically:
// { "code": "VALIDATION_ERROR", "message": "email must be a valid email" }<div style="margin:2.5rem auto;max-width:600px;width:100%;text-align:center;"><svg viewBox="0 0 600 220" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"><rect width="600" height="220" rx="12" fill="#1a1a2e"/><rect x="230" y="15" width="140" height="35" rx="8" fill="#6366f1" opacity="0.9"/><text x="300" y="38" text-anchor="middle" fill="#ffffff" font-size="12" font-family="system-ui" font-weight="bold">API Gateway</text><rect x="30" y="80" width="100" height="50" rx="8" fill="#3b82f6" opacity="0.8"/><text x="80" y="100" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">Auth</text><text x="80" y="115" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">Service</text><rect x="160" y="80" width="100" height="50" rx="8" fill="#a855f7" opacity="0.8"/><text x="210" y="100" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">User</text><text x="210" y="115" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">Service</text><rect x="290" y="80" width="100" height="50" rx="8" fill="#2dd4bf" opacity="0.8"/><text x="340" y="100" text-anchor="middle" fill="#1a1a2e" font-size="10" font-family="system-ui">Order</text><text x="340" y="115" text-anchor="middle" fill="#1a1a2e" font-size="10" font-family="system-ui">Service</text><rect x="420" y="80" width="100" height="50" rx="8" fill="#f59e0b" opacity="0.8"/><text x="470" y="100" text-anchor="middle" fill="#1a1a2e" font-size="10" font-family="system-ui">Payment</text><text x="470" y="115" text-anchor="middle" fill="#1a1a2e" font-size="10" font-family="system-ui">Service</text><line x1="265" y1="50" x2="80" y2="78" stroke="#e2e8f0" stroke-width="1" opacity="0.5"/><line x1="285" y1="50" x2="210" y2="78" stroke="#e2e8f0" stroke-width="1" opacity="0.5"/><line x1="315" y1="50" x2="340" y2="78" stroke="#e2e8f0" stroke-width="1" opacity="0.5"/><line x1="335" y1="50" x2="470" y2="78" stroke="#e2e8f0" stroke-width="1" opacity="0.5"/><ellipse cx="80" cy="175" rx="35" ry="12" fill="none" stroke="#3b82f6" stroke-width="1.5"/><line x1="45" y1="175" x2="45" y2="190" stroke="#3b82f6" stroke-width="1.5"/><line x1="115" y1="175" x2="115" y2="190" stroke="#3b82f6" stroke-width="1.5"/><ellipse cx="80" cy="190" rx="35" ry="12" fill="none" stroke="#3b82f6" stroke-width="1.5"/><line x1="80" y1="130" x2="80" y2="163" stroke="#94a3b8" stroke-width="1" stroke-dasharray="3,3"/><ellipse cx="340" cy="175" rx="35" ry="12" fill="none" stroke="#2dd4bf" stroke-width="1.5"/><line x1="305" y1="175" x2="305" y2="190" stroke="#2dd4bf" stroke-width="1.5"/><line x1="375" y1="175" x2="375" y2="190" stroke="#2dd4bf" stroke-width="1.5"/><ellipse cx="340" cy="190" rx="35" ry="12" fill="none" stroke="#2dd4bf" stroke-width="1.5"/><line x1="340" y1="130" x2="340" y2="163" stroke="#94a3b8" stroke-width="1" stroke-dasharray="3,3"/><rect x="155" y="160" width="150" height="30" rx="6" fill="#a855f7" opacity="0.3"/><text x="230" y="180" text-anchor="middle" fill="#a855f7" font-size="10" font-family="system-ui">Message Bus / Events</text><line x1="210" y1="130" x2="210" y2="158" stroke="#94a3b8" stroke-width="1" stroke-dasharray="3,3"/><line x1="470" y1="130" x2="470" y2="175" stroke="#94a3b8" stroke-width="1" stroke-dasharray="3,3"/><line x1="305" y1="175" x2="470" y2="175" stroke="#94a3b8" stroke-width="0.5" stroke-dasharray="3,3" opacity="0.3"/></svg><p style="margin-top:0.75rem;font-size:0.85rem;color:#94a3b8;font-style:italic;line-height:1.4;">Microservices architecture: independent services communicate through an API gateway and event bus.</p></div>
Interactive Documentation
// Serve Swagger UI
import swaggerUi from 'swagger-ui-express';
import YAML from 'yaml';
import fs from 'fs';
const spec = YAML.parse(fs.readFileSync('./openapi.yaml', 'utf8'));
app.use('/docs', swaggerUi.serve, swaggerUi.setup(spec, {
customSiteTitle: 'TechSaaS API Docs',
customCss: '.swagger-ui .topbar { display: none }',
}));
// Or use Scalar (modern, beautiful alternative)
// npm install @scalar/express-api-reference
import { apiReference } from '@scalar/express-api-reference';
app.use('/docs', apiReference({
spec: { url: '/openapi.yaml' },
theme: 'purple',
}));API Design Best Practices
Consistent Error Format
{
"code": "VALIDATION_ERROR",
"message": "Invalid request body",
"details": {
"email": ["must be a valid email address"],
"name": ["is required"]
}
}Versioning Strategy
URL path: /v1/users (recommended for simplicity)
Header: Accept: application/vnd.techsaas.v1+json
Query param: /users?version=1 (not recommended)Pagination
{
"data": [...],
"pagination": {
"page": 1,
"limit": 20,
"total": 156,
"totalPages": 8
}
}<div style="margin:2.5rem auto;max-width:600px;width:100%;text-align:center;"><svg viewBox="0 0 600 170" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"><rect width="600" height="170" rx="12" fill="#1a1a2e"/><circle cx="60" cy="85" r="25" fill="#f59e0b" opacity="0.85"/><text x="60" y="82" text-anchor="middle" fill="#1a1a2e" font-size="9" font-family="system-ui" font-weight="bold">Trigger</text><text x="60" y="94" text-anchor="middle" fill="#1a1a2e" font-size="8" font-family="system-ui">webhook</text><polygon points="175,55 210,85 175,115 140,85" fill="#6366f1" opacity="0.85"/><text x="175" y="88" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">If</text><rect x="250" y="35" width="100" height="40" rx="6" fill="#2dd4bf" opacity="0.85"/><text x="300" y="55" text-anchor="middle" fill="#1a1a2e" font-size="10" font-family="system-ui">Send Email</text><text x="300" y="67" text-anchor="middle" fill="#1a1a2e" font-size="8" font-family="system-ui">SMTP</text><rect x="250" y="95" width="100" height="40" rx="6" fill="#a855f7" opacity="0.85"/><text x="300" y="115" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">Log Event</text><text x="300" y="127" text-anchor="middle" fill="#ffffff" font-size="8" font-family="system-ui">database</text><rect x="400" y="55" width="100" height="40" rx="6" fill="#3b82f6" opacity="0.85"/><text x="450" y="75" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">Update CRM</text><text x="450" y="87" text-anchor="middle" fill="#ffffff" font-size="8" font-family="system-ui">API call</text><circle cx="545" cy="75" r="18" fill="none" stroke="#2dd4bf" stroke-width="2"/><text x="545" y="79" text-anchor="middle" fill="#2dd4bf" font-size="9" font-family="system-ui">Done</text><defs><marker id="arrow10" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6" fill="#e2e8f0"/></marker></defs><line x1="87" y1="85" x2="138" y2="85" stroke="#e2e8f0" stroke-width="1.5" marker-end="url(#arrow10)"/><line x1="210" y1="72" x2="248" y2="55" stroke="#e2e8f0" stroke-width="1.5" marker-end="url(#arrow10)"/><line x1="210" y1="98" x2="248" y2="115" stroke="#e2e8f0" stroke-width="1.5" marker-end="url(#arrow10)"/><line x1="352" y1="55" x2="398" y2="68" stroke="#e2e8f0" stroke-width="1.5" marker-end="url(#arrow10)"/><line x1="352" y1="115" x2="398" y2="82" stroke="#e2e8f0" stroke-width="1.5" marker-end="url(#arrow10)"/><line x1="502" y1="75" x2="525" y2="75" stroke="#e2e8f0" stroke-width="1.5" marker-end="url(#arrow10)"/><text x="225" y="45" text-anchor="middle" fill="#2dd4bf" font-size="8" font-family="system-ui">true</text><text x="225" y="120" text-anchor="middle" fill="#a855f7" font-size="8" font-family="system-ui">false</text></svg><p style="margin-top:0.75rem;font-size:0.85rem;color:#94a3b8;font-style:italic;line-height:1.4;">Workflow automation: triggers, conditions, and actions chain together to eliminate manual processes.</p></div>
Response Envelope
Always wrap responses in a consistent envelope:
Success: { "data": ... }
List: { "data": [...], "pagination": {...} }
Error: { "code": "...", "message": "...", "details": {...} }At TechSaaS, every internal API starts with an OpenAPI spec. The spec is the contract between frontend and backend teams — it gets reviewed in a PR before any code is written. We generate TypeScript types for the frontend, validation middleware for the backend, and interactive docs that serve as the living API documentation. This approach eliminates the "the API changed and nobody told the frontend team" problem.
Need help with platform engineering?
TechSaaS provides expert consulting and managed services for cloud infrastructure, DevOps, and AI/ML operations.