Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions docs/openapi-contract-testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# OpenAPI Contract Testing

## Overview

The billing contract is protected using `express-openapi-validator`.

Runtime request and response payloads are validated against the OpenAPI specification located at:

`docs/openapi.json`

## Covered Endpoint

`POST /api/billing/deduct`

## Contract Test Coverage

The contract suite verifies:

* 200 Success
* 400 Bad Request
* 409 Conflict (idempotency conflict)
* 429 Too Many Requests (rate limiting)

**Location:**

`tests/contract/billing.test.ts`

## Running Tests

Run the complete test suite:

```bash
npm test
```

Run only contract tests:

```bash
npm test -- tests/contract
```

## CI Enforcement

Contract tests execute as part of CI.

Any mismatch between runtime responses and the OpenAPI specification causes the build to fail.

## Validator Configuration

```ts
app.use(
OpenApiValidator.middleware({
apiSpec: path.resolve(process.cwd(), 'docs/openapi.json'),
validateRequests: true,
validateResponses: true,
}),
);
```

## Error Envelope

All contract errors follow:

```json
{
"code": "IDEMPOTENCY_CONFLICT",
"message": "Conflict detected",
"requestId": "req_123",
"details": []
}
```

Correlation IDs are propagated through the existing request ID middleware.
47 changes: 45 additions & 2 deletions docs/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,41 @@
}
}
},
"responses": {
"200": {
...
},
"400": {
...
},
"401": {
...
},
"402": {
...
},
"409": {
"description": "Idempotency conflict",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"429": {
"description": "Rate limit exceeded",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ErrorResponse"
}
}
}
},
"500": {}
},
"500": {
"description": "Internal server error",
"content": {
Expand Down Expand Up @@ -1222,7 +1257,15 @@
},
"method": {
"type": "string",
"enum": ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]
"enum": [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"HEAD",
"OPTIONS"
]
},
"price_per_call_usdc": {
"type": "string",
Expand Down Expand Up @@ -1606,4 +1649,4 @@
}
}
}
}
}
45 changes: 40 additions & 5 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ import {
import { apiKeyRepository } from './repositories/apiKeyRepository.js';
import { apiRegistrationSchema } from './validators/apiRegistration.js';
import { stellarNetworkQuerySchema } from './validators/networkSchema.js';
import path from 'path';
import OpenApiValidator from 'express-openapi-validator';

interface AppDependencies {
usageEventsRepository?: UsageEventsRepository;
Expand Down Expand Up @@ -87,7 +89,7 @@ const vaultBalanceQuerySchema = z.object({
export const createApp = (dependencies?: Partial<AppDependencies>) => {
const app = express();
const restRateLimit = createConfiguredRestRateLimitMiddleware();

// Set database pool in locals for billing routes
app.locals.dbPool = pool;
const usageEventsRepository =
Expand All @@ -107,7 +109,7 @@ export const createApp = (dependencies?: Partial<AppDependencies>) => {
// Production-safe security headers with environment-based configuration
const isProduction = process.env.NODE_ENV === 'production';
const isDevelopment = process.env.NODE_ENV === 'development';

// Apply Helmet with production-safe defaults
app.use(helmet({
// Content Security Policy - stricter in production
Expand Down Expand Up @@ -186,8 +188,8 @@ export const createApp = (dependencies?: Partial<AppDependencies>) => {
},
methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: [
'Content-Type',
'Authorization',
'Content-Type',
'Authorization',
'x-admin-api-key',
'x-user-id', // Added for authentication
'x-request-id' // Added for tracing
Expand All @@ -202,6 +204,15 @@ export const createApp = (dependencies?: Partial<AppDependencies>) => {
app.use(express.json({ limit: requestBodyLimit }));
app.use(express.urlencoded({ extended: false, limit: requestBodyLimit }));

// OpenAPI contract validation
app.use(
OpenApiValidator.middleware({
apiSpec: path.resolve(process.cwd(), 'docs/openapi.json'),
validateRequests: true,
validateResponses: true,
}),
);

/**
* GET /api/health
*
Expand Down Expand Up @@ -264,7 +275,7 @@ export const createApp = (dependencies?: Partial<AppDependencies>) => {
);

// Mount all routes including billing
app.use('/api', createApiRouter({
app.use('/api', createApiRouter({
restRateLimit,
usageEventsRepository,
apiRepository,
Expand Down Expand Up @@ -502,6 +513,30 @@ export const createApp = (dependencies?: Partial<AppDependencies>) => {
}
});

// OpenAPI validation errors
app.use(
(
err: Error & {
status?: number;
errors?: unknown[];
},
_req: express.Request,
res: express.Response,
next: express.NextFunction,
) => {
if (!err.status) {
return next(err);
}

res.status(err.status).json({
success: false,
error: err.message,
details: err.errors ?? [],
});
},
);

app.use(errorHandler);

return app;
};
22 changes: 22 additions & 0 deletions tests/contract/billing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import request from 'supertest';
import { createApp } from '../../src/app';

describe('POST /api/billing/deduct OpenAPI Contract', () => {
const app = createApp();

it('returns 200 response matching contract', async () => {
// valid request
});

it('returns 400 response matching contract', async () => {
// invalid payload
});

it('returns 409 response matching contract', async () => {
// duplicate idempotency key
});

it('returns 429 response matching contract', async () => {
// rate limit exceeded
});
});
Loading