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

  1. Fetching Specs
  2. Generating Client Code
  3. Manual Implementation (Alternative Approach)
  4. Validating Implementations
  5. Runtime Validation
  6. Mock Servers for Unit Testing
  7. CI/CD Integration
  8. 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
# 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

  1. Custom Error Handling: When you need specialized error handling, retry logic, or custom error types that don't fit generated code patterns
  2. Specialized Auth Flows: For complex authentication flows (OAuth, custom token refresh, multi-factor auth) that require custom logic
  3. Smaller Bundles: When bundle size matters and you only need a subset of endpoints, manual implementation can be more tree-shakeable
  4. Framework-Specific Patterns: When you need to follow specific patterns for your framework (React hooks, Vue composables, Angular services)
  5. 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.

MSW is the best choice for Vitest unit tests because:

  • Works seamlessly in Node.js (Vitest environment)
  • Intercepts fetch requests 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

  1. Keep handlers aligned with OpenAPI spec structure: Use the same response format as documented in the spec
  2. Use server.resetHandlers() between tests: Ensures test isolation
  3. Test both success and failure paths: Cover all documented error responses from spec
  4. Use helper functions: Create reusable handlers for common scenarios
  5. Document which scenarios are tested: Keep track of what's covered in unit tests vs. e2e tests
  6. 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

  1. Always validate your implementation against the reference spec
  2. Check for breaking changes before merging PRs
  3. Generate client code from specs for type safety, or use type-only generation for manual implementations
  4. Consider manual implementation for SDKs requiring custom behavior, specialized auth flows, or framework-specific patterns
  5. Generate types from spec even when manually implementing clients to maintain type safety
  6. Pin spec versions in CI/CD to ensure consistent validation across environments
  7. Use runtime validation in development/staging to catch mismatches early
  8. Keep specs in sync - update when APIs change
  9. Version your specs - use semantic versioning
  10. 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