Part VI: API Specification — OpenAPI, AsyncAPI, Contract Testing
The OpenAPI spec guarantees that
POST /users/{id}/rolesaccepts aRoleobject and returns a204. It says nothing about whether an admin can actually assign roles, whether role changes take effect immediately, or whether non-admins are blocked. API shape is not feature behavior.
What They Are
API specification tools define the shape of an API — endpoints, request/response schemas, status codes, authentication requirements — in a machine-readable format:
- OpenAPI (formerly Swagger): REST API specification in YAML or JSON
- AsyncAPI: Event-driven / message-based API specification
- Pact: Consumer-driven contract testing for microservice boundaries
- GraphQL SDL: Schema Definition Language for GraphQL APIs
- gRPC / Protocol Buffers: Strongly typed RPC interface definitions
Contract testing tools then verify that the implementation matches the specification.
How They Link Requirements to Tests
The specification document IS the requirement. Tests validate that the implementation conforms:
# openapi.yaml — the specification
openapi: 3.0.3
info:
title: User Roles API
version: 1.0.0
paths:
/users/{userId}/roles:
post:
operationId: assignRole
summary: Assign a role to a user
parameters:
- name: userId
in: path
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RoleAssignment'
responses:
'204':
description: Role assigned successfully
'403':
description: Caller lacks permission
components:
schemas:
RoleAssignment:
type: object
required: [role]
properties:
role:
type: string
enum: [viewer, editor, admin]# openapi.yaml — the specification
openapi: 3.0.3
info:
title: User Roles API
version: 1.0.0
paths:
/users/{userId}/roles:
post:
operationId: assignRole
summary: Assign a role to a user
parameters:
- name: userId
in: path
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RoleAssignment'
responses:
'204':
description: Role assigned successfully
'403':
description: Caller lacks permission
components:
schemas:
RoleAssignment:
type: object
required: [role]
properties:
role:
type: string
enum: [viewer, editor, admin]// Contract test — validates the implementation matches the spec
import { createValidator } from 'openapi-validator';
const validator = createValidator('./openapi.yaml');
test('POST /users/:id/roles matches the OpenAPI spec', async () => {
const response = await api.post('/users/user-1/roles', {
role: 'editor',
});
expect(response.status).toBe(204);
// Validate the request/response pair against the spec
const errors = validator.validate('POST', '/users/{userId}/roles', {
request: { body: { role: 'editor' } },
response: { status: 204 },
});
expect(errors).toHaveLength(0);
});
test('POST /users/:id/roles rejects invalid role', async () => {
const response = await api.post('/users/user-1/roles', {
role: 'superadmin', // not in the enum
});
expect(response.status).toBe(400);
});// Contract test — validates the implementation matches the spec
import { createValidator } from 'openapi-validator';
const validator = createValidator('./openapi.yaml');
test('POST /users/:id/roles matches the OpenAPI spec', async () => {
const response = await api.post('/users/user-1/roles', {
role: 'editor',
});
expect(response.status).toBe(204);
// Validate the request/response pair against the spec
const errors = validator.validate('POST', '/users/{userId}/roles', {
request: { body: { role: 'editor' } },
response: { status: 204 },
});
expect(errors).toHaveLength(0);
});
test('POST /users/:id/roles rejects invalid role', async () => {
const response = await api.post('/users/user-1/roles', {
role: 'superadmin', // not in the enum
});
expect(response.status).toBe(400);
});Pact takes a different approach — consumer-driven contracts:
// Consumer side (frontend)
const interaction = {
uponReceiving: 'a request to assign a role',
withRequest: {
method: 'POST',
path: '/users/user-1/roles',
body: { role: 'editor' },
},
willRespondWith: {
status: 204,
},
};
// Provider side (backend) — verifies it satisfies the consumer's contract
// pact-verifier reads the contract and replays requests against the real API// Consumer side (frontend)
const interaction = {
uponReceiving: 'a request to assign a role',
withRequest: {
method: 'POST',
path: '/users/user-1/roles',
body: { role: 'editor' },
},
willRespondWith: {
status: 204,
},
};
// Provider side (backend) — verifies it satisfies the consumer's contract
// pact-verifier reads the contract and replays requests against the real APIWhat They Catch
API specification tools excel at shape validation at boundaries:
Schema drift. If the API returns a field that's not in the spec, or omits a required field, the contract test fails. This is particularly powerful in microservice architectures where services evolve independently.
Breaking changes. Schema diff tools (like oasdiff for OpenAPI) detect breaking changes between API versions: removed endpoints, changed field types, narrowed enums. These can be integrated into CI to prevent accidental breakage.
Consumer-provider mismatches. Pact-style contract testing catches cases where the frontend expects a field that the backend doesn't provide — without requiring end-to-end integration tests.
Documentation accuracy. The OpenAPI spec IS the documentation. Tools like Swagger UI and Redoc generate interactive API docs from the spec. If the spec is validated by tests, the documentation is always accurate.
Type generation. Tools like openapi-typescript, NSwag, and openapi-generator generate client SDKs and server stubs from the spec. The generated types enforce the contract at compile time in consuming code.
Where They Don't Apply
This is the critical distinction: API specs define what the API looks like, not what the feature does.
Shape vs. Behavior
The OpenAPI spec says POST /users/{id}/roles accepts a RoleAssignment and returns 204. It doesn't say:
- Who can assign roles (authorization logic)
- When role changes take effect (immediately? after a sync?)
- What the user sees after the role change (UI feedback)
- Whether the role change is audited (compliance requirement)
- How concurrent role changes are handled (conflict resolution)
These are behavioral requirements — acceptance criteria that describe what the system should DO, not what it should LOOK LIKE. API specs can't express them.
Feature Scope vs. Endpoint Scope
A "User Roles" feature might have these ACs:
- Admin can assign roles to other users
- Non-admin cannot assign roles
- Role change takes effect immediately
- Role changes are logged in the audit trail
- Users see a confirmation after role assignment
AC #1 and #2 touch the API. AC #3 is a timing/consistency requirement. AC #4 is a backend/infrastructure concern. AC #5 is a frontend concern. The API spec covers 2 out of 5. The other 3 are invisible to contract testing.
Layer Coverage
| Testable Behavior | API Spec / Contract | Typed Specs |
|---|---|---|
| Endpoint exists and accepts correct schema | Yes | No (not its job) |
| Response matches documented schema | Yes | No (not its job) |
| Authorization rules are enforced | Partial (status codes) | Yes (AC: non-admin blocked) |
| Business logic is correct | No | Yes (AC: role change is immediate) |
| UI feedback is correct | No | Yes (AC: confirmation shown) |
| Accessibility of the feature | No | Yes (AC: keyboard navigable) |
| Visual regression | No | Yes (AC: theme variants match) |
| Performance under load | No | Yes (AC: response within 200ms) |
How Typed Specs Differ
| Dimension | API Spec / Contract Testing | Typed Specifications |
|---|---|---|
| Scope | API surface (endpoints, schemas) | Feature behavior (any testable AC) |
| What it specifies | Shape and structure | Expected outcomes and behaviors |
| Test types covered | Contract tests, schema validation | Unit, E2E, visual, a11y, perf — any |
| Typo detection | Runtime (schema mismatch) | Compile-time (keyof T) |
| Rename safety | Partial ($ref for schemas) |
Full (IDE refactor) |
| Completeness check | Schema diff (shape only) | Scanner checks all ACs |
| Build integration | Yes (contract test failure) | Yes (quality gate exit code) |
| Drift resistance | High (for API shape) | High (for feature behavior) |
The Complementary Insight
API specs and typed specs are not competing. They operate at different layers and cover different concerns:
┌─────────────────────────────────────────────────────┐
│ Feature: User Roles │
│ │
│ AC: Admin can assign roles │
│ ├── Contract test (API shape: POST /roles → 204) │
│ └── E2E test (behavior: role appears in UI) │
│ │
│ AC: Non-admin cannot assign roles │
│ ├── Contract test (API shape: POST /roles → 403) │
│ └── E2E test (behavior: button is disabled) │
│ │
│ AC: Role change takes effect immediately │
│ └── Integration test (no API spec coverage) │
│ │
│ AC: Role changes are audited │
│ └── Unit test (no API spec coverage) │
│ │
│ AC: User sees confirmation │
│ └── E2E test (no API spec coverage) │
│ │
│ @FeatureTest(UserRolesFeature) │
│ @Implements for each AC above │
└─────────────────────────────────────────────────────┘┌─────────────────────────────────────────────────────┐
│ Feature: User Roles │
│ │
│ AC: Admin can assign roles │
│ ├── Contract test (API shape: POST /roles → 204) │
│ └── E2E test (behavior: role appears in UI) │
│ │
│ AC: Non-admin cannot assign roles │
│ ├── Contract test (API shape: POST /roles → 403) │
│ └── E2E test (behavior: button is disabled) │
│ │
│ AC: Role change takes effect immediately │
│ └── Integration test (no API spec coverage) │
│ │
│ AC: Role changes are audited │
│ └── Unit test (no API spec coverage) │
│ │
│ AC: User sees confirmation │
│ └── E2E test (no API spec coverage) │
│ │
│ @FeatureTest(UserRolesFeature) │
│ @Implements for each AC above │
└─────────────────────────────────────────────────────┘The OpenAPI spec verifies the contract between client and server. Typed specs verify that the feature's acceptance criteria are tested — regardless of whether those tests are contract tests, E2E tests, unit tests, or accessibility audits.
The healthiest setup uses both:
- OpenAPI for API boundaries between services
- Typed specs for feature-level acceptance criteria that span multiple test types
- Contract tests (Pact) for consumer-provider alignment in microservices
One feature's ACs might include a contract test AND an E2E test AND a visual regression test. Typed specs link all three to the feature. OpenAPI validates only the contract test. They're complementary lenses on the same system.
When API Specs Are Sufficient
If your product IS an API — no frontend, no UI, no user-facing behavior beyond the API surface — then OpenAPI/AsyncAPI + contract testing may be all you need. The API spec IS the feature spec. Every AC is expressible as a schema constraint or status code.
But the moment you have a UI, accessibility requirements, performance criteria, or behavioral logic that isn't captured by "this endpoint returns this shape" — you need something more. Typed specs fill that layer.
Previous: Part V: Directory Conventions and Wiki Matrices Next: Part VII: C# Roslyn Source Generators — the same philosophy, different mechanics.