How Clients Use OpenAPI Specs
January 23, 2026 · View on GitHub
This guide explains how clients and other repositories can use the OpenAPI specifications from this repository.
Table of Contents
- Fetching Specs
- Generating Client Code
- Manual Implementation (Alternative Approach)
- Validating Implementations
- Runtime Validation
- Mock Servers for Unit Testing
- CI/CD Integration
- Type Safety
Fetching Specs
Option 1: Direct Download (Simple)
# Download the spec directly
curl -O https://raw.githubusercontent.com/calimero-network/core-server-open-api/main/specs/core-server/openapi.yaml
# Or use wget
wget https://raw.githubusercontent.com/calimero-network/core-server-open-api/main/specs/core-server/openapi.yaml
Option 2: Git Submodule (Recommended for Projects)
# Add as submodule
git submodule add git@github.com:calimero-network/core-server-open-api.git openapi-specs
# Update submodule
git submodule update --init --recursive
Option 3: npm Package (Future)
npm install @calimero-network/core-server-open-api
# Specs available at: node_modules/@calimero-network/core-server-open-api/specs/
Option 4: Checkout in CI
- name: Checkout OpenAPI specs
uses: actions/checkout@v4
with:
repository: calimero-network/core-server-open-api
path: openapi-specs
token: ${{ secrets.GITHUB_TOKEN }}
Generating Client Code
TypeScript/JavaScript
Using openapi-generator
# Install openapi-generator
npm install -g @openapitools/openapi-generator-cli
# Generate TypeScript client
openapi-generator-cli generate \
-i https://raw.githubusercontent.com/calimero-network/core-server-open-api/main/specs/core-server/openapi.yaml \
-g typescript-axios \
-o src/generated/client
# Or from local file
openapi-generator-cli generate \
-i openapi-specs/specs/core-server/openapi.yaml \
-g typescript-axios \
-o src/generated/client
Using openapi-typescript-codegen
npm install openapi-typescript-codegen
# Generate client
npx openapi-typescript-codegen \
--input openapi-specs/specs/core-server/openapi.yaml \
--output src/generated/client
Type-Only Generation
For manual implementations, you may want to generate only TypeScript types without the full client code. This gives you type safety while maintaining full control over the implementation:
# Install openapi-typescript
npm install -D openapi-typescript
# Generate types only
npx openapi-typescript openapi-specs/specs/core-server/openapi.yaml -o src/generated/types.ts
This generates a single TypeScript file with all type definitions from the OpenAPI spec:
// src/generated/types.ts (auto-generated)
export interface paths {
'/admin-api/health': {
get: {
responses: {
200: {
content: {
'application/json': {
status: string;
};
};
};
};
};
};
// ... more paths
}
export interface components {
schemas: {
Application: {
id: string;
name: string;
version: string;
};
// ... more schemas
};
}
Then use these types in your manual implementation:
import type { paths, components } from './generated/types';
type Application = components['schemas']['Application'];
type HealthResponse = paths['/admin-api/health']['get']['responses']['200']['content']['application/json'];
class MyCustomClient {
async getHealth(): Promise<HealthResponse> {
// Your custom implementation with type safety
const response = await fetch('/admin-api/health');
return response.json();
}
}
Example Generated Client Usage
import { CalimeroNodeClient } from './generated/client';
const client = new CalimeroNodeClient({
baseURL: 'http://localhost:8080',
headers: {
'Authorization': 'Bearer <token>'
}
});
// Type-safe API calls
const health = await client.adminApi.getHealth();
const apps = await client.adminApi.listApplications();
Python
# Install openapi-generator
pip install openapi-generator-cli
# Generate Python client
openapi-generator-cli generate \
-i openapi-specs/specs/core-server/openapi.yaml \
-g python \
-o generated/python-client
Example Generated Client Usage
from generated.python_client import CalimeroNodeClient
client = CalimeroNodeClient(
base_url="http://localhost:8080",
headers={"Authorization": "Bearer <token>"}
)
# Type-safe API calls
health = client.admin_api.get_health()
apps = client.admin_api.list_applications()
Rust
# Generate Rust client
openapi-generator-cli generate \
-i openapi-specs/specs/core-server/openapi.yaml \
-g rust \
-o generated/rust-client
Manual Implementation (Alternative Approach)
While code generation is convenient, manual implementation can be preferable in certain scenarios. This approach gives you full control over the client implementation while still leveraging the OpenAPI spec for type safety and validation.
When Manual Implementation is Preferable
- Custom Error Handling: When you need specialized error handling, retry logic, or custom error types that don't fit generated code patterns
- Specialized Auth Flows: For complex authentication flows (OAuth, custom token refresh, multi-factor auth) that require custom logic
- Smaller Bundles: When bundle size matters and you only need a subset of endpoints, manual implementation can be more tree-shakeable
- Framework-Specific Patterns: When you need to follow specific patterns for your framework (React hooks, Vue composables, Angular services)
- SDK Requirements: For SDKs that need custom behavior, middleware, or integration with specific libraries
Best Practices for Manual Implementation
1. Reference OpenAPI Spec as Source of Truth
Always keep the OpenAPI spec as your single source of truth. Reference it when implementing new endpoints or updating existing ones:
// Reference the spec when implementing
// Spec: GET /admin-api/health
// Response: { status: string }
async getHealth(): Promise<{ status: string }> {
const response = await this.fetch('/admin-api/health');
return response.json();
}
2. Generate Only Types (Not Full Clients)
Use type-only generation to get TypeScript types without the full client implementation:
# Generate types from spec
npx openapi-typescript openapi-specs/specs/core-server/openapi.yaml -o src/generated/types.ts
Then import and use these types in your manual implementation:
import type { paths, components } from './generated/types';
type HealthResponse = paths['/admin-api/health']['get']['responses']['200']['content']['application/json'];
class CalimeroClient {
async getHealth(): Promise<HealthResponse> {
// Manual implementation with generated types
const response = await fetch(`${this.baseURL}/admin-api/health`);
if (!response.ok) throw new Error('Health check failed');
return response.json();
}
}
3. Use Runtime Validation
Validate requests and responses at runtime using the OpenAPI spec:
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import * as yaml from 'js-yaml';
import * as fs from 'fs';
import type { OpenAPIV3 } from 'openapi-types';
// Load spec at build time or runtime
const spec = yaml.load(
fs.readFileSync('openapi-specs/specs/core-server/openapi.yaml', 'utf8')
) as OpenAPIV3.Document;
const ajv = new Ajv();
addFormats(ajv);
class CalimeroClient {
private validateResponse(path: string, method: string, data: unknown) {
const pathItem = spec.paths[path];
const operation = pathItem?.[method.toLowerCase()];
const response = operation?.responses?.['200'];
if (response && 'content' in response) {
const schema = response.content['application/json']?.schema;
if (schema) {
const validate = ajv.compile(schema);
if (!validate(data)) {
throw new Error(`Response validation failed: ${JSON.stringify(validate.errors)}`);
}
}
}
}
async getHealth() {
const response = await fetch('/admin-api/health');
const data = await response.json();
this.validateResponse('/admin-api/health', 'GET', data);
return data;
}
}
4. Keep in Sync via CI
Set up CI checks to ensure your manual implementation stays in sync with the spec:
- name: Validate manual implementation matches spec
run: |
# Check that all endpoints in spec are implemented
# Check that request/response types match
npm run validate:implementation
See the Validating Manual Implementations section below for detailed CI setup.
Example: Manual TypeScript Client
// src/client.ts
import type { paths, components } from './generated/types';
import type {
paths as Paths,
components as Components
} from './generated/types';
// Extract types from generated types
type HealthResponse = Paths['/admin-api/health']['get']['responses']['200']['content']['application/json'];
type Application = Components['schemas']['Application'];
type ListApplicationsResponse = Paths['/admin-api/applications']['get']['responses']['200']['content']['application/json'];
class CalimeroNodeClient {
constructor(
private baseURL: string,
private token?: string
) {}
private async request<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const response = await fetch(`${this.baseURL}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(this.token && { Authorization: `Bearer ${this.token}` }),
...options.headers,
},
});
if (!response.ok) {
throw new Error(`API request failed: ${response.statusText}`);
}
return response.json();
}
// Manual implementation with type safety
async getHealth(): Promise<HealthResponse> {
return this.request<HealthResponse>('/admin-api/health');
}
async listApplications(): Promise<ListApplicationsResponse> {
return this.request<ListApplicationsResponse>('/admin-api/applications');
}
async createApplication(app: Omit<Application, 'id'>): Promise<Application> {
return this.request<Application>('/admin-api/applications', {
method: 'POST',
body: JSON.stringify(app),
});
}
}
Validating Implementations
Validate Your Server Against Spec
# Install swagger-cli
npm install -g @apidevtools/swagger-cli
# Validate your server's OpenAPI spec against the reference
swagger-cli validate your-server-spec.yaml
# Compare schemas
swagger-cli diff \
openapi-specs/specs/core-server/openapi.yaml \
your-server-spec.yaml
Runtime Validation with express-openapi-validator
import express from 'express';
import { OpenApiValidator } from 'express-openapi-validator';
const app = express();
// Validate requests/responses against OpenAPI spec
new OpenApiValidator({
apiSpec: './openapi-specs/specs/core-server/openapi.yaml',
validateRequests: true,
validateResponses: true,
}).install(app);
// Your routes
app.get('/admin-api/health', (req, res) => {
// Request/response automatically validated
res.json({ status: 'ok' });
});
Runtime Validation
Request/Response Validation
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import { OpenAPIV3 } from 'openapi-types';
import * as fs from 'fs';
import * as yaml from 'js-yaml';
// Load OpenAPI spec
const spec = yaml.load(
fs.readFileSync('openapi-specs/specs/core-server/openapi.yaml', 'utf8')
) as OpenAPIV3.Document;
// Create validator
const ajv = new Ajv();
addFormats(ajv);
// Validate request
function validateRequest(path: string, method: string, body: any) {
const pathItem = spec.paths[path];
const operation = pathItem?.[method.toLowerCase()];
const requestBody = operation?.requestBody;
if (requestBody && 'content' in requestBody) {
const schema = requestBody.content['application/json']?.schema;
if (schema) {
const validate = ajv.compile(schema);
const valid = validate(body);
if (!valid) {
throw new Error(`Validation failed: ${JSON.stringify(validate.errors)}`);
}
}
}
}
Mock Servers for Unit Testing
OpenAPI specifications can be used to generate mock servers for comprehensive unit testing. This approach allows you to test all edge cases, error scenarios, and response variations without Docker overhead, while keeping e2e tests with real Docker infrastructure for integration testing.
Overview
Use OpenAPI-based mock servers for unit tests to cover:
- All HTTP status codes (200, 400, 401, 404, 500, etc.)
- Network failures and timeouts
- Malformed responses
- Edge cases (empty arrays, null values, zero counts)
- Fast execution (no Docker startup)
- Deterministic and repeatable tests
Keep e2e tests with Docker/merobox for real infrastructure testing.
Recommended Tool: MSW (Mock Service Worker)
MSW is the best choice for Vitest unit tests because:
- Works seamlessly in Node.js (Vitest environment)
- Intercepts
fetchrequests automatically - Easy to configure different scenarios per test
- No separate server process needed
- Perfect for testing HTTP clients
MSW Setup for Vitest
Installation
npm install -D msw
Basic Setup
// tests/mocks/server.ts
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
export const server = setupServer();
// vitest.setup.ts
import { beforeAll, afterEach, afterAll } from 'vitest';
import { server } from './tests/mocks/server';
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
Creating Handlers from OpenAPI Spec
// tests/mocks/handlers.ts
import { http, HttpResponse } from 'msw';
import { OpenAPIV3 } from 'openapi-types';
import * as yaml from 'js-yaml';
import * as fs from 'fs';
// Load spec for reference
const spec = yaml.load(
fs.readFileSync('openapi-specs/specs/core-server/openapi.yaml', 'utf8')
) as OpenAPIV3.Document;
// Base handlers matching OpenAPI spec
// Note: Return responses matching the OpenAPI spec structure directly
// (not wrapped in a 'data' field, unless the spec specifies it)
export const baseHandlers = [
http.get('*/admin-api/health', () => {
return HttpResponse.json({ status: 'ok' });
}),
http.get('*/admin-api/applications', () => {
return HttpResponse.json({
applications: [
{ id: 'app1', name: 'Test App', version: '1.0.0' }
],
total: 1
});
}),
// ... more handlers matching spec endpoints
];
Testing All Scenarios
Error Scenarios
// src/api/admin/__tests__/applications.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/mocks/server';
import { ApplicationsApiClient } from '../applications';
import { createBrowserHttpClient } from '../../../http-client';
describe('ApplicationsApiClient - Error Scenarios', () => {
let client: ApplicationsApiClient;
beforeEach(() => {
const httpClient = createBrowserHttpClient({
baseUrl: 'http://localhost:8080',
});
client = new ApplicationsApiClient(httpClient);
});
it('should handle 404 Not Found', async () => {
server.use(
http.get('*/admin-api/applications/nonexistent', () => {
return HttpResponse.json(
{ error: 'Application not found' },
{ status: 404 }
);
})
);
await expect(
client.getApplication('nonexistent')
).rejects.toThrow();
});
it('should handle 401 Unauthorized', async () => {
server.use(
http.get('*/admin-api/applications', () => {
return HttpResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
})
);
await expect(client.listApplications()).rejects.toThrow();
});
it('should handle 500 Internal Server Error', async () => {
server.use(
http.get('*/admin-api/applications', () => {
return HttpResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
})
);
await expect(client.listApplications()).rejects.toThrow();
});
it('should handle network timeout', async () => {
// Configure client with short timeout
const httpClient = createBrowserHttpClient({
baseUrl: 'http://localhost:8080',
timeout: 1000, // 1 second timeout
});
const clientWithTimeout = new ApplicationsApiClient(httpClient);
server.use(
http.get('*/admin-api/applications', async () => {
// Delay longer than client timeout
await new Promise(resolve => setTimeout(resolve, 2000));
return HttpResponse.json({ applications: [], total: 0 });
})
);
await expect(clientWithTimeout.listApplications()).rejects.toThrow();
});
it('should handle empty response array', async () => {
server.use(
http.get('*/admin-api/applications', () => {
return HttpResponse.json({ applications: [], total: 0 });
})
);
const result = await client.listApplications();
expect(result.applications).toEqual([]);
expect(result.total).toBe(0);
});
it('should handle malformed JSON response', async () => {
server.use(
http.get('*/admin-api/applications', () => {
return new HttpResponse('invalid json', {
headers: { 'Content-Type': 'application/json' }
});
})
);
await expect(client.listApplications()).rejects.toThrow();
});
});
Edge Cases
describe('Edge Cases', () => {
it('should handle zero count responses', async () => {
server.use(
http.get('*/admin-api/contexts/:id/proposals/count', () => {
// Match the actual API response structure from OpenAPI spec
// Adjust this to match your spec (e.g., { count: 0 } or just 0)
return HttpResponse.json({ count: 0 });
})
);
const result = await proposalsClient.getNumberOfActiveProposals('ctx-1');
expect(result).toBe(0);
});
it('should handle null optional fields', async () => {
server.use(
http.get('*/admin-api/blobs/:id', () => {
return HttpResponse.json({
data: { blobId: 'blob1', size: 100, hash: null }
});
})
);
const result = await blobsClient.getBlobInfo('blob1');
expect(result.hash).toBeNull();
});
it('should handle very large responses', async () => {
const largeArray = Array(10000).fill({ id: 'item', name: 'Test', version: '1.0.0' });
server.use(
http.get('*/admin-api/applications', () => {
return HttpResponse.json({
applications: largeArray,
total: 10000
});
})
);
const result = await client.listApplications();
expect(result.applications).toHaveLength(10000);
expect(result.total).toBe(10000);
});
});
Authentication Scenarios
describe('Auth API - Token Scenarios', () => {
it('should handle token refresh on 401', async () => {
let callCount = 0;
server.use(
http.get('*/admin-api/applications', () => {
callCount++;
if (callCount === 1) {
return HttpResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
return HttpResponse.json({
applications: [],
total: 0
});
}),
http.post('*/auth/refresh', () => {
// Auth API response structure (adjust to match your actual auth API spec)
return HttpResponse.json({
access_token: 'new-token',
refresh_token: 'new-refresh',
expires_in: 3600
});
})
);
// Test that client automatically refreshes token
const result = await client.listApplications();
expect(result).toEqual([]);
});
});
Helper Functions for Common Scenarios
// tests/mocks/helpers.ts
import { http, HttpResponse } from 'msw';
export const createErrorHandler = (path: string, status: number, message: string) => {
return http.all(path, () => {
return HttpResponse.json({ error: message }, { status });
});
};
export const createTimeoutHandler = (path: string, delay: number, response: any = {}) => {
return http.all(path, async () => {
await new Promise(resolve => setTimeout(resolve, delay));
return HttpResponse.json(response);
});
};
export const createEmptyResponseHandler = (path: string, responseStructure: any = { applications: [], total: 0 }) => {
return http.all(path, () => {
return HttpResponse.json(responseStructure);
});
};
// Usage in tests
import { createErrorHandler, createEmptyResponseHandler } from '../../../tests/mocks/helpers';
it('should handle 500 error', async () => {
server.use(createErrorHandler('*/admin-api/applications', 500, 'Server error'));
await expect(client.listApplications()).rejects.toThrow();
});
it('should handle empty list', async () => {
server.use(createEmptyResponseHandler('*/admin-api/applications', { applications: [], total: 0 }));
const result = await client.listApplications();
expect(result.applications).toEqual([]);
});
Benefits for Unit Tests
- Comprehensive Coverage: Test all HTTP status codes, error scenarios, and edge cases
- Fast Execution: No Docker startup overhead, tests run in milliseconds
- Deterministic: Same responses every time, no flakiness
- Isolated: Each test can configure its own mock responses
- Easy to Maintain: Handlers match OpenAPI spec structure
- Offline Testing: No network dependencies required
Best Practices
- Keep handlers aligned with OpenAPI spec structure: Use the same response format as documented in the spec
- Use
server.resetHandlers()between tests: Ensures test isolation - Test both success and failure paths: Cover all documented error responses from spec
- Use helper functions: Create reusable handlers for common scenarios
- Document which scenarios are tested: Keep track of what's covered in unit tests vs. e2e tests
- Match spec examples: Use the same example responses from the OpenAPI spec when possible
Integration with Existing Tests
- Unit Tests: Use MSW mock server (fast, comprehensive coverage of all scenarios)
- E2E Tests: Keep using merobox/Docker (real infrastructure testing)
- Both complement each other: Unit tests catch edge cases quickly, e2e tests verify real behavior
Alternative: Prism Mock Server
For standalone mock servers (not integrated into test framework), use Prism:
# Install Prism
npm install -g @stoplight/prism-cli
# Start mock server from OpenAPI spec
prism mock openapi-specs/specs/core-server/openapi.yaml
# Server runs on http://localhost:4010
# Validate requests against spec
prism mock -d openapi-specs/specs/core-server/openapi.yaml
# Custom port
prism mock -p 8080 openapi-specs/specs/core-server/openapi.yaml
Prism is useful for:
- Manual testing during development
- Contract testing (validate real server matches spec)
- Standalone mock server for integration tests
CI/CD Integration
GitHub Actions Workflow
name: Validate API Implementation
on:
pull_request:
paths:
- 'src/**'
- 'openapi-specs/**'
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkout OpenAPI specs
uses: actions/checkout@v4
with:
repository: calimero-network/core-server-open-api
path: openapi-specs
token: ${{ secrets.GITHUB_TOKEN }}
# Pin to specific version/tag for consistency
ref: v1.0.0 # or use a specific commit SHA
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
working-directory: openapi-specs
run: npm ci
- name: Validate OpenAPI spec
run: |
npx @apidevtools/swagger-cli validate \
openapi-specs/specs/core-server/openapi.yaml
- name: Check for breaking changes
run: |
# Compare your spec against reference
bash openapi-specs/scripts/check-breaking-changes.sh \
openapi-specs/specs/core-server/openapi.yaml \
./your-spec.yaml || exit 1
- name: Generate and test client
run: |
npx @openapitools/openapi-generator-cli generate \
-i openapi-specs/specs/core-server/openapi.yaml \
-g typescript-axios \
-o generated-client
# Run tests with generated client
npm test
Pre-commit Hook
#!/bin/bash
# .git/hooks/pre-commit
# Fetch latest spec
curl -s https://raw.githubusercontent.com/calimero-network/core-server-open-api/main/specs/core-server/openapi.yaml \
> /tmp/reference-spec.yaml
# Validate your spec
npx @apidevtools/swagger-cli validate your-spec.yaml
# Check breaking changes
bash openapi-specs/scripts/check-breaking-changes.sh \
/tmp/reference-spec.yaml \
your-spec.yaml
exit $?
Validating Manual Implementations
When using manual implementation, it's important to validate that your implementation matches the OpenAPI spec. This goes beyond just validating the spec itself—you need to ensure your code actually implements what the spec defines.
Endpoint Coverage Check
Create a script to verify all endpoints in the spec are implemented:
// scripts/validate-coverage.ts
import * as yaml from 'js-yaml';
import * as fs from 'fs';
import type { OpenAPIV3 } from 'openapi-types';
import { CalimeroNodeClient } from '../src/client';
const spec = yaml.load(
fs.readFileSync('openapi-specs/specs/core-server/openapi.yaml', 'utf8')
) as OpenAPIV3.Document;
// Extract all endpoints from spec
const specEndpoints = Object.keys(spec.paths).flatMap(path => {
const pathItem = spec.paths[path];
return Object.keys(pathItem || {})
.filter(method => ['get', 'post', 'put', 'delete', 'patch'].includes(method))
.map(method => ({ path, method }));
});
// Check if client has corresponding methods
// This is a simplified example - you'd need to reflect on your actual client
const client = new CalimeroNodeClient('http://localhost:8080');
const clientMethods = Object.getOwnPropertyNames(Object.getPrototypeOf(client))
.filter(name => name !== 'constructor' && typeof client[name as keyof typeof client] === 'function');
console.log(`Spec endpoints: ${specEndpoints.length}`);
console.log(`Client methods: ${clientMethods.length}`);
// Add your validation logic here
Type Compatibility Check
Ensure your manual types match the generated types:
// scripts/validate-types.ts
import type { paths, components } from '../src/generated/types';
import type { HealthResponse, Application } from '../src/client';
// TypeScript will error at compile time if types don't match
type HealthCheck = HealthResponse extends paths['/admin-api/health']['get']['responses']['200']['content']['application/json']
? true
: never;
type ApplicationCheck = Application extends components['schemas']['Application']
? true
: never;
// If compilation succeeds, types are compatible
CI Workflow for Manual Implementation Validation
name: Validate Manual Implementation
on:
pull_request:
paths:
- 'src/**'
- 'openapi-specs/**'
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Checkout OpenAPI specs
uses: actions/checkout@v4
with:
repository: calimero-network/core-server-open-api
path: openapi-specs
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: Generate types from spec
run: |
npx openapi-typescript openapi-specs/specs/core-server/openapi.yaml \
-o src/generated/types.ts
- name: Type check implementation
run: |
# TypeScript will fail if types don't match
npm run type-check
- name: Validate endpoint coverage
run: |
# Run custom script to check all endpoints are implemented
npm run validate:coverage
- name: Validate request/response shapes
run: |
# Use runtime validation to ensure shapes match
npm run validate:shapes
- name: Run integration tests
run: |
# Test actual API calls match spec
npm run test:integration
Runtime Shape Validation
Validate that actual API responses match the spec at runtime:
// src/validation.ts
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import * as yaml from 'js-yaml';
import * as fs from 'fs';
import type { OpenAPIV3 } from 'openapi-types';
const spec = yaml.load(
fs.readFileSync('openapi-specs/specs/core-server/openapi.yaml', 'utf8')
) as OpenAPIV3.Document;
const ajv = new Ajv();
addFormats(ajv);
export function validateResponse(
path: string,
method: string,
statusCode: number,
data: unknown
): void {
const pathItem = spec.paths[path];
const operation = pathItem?.[method.toLowerCase()];
const response = operation?.responses?.[statusCode.toString()];
if (!response) {
throw new Error(`Unexpected status code ${statusCode} for ${method} ${path}`);
}
if ('content' in response) {
const schema = response.content['application/json']?.schema;
if (schema) {
const validate = ajv.compile(schema);
if (!validate(data)) {
throw new Error(
`Response validation failed for ${method} ${path}: ${JSON.stringify(validate.errors)}`
);
}
}
}
}
// Use in your client
class CalimeroClient {
async getHealth() {
const response = await fetch('/admin-api/health');
const data = await response.json();
validateResponse('/admin-api/health', 'GET', response.status, data);
return data;
}
}
Type Safety
TypeScript Types from OpenAPI
import { components } from './generated/client/types';
// Use generated types
type Application = components['schemas']['Application'];
type HealthResponse = components['schemas']['HealthResponse'];
function handleApplication(app: Application) {
// Full type safety
console.log(app.id, app.name);
}
Runtime Type Checking
import { Type, Static } from '@sinclair/typebox';
import { Value } from '@sinclair/typebox/value';
// Convert OpenAPI schema to TypeBox
const ApplicationSchema = Type.Object({
id: Type.String(),
name: Type.String(),
version: Type.String(),
});
type Application = Static<typeof ApplicationSchema>;
// Validate at runtime
function validateApplication(data: unknown): Application {
if (Value.Check(ApplicationSchema, data)) {
return data;
}
throw new Error('Invalid application data');
}
Complete Example: TypeScript Client
// src/client.ts
import axios from 'axios';
import { OpenAPIV3 } from 'openapi-types';
import * as yaml from 'js-yaml';
import * as fs from 'fs';
// Load OpenAPI spec
const spec = yaml.load(
fs.readFileSync('openapi-specs/specs/core-server/openapi.yaml', 'utf8')
) as OpenAPIV3.Document;
// Create typed client
class CalimeroClient {
private baseURL: string;
private axiosInstance: any;
constructor(baseURL: string, token?: string) {
this.baseURL = baseURL;
this.axiosInstance = axios.create({
baseURL,
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
}
// Generate methods from OpenAPI spec
async getHealth() {
const path = '/admin-api/health';
const response = await this.axiosInstance.get(path);
return response.data;
}
async listApplications() {
const path = '/admin-api/applications';
const response = await this.axiosInstance.get(path);
return response.data;
}
// ... more methods generated from spec
}
export { CalimeroClient };
Best Practices
- Always validate your implementation against the reference spec
- Check for breaking changes before merging PRs
- Generate client code from specs for type safety, or use type-only generation for manual implementations
- Consider manual implementation for SDKs requiring custom behavior, specialized auth flows, or framework-specific patterns
- Generate types from spec even when manually implementing clients to maintain type safety
- Pin spec versions in CI/CD to ensure consistent validation across environments
- Use runtime validation in development/staging to catch mismatches early
- Keep specs in sync - update when APIs change
- Version your specs - use semantic versioning
- Document breaking changes clearly
Tools Reference
- openapi-generator: Generate clients in 40+ languages
- openapi-typescript: Generate TypeScript types only (for type-only generation in manual implementations)
- openapi-typescript-codegen: Generate full TypeScript clients with methods
- swagger-cli: Validate and diff OpenAPI specs
- @redocly/cli: OpenAPI linting and validation (can be extended for breaking change detection)
- express-openapi-validator: Runtime validation for Express
- ajv: JSON Schema validation for runtime request/response validation
- msw (Mock Service Worker): Intercept HTTP requests in tests, perfect for unit testing with Vitest
- Prism: Standalone mock server from OpenAPI specs, supports validation and dynamic responses
Troubleshooting
Spec not found
# Ensure you've fetched the specs
git submodule update --init --recursive
Validation errors
# Check spec syntax
npx @apidevtools/swagger-cli validate spec.yaml
# Check for common issues
npx @stoplight/spectral lint spec.yaml
Breaking changes detected
# Review the diff
bash scripts/check-breaking-changes.sh old.yaml new.yaml
# If intentional, update version in spec
# info:
# version: 2.0.0 # Major version bump for breaking changes