API Contracts
Design APIs with contracts first - schema validation, versioning, and breaking changes.
6 min read
What are API Contracts?#
An API contract is a formal agreement between your API and its consumers:
- Request format - What data to send
- Response format - What data comes back
- Error format - How errors are returned
- Versioning - How changes are handled
Contract-First Design#
Define the contract before writing code:
yaml
# api-contract.yaml
openapi: 3.0.0
info:
title: User API
version: 1.0.0
paths:
/users:
post:
summary: Create user
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateUser'
responses:
'201':
description: User created
content:
application/json:
schema:
$ref: '#/components/schemas/UserResponse'
'400':
$ref: '#/components/responses/ValidationError'
components:
schemas:
CreateUser:
type: object
required: [email, password, name]
properties:
email:
type: string
format: email
password:
type: string
minLength: 8
name:
type: string
minLength: 1
maxLength: 100
UserResponse:
type: object
properties:
data:
$ref: '#/components/schemas/User'
User:
type: object
properties:
id:
type: string
email:
type: string
name:
type: string
createdAt:
type: string
format: date-time
Enforcing Contracts with Validation#
Zod (Runtime Validation)#
javascript
// src/contracts/user.js
import { z } from 'zod';
// Request schemas
export const CreateUserSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(1).max(100),
});
export const UpdateUserSchema = z.object({
name: z.string().min(1).max(100).optional(),
email: z.string().email().optional(),
});
export const UserIdSchema = z.object({
id: z.string().regex(/^[a-f\d]{24}$/i, 'Invalid ID format'),
});
// Response schemas
export const UserSchema = z.object({
id: z.string(),
email: z.string().email(),
name: z.string(),
createdAt: z.string().datetime(),
});
export const UserResponseSchema = z.object({
data: UserSchema,
});
export const UsersResponseSchema = z.object({
data: z.array(UserSchema),
meta: z.object({
page: z.number(),
limit: z.number(),
total: z.number(),
}),
});
Validation Middleware#
javascript
// src/middleware/validate.js
export function validateBody(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: {
message: 'Validation failed',
code: 'VALIDATION_ERROR',
details: result.error.flatten().fieldErrors,
},
});
}
req.body = result.data; // Use parsed/transformed data
next();
};
}
export function validateParams(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.params);
if (!result.success) {
return res.status(400).json({
error: {
message: 'Invalid parameters',
code: 'INVALID_PARAMS',
details: result.error.flatten().fieldErrors,
},
});
}
req.params = result.data;
next();
};
}
export function validateQuery(schema) {
return (req, res, next) => {
const result = schema.safeParse(req.query);
if (!result.success) {
return res.status(400).json({
error: {
message: 'Invalid query parameters',
code: 'INVALID_QUERY',
details: result.error.flatten().fieldErrors,
},
});
}
req.query = result.data;
next();
};
}
Apply to Routes#
javascript
// src/routes/users.js
import { validateBody, validateParams } from '../middleware/validate.js';
import { CreateUserSchema, UpdateUserSchema, UserIdSchema } from '../contracts/user.js';
router.post('/',
validateBody(CreateUserSchema),
createUser
);
router.get('/:id',
validateParams(UserIdSchema),
getUser
);
router.patch('/:id',
validateParams(UserIdSchema),
validateBody(UpdateUserSchema),
updateUser
);
Standard Response Format#
Define consistent response structures:
javascript
// src/contracts/responses.js
import { z } from 'zod';
// Success response wrapper
export function dataResponse(dataSchema) {
return z.object({
data: dataSchema,
});
}
// Paginated response wrapper
export function paginatedResponse(itemSchema) {
return z.object({
data: z.array(itemSchema),
meta: z.object({
page: z.number(),
limit: z.number(),
total: z.number(),
totalPages: z.number(),
}),
});
}
// Error response
export const ErrorResponseSchema = z.object({
error: z.object({
message: z.string(),
code: z.string(),
details: z.record(z.array(z.string())).optional(),
}),
});
javascript
// src/utils/responses.js
export function sendSuccess(res, data, status = 200) {
res.status(status).json({ data });
}
export function sendPaginated(res, items, meta) {
res.json({
data: items,
meta: {
page: meta.page,
limit: meta.limit,
total: meta.total,
totalPages: Math.ceil(meta.total / meta.limit),
},
});
}
export function sendError(res, status, message, code, details = null) {
const response = {
error: { message, code },
};
if (details) response.error.details = details;
res.status(status).json(response);
}
API Versioning#
URL Versioning#
javascript
// Most explicit, easy to understand
app.use('/api/v1/users', v1UserRoutes);
app.use('/api/v2/users', v2UserRoutes);
// Structure
// src/routes/v1/users.js
// src/routes/v2/users.js
Header Versioning#
javascript
// src/middleware/apiVersion.js
export function apiVersion(req, res, next) {
const version = req.headers['api-version'] || '1';
req.apiVersion = parseInt(version);
next();
}
// In routes
router.get('/users', (req, res) => {
if (req.apiVersion === 2) {
// V2 behavior
} else {
// V1 behavior (default)
}
});
Content Negotiation#
javascript
// Accept: application/vnd.myapi.v2+json
app.use((req, res, next) => {
const accept = req.headers.accept || '';
const match = accept.match(/vnd\.myapi\.v(\d+)/);
req.apiVersion = match ? parseInt(match[1]) : 1;
next();
});
Handling Breaking Changes#
What's a Breaking Change?#
| Change Type | Breaking? | Example |
|---|---|---|
| Add optional field | No | Add nickname to user |
| Add required field | Yes | Require phone for signup |
| Remove field | Yes | Remove avatar from response |
| Rename field | Yes | name → fullName |
| Change field type | Yes | age: string → age: number |
| Change validation | Maybe | min: 6 → min: 8 |
Strategies for Breaking Changes#
javascript
// 1. Deprecation headers
res.set('Deprecation', 'true');
res.set('Sunset', 'Sat, 1 Jan 2025 00:00:00 GMT');
res.set('Link', '</api/v2/users>; rel="successor-version"');
// 2. Support both formats temporarily
const UserSchemaV1 = z.object({
name: z.string(), // Old field
});
const UserSchemaV2 = z.object({
firstName: z.string(),
lastName: z.string(),
});
// 3. Transform responses for backwards compatibility
function transformUserForV1(user) {
return {
...user,
name: `${user.firstName} ${user.lastName}`, // Add old field
};
}
Contract Testing#
Verify your API matches the contract:
javascript
// tests/contracts/users.test.js
import { describe, it, expect } from 'vitest';
import request from 'supertest';
import { app } from '../../src/app.js';
import { UserResponseSchema, UsersResponseSchema } from '../../src/contracts/user.js';
describe('User API Contract', () => {
it('GET /users matches contract', async () => {
const response = await request(app)
.get('/api/users')
.expect(200);
// Validate response matches schema
const result = UsersResponseSchema.safeParse(response.body);
expect(result.success).toBe(true);
});
it('POST /users validates input', async () => {
const response = await request(app)
.post('/api/users')
.send({ email: 'invalid' }) // Missing required fields
.expect(400);
expect(response.body.error.code).toBe('VALIDATION_ERROR');
expect(response.body.error.details).toHaveProperty('password');
expect(response.body.error.details).toHaveProperty('name');
});
it('POST /users response matches contract', async () => {
const response = await request(app)
.post('/api/users')
.send({
email: 'test@example.com',
password: 'password123',
name: 'Test User',
})
.expect(201);
const result = UserResponseSchema.safeParse(response.body);
expect(result.success).toBe(true);
});
});
Generating SDKs from Contracts#
bash
# Generate TypeScript client from OpenAPI
npx openapi-typescript-codegen \
--input ./api-contract.yaml \
--output ./generated/client \
--client axios
# Generate types only
npx openapi-typescript ./api-contract.yaml -o ./generated/types.ts
Key Takeaways#
- Design contract first - Agree on the API before coding
- Validate everything - Request body, params, query strings
- Consistent responses - Same structure everywhere
- Version your API - URL versioning is simplest
- Test contracts - Ensure responses match schemas
- Document breaking changes - Deprecation headers, changelogs
Continue Learning
Ready to level up your skills?
Explore more guides and tutorials to deepen your understanding and become a better developer.