diff --git a/PR_DESCRIPTION_USAGE_OPENAPI_650.md b/PR_DESCRIPTION_USAGE_OPENAPI_650.md new file mode 100644 index 00000000..10a13685 --- /dev/null +++ b/PR_DESCRIPTION_USAGE_OPENAPI_650.md @@ -0,0 +1,152 @@ +# OpenAPI Examples for Usage Endpoints + +## Summary + +Adds named OpenAPI 3.1 example payloads to every response status code for the three usage-related API paths: + +| Path | Method | Before | After | +|---|---|---|---| +| `/api/usage` | GET | Schema reference only | Named examples: `withEvents`, `withBuckets`, `empty`, `withCursorPagination` (200); `invalidDateRange`, `invalidGroupBy`, `invalidCursor` (400); `missingToken`, `expiredToken` (401); `internalError` (500) | +| `/api/usage/sse` | GET | No examples | Named examples: `connected`, `usageEvent` (200 text/event-stream); `missingToken` (401) | +| `/api/usage/by-endpoint` | GET | Single anonymous `example` on 200 only | Named examples: `topEndpoints`, `filteredByApi`, `empty` (200); `invalidDateRange`, `invalidLimit`, `invalidDate` (400); `missingToken` (401); `internalError` (500) | + +Also extends the `UsageResponse` schema with `pagination` and `requestId` fields that are already present in the live API response but were missing from the spec. + +This is part of the **GrantFox FWC26 campaign (Stellar Wave)** documentation work. + +--- + +## What Changed + +### `docs/openapi.json` + +#### `GET /api/usage` — full named examples added + +**200 — `withEvents`** — typical two-event response including `pagination` and `requestId` +**200 — `withBuckets`** — response with `stats.buckets` (used when `groupBy` is supplied) +**200 — `empty`** — zero-event response with zeroed stats +**200 — `withCursorPagination`** — cursor-paginated response showing `pagination.nextCursor` +**400 — `invalidDateRange`** — `from` after `to` +**400 — `invalidGroupBy`** — unrecognised `groupBy` enum value +**400 — `invalidCursor`** — malformed base64 cursor +**401 — `missingToken`** — no `Authorization` header +**401 — `expiredToken`** — JWT token expired (`TOKEN_EXPIRED`) +**500 — `internalError`** — generic internal server error + +#### `GET /api/usage/sse` — named examples added to both responses + +**200 (text/event-stream) — `connected`** — initial `event: connected` SSE frame +**200 (text/event-stream) — `usageEvent`** — `event: usage` SSE frame with full event payload +**401 — `missingToken`** — standardised error envelope + +#### `GET /api/usage/by-endpoint` — anonymous `example` replaced by named `examples` + +The pre-existing anonymous `example` on the 200 response was replaced by three named examples: + +**200 — `topEndpoints`** — two endpoints ranked descending by call count +**200 — `filteredByApi`** — single endpoint result when `apiId` filter is applied +**200 — `empty`** — empty `data` array for a period with no calls +**400 — `invalidDateRange`**, **`invalidLimit`**, **`invalidDate`** — each validator error +**401 — `missingToken`** — standardised error envelope +**500 — `internalError`** — standardised error envelope + +#### `UsageResponse` schema extended + +- Added `pagination` property (object; shape varies by pagination mode) +- Added `requestId` string property +- Added `description` annotations to all existing properties for clarity + +### `src/routes/usage.openapi.test.ts` (new) + +37-test OpenAPI contract suite covering all three paths, verifying: + +- Named examples exist for every documented status code +- `withEvents`: event objects have the five required fields with correct types +- `withBuckets`: `stats.buckets` is present and well-formed +- `empty`: `events` is `[]`, `totalCalls` is `0` +- `withCursorPagination`: `pagination.nextCursor` is a non-empty string +- All 200 examples include `pagination` and `requestId` +- All 4xx/5xx examples have `success: false` and typed `error.code` / `error.message` +- SSE examples are raw SSE-formatted strings matching `event: connected` / `event: usage` +- `by-endpoint` `topEndpoints` items are descending by call count +- `UsageResponse` schema defines `pagination` and `requestId` +- No stray nested `responses` keys inside status-code objects +- The spec file parses as valid JSON + +--- + +## Test Results + +``` +PASS src/routes/usage.openapi.test.ts + OpenAPI examples — GET /api/usage + 200 response + ✓ defines named examples for the 200 response + ✓ includes a "withEvents" example with valid shape + ✓ includes a "withBuckets" example containing stats.buckets + ✓ includes an "empty" example with empty events and zero stats + ✓ includes a "withCursorPagination" example with nextCursor in pagination + ✓ all 200 examples include a pagination object + ✓ all 200 examples include a requestId string + 400 response + ✓ defines named examples for the 400 response + ✓ includes an "invalidDateRange" example + ✓ includes an "invalidGroupBy" example + ✓ includes an "invalidCursor" example + ✓ all 400 examples have success=false + 401 response + ✓ defines named examples for the 401 response + ✓ includes a "missingToken" example with UNAUTHORIZED code + ✓ includes an "expiredToken" example with TOKEN_EXPIRED code + 500 response + ✓ defines named examples for the 500 response + ✓ includes an "internalError" example with INTERNAL_SERVER_ERROR code + OpenAPI examples — GET /api/usage/sse + 200 text/event-stream response + ✓ defines named examples for the SSE 200 response + ✓ includes a "connected" example that is an SSE-formatted string + ✓ includes a "usageEvent" example that is an SSE-formatted string with usage event data + 401 response + ✓ defines named examples for the SSE 401 response + ✓ includes a "missingToken" example with UNAUTHORIZED code + OpenAPI examples — GET /api/usage/by-endpoint + 200 response + ✓ defines named examples for the 200 response + ✓ includes a "topEndpoints" example with ranked endpoint data + ✓ includes a "filteredByApi" example + ✓ includes an "empty" example with no data + 400 response + ✓ defines named examples for the 400 response + ✓ includes "invalidDateRange", "invalidLimit", and "invalidDate" examples + ✓ all 400 examples have success=false with an error code + 401 response + ✓ defines named examples for the 401 response + ✓ includes a "missingToken" example + 500 response + ✓ defines named examples for the 500 response + ✓ includes an "internalError" example + OpenAPI spec integrity — usage-related schemas + ✓ UsageResponse schema includes a pagination field + ✓ UsageResponse schema includes a requestId field + ✓ all usage path responses are valid JSON (no stray "responses" nesting) + ✓ the spec file is valid JSON + +Tests: 37 passed, 37 total +``` + +--- + +## Checklist + +- [x] Named examples added to all usage endpoint responses (200/400/401/500) +- [x] SSE text/event-stream examples added for `/api/usage/sse` +- [x] Anonymous `example` on `/api/usage/by-endpoint` 200 upgraded to named `examples` +- [x] `UsageResponse` schema extended with `pagination` and `requestId` +- [x] 37 contract tests written and passing +- [x] `docs/openapi.json` is valid JSON (verified) +- [x] No production code changed — documentation and test only +- [x] No new dependencies + +--- + +Closes #650 diff --git a/docs/openapi.json b/docs/openapi.json index c50e5079..1872319e 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -534,6 +534,147 @@ "application/json": { "schema": { "$ref": "#/components/schemas/UsageResponse" + }, + "examples": { + "withEvents": { + "summary": "Typical response with two usage events", + "value": { + "events": [ + { + "id": "evt_01hx9r2k3m4n5p6q7r8s9t0u", + "apiId": "api_weather_v1", + "endpoint": "/v1/weather/current", + "occurredAt": "2026-07-01T14:32:00.000Z", + "revenue": "1000000" + }, + { + "id": "evt_01hx9r2k3m4n5p6q7r8s9t0v", + "apiId": "api_weather_v1", + "endpoint": "/v1/weather/forecast", + "occurredAt": "2026-07-01T14:45:00.000Z", + "revenue": "1000000" + } + ], + "stats": { + "totalCalls": 2, + "totalSpent": "2000000", + "breakdownByApi": [ + { + "apiId": "api_weather_v1", + "calls": 2, + "revenue": "2000000" + } + ] + }, + "period": { + "from": "2026-06-01T00:00:00.000Z", + "to": "2026-07-01T00:00:00.000Z" + }, + "pagination": { + "limit": 20, + "offset": 0, + "hasMore": false + }, + "requestId": "req-01hx9r2k3m4n5p6q7r8s9t0w" + } + }, + "withBuckets": { + "summary": "Response with daily call buckets (groupBy=day)", + "value": { + "events": [ + { + "id": "evt_01hx9r2k3m4n5p6q7r8s9t0x", + "apiId": "api_maps_v2", + "endpoint": "/v2/geocode", + "occurredAt": "2026-07-10T09:15:00.000Z", + "revenue": "500000" + } + ], + "stats": { + "totalCalls": 1, + "totalSpent": "500000", + "breakdownByApi": [ + { + "apiId": "api_maps_v2", + "calls": 1, + "revenue": "500000" + } + ], + "buckets": [ + { + "period": "2026-07-10", + "calls": 1, + "revenue": "500000" + } + ] + }, + "period": { + "from": "2026-07-10T00:00:00.000Z", + "to": "2026-07-11T00:00:00.000Z" + }, + "pagination": { + "limit": 20, + "offset": 0, + "hasMore": false + }, + "requestId": "req-01hx9r2k3m4n5p6q7r8s9t0y" + } + }, + "empty": { + "summary": "No events in the requested period", + "value": { + "events": [], + "stats": { + "totalCalls": 0, + "totalSpent": "0", + "breakdownByApi": [] + }, + "period": { + "from": "2026-06-01T00:00:00.000Z", + "to": "2026-07-01T00:00:00.000Z" + }, + "pagination": { + "limit": 20, + "offset": 0, + "hasMore": false + }, + "requestId": "req-01hx9r2k3m4n5p6q7r8s9t0z" + } + }, + "withCursorPagination": { + "summary": "Cursor-paginated response (more pages available)", + "value": { + "events": [ + { + "id": "evt_01hx9r2k3m4n5p6q7r8s9t1a", + "apiId": "api_translate_v1", + "endpoint": "/v1/translate", + "occurredAt": "2026-07-20T11:00:00.000Z", + "revenue": "250000" + } + ], + "stats": { + "totalCalls": 1, + "totalSpent": "250000", + "breakdownByApi": [ + { + "apiId": "api_translate_v1", + "calls": 1, + "revenue": "250000" + } + ] + }, + "period": { + "from": "2026-07-01T00:00:00.000Z", + "to": "2026-07-31T00:00:00.000Z" + }, + "pagination": { + "limit": 1, + "nextCursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wNy0yMFQxMTowMDowMC4wMDBaIiwiaWQiOiJldnRfMDFoeDlyMmszbTRuNXA2cTdyOHM5dDF" + }, + "requestId": "req-01hx9r2k3m4n5p6q7r8s9t1b" + } + } } } } @@ -544,6 +685,59 @@ "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "invalidDateRange": { + "summary": "from date is after to date", + "value": { + "success": false, + "error": { + "code": "VALIDATION_ERROR", + "message": "'from' date must be before or equal to 'to' date", + "details": [ + { + "field": "from", + "message": "'from' date must be before or equal to 'to' date", + "code": "custom" + } + ] + }, + "requestId": "req-01hx9r2k3m4n5p6q7r8s9t1c", + "timestamp": "2026-07-01T10:00:00.000Z" + } + }, + "invalidGroupBy": { + "summary": "Invalid groupBy value", + "value": { + "success": false, + "error": { + "code": "VALIDATION_ERROR", + "message": "Invalid enum value. Expected 'day' | 'week' | 'month'", + "details": [ + { + "field": "groupBy", + "message": "Invalid enum value. Expected 'day' | 'week' | 'month'", + "code": "invalid_enum_value" + } + ] + }, + "requestId": "req-01hx9r2k3m4n5p6q7r8s9t1d", + "timestamp": "2026-07-01T10:00:00.000Z" + } + }, + "invalidCursor": { + "summary": "Malformed cursor value", + "value": { + "success": false, + "error": { + "code": "BAD_REQUEST", + "message": "Invalid cursor format. Cursor must be base64 encoded (created_at, id).", + "details": [] + }, + "requestId": "req-01hx9r2k3m4n5p6q7r8s9t1e", + "timestamp": "2026-07-01T10:00:00.000Z" + } + } } } } @@ -554,6 +748,32 @@ "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "missingToken": { + "summary": "No authorization token provided", + "value": { + "success": false, + "error": { + "code": "UNAUTHORIZED", + "message": "Unauthorized" + }, + "requestId": "req-01hx9r2k3m4n5p6q7r8s9t1f", + "timestamp": "2026-07-01T10:00:00.000Z" + } + }, + "expiredToken": { + "summary": "JWT token has expired", + "value": { + "success": false, + "error": { + "code": "TOKEN_EXPIRED", + "message": "Token expired" + }, + "requestId": "req-01hx9r2k3m4n5p6q7r8s9t1g", + "timestamp": "2026-07-01T10:00:00.000Z" + } + } } } } @@ -564,6 +784,20 @@ "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "internalError": { + "summary": "Unexpected server error", + "value": { + "success": false, + "error": { + "code": "INTERNAL_SERVER_ERROR", + "message": "Internal server error" + }, + "requestId": "req-01hx9r2k3m4n5p6q7r8s9t1h", + "timestamp": "2026-07-01T10:00:00.000Z" + } + } } } } @@ -587,6 +821,16 @@ "text/event-stream": { "schema": { "type": "string" + }, + "examples": { + "connected": { + "summary": "Initial connected event sent on stream open", + "value": "event: connected\ndata: {\"status\":\"connected\"}\n\n" + }, + "usageEvent": { + "summary": "Live usage event pushed to the stream", + "value": "event: usage\ndata: {\"id\":\"evt_01hx9r2k3m4n5p6q7r8s9t2a\",\"apiId\":\"api_weather_v1\",\"endpoint\":\"/v1/weather/current\",\"occurredAt\":\"2026-07-01T14:32:00.000Z\",\"revenue\":\"1000000\"}\n\n" + } } } } @@ -597,6 +841,20 @@ "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "missingToken": { + "summary": "No authorization token provided", + "value": { + "success": false, + "error": { + "code": "UNAUTHORIZED", + "message": "Unauthorized" + }, + "requestId": "req-01hx9r2k3m4n5p6q7r8s9t2b", + "timestamp": "2026-07-01T10:00:00.000Z" + } + } } } } @@ -714,22 +972,53 @@ } } }, - "example": { - "data": [ - { - "endpoint": "/v1/weather/current", - "calls": 142, - "revenue": "142000" - }, - { - "endpoint": "/v1/weather/forecast", - "calls": 87, - "revenue": "87000" + "examples": { + "topEndpoints": { + "summary": "Two most-called endpoints in the period", + "value": { + "data": [ + { + "endpoint": "/v1/weather/current", + "calls": 142, + "revenue": "142000" + }, + { + "endpoint": "/v1/weather/forecast", + "calls": 87, + "revenue": "87000" + } + ], + "period": { + "from": "2026-06-01T00:00:00.000Z", + "to": "2026-07-01T00:00:00.000Z" + } + } + }, + "filteredByApi": { + "summary": "Top endpoints filtered to a single API", + "value": { + "data": [ + { + "endpoint": "/v2/geocode", + "calls": 310, + "revenue": "310000" + } + ], + "period": { + "from": "2026-07-01T00:00:00.000Z", + "to": "2026-07-27T00:00:00.000Z" + } + } + }, + "empty": { + "summary": "No calls in the requested period", + "value": { + "data": [], + "period": { + "from": "2026-01-01T00:00:00.000Z", + "to": "2026-01-02T00:00:00.000Z" + } } - ], - "period": { - "from": "2026-06-01T00:00:00.000Z", - "to": "2026-07-01T00:00:00.000Z" } } } @@ -741,6 +1030,44 @@ "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "invalidDateRange": { + "summary": "from is after to", + "value": { + "success": false, + "error": { + "code": "BAD_REQUEST", + "message": "from must be before or equal to to" + }, + "requestId": "req-01hx9r2k3m4n5p6q7r8s9t3a", + "timestamp": "2026-07-01T10:00:00.000Z" + } + }, + "invalidLimit": { + "summary": "Non-positive limit value", + "value": { + "success": false, + "error": { + "code": "BAD_REQUEST", + "message": "limit must be a positive integer" + }, + "requestId": "req-01hx9r2k3m4n5p6q7r8s9t3b", + "timestamp": "2026-07-01T10:00:00.000Z" + } + }, + "invalidDate": { + "summary": "Malformed date string in from/to parameter", + "value": { + "success": false, + "error": { + "code": "BAD_REQUEST", + "message": "Invalid \"from\" date" + }, + "requestId": "req-01hx9r2k3m4n5p6q7r8s9t3c", + "timestamp": "2026-07-01T10:00:00.000Z" + } + } } } } @@ -751,6 +1078,20 @@ "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "missingToken": { + "summary": "No authorization token provided", + "value": { + "success": false, + "error": { + "code": "UNAUTHORIZED", + "message": "Unauthorized" + }, + "requestId": "req-01hx9r2k3m4n5p6q7r8s9t3d", + "timestamp": "2026-07-01T10:00:00.000Z" + } + } } } } @@ -761,6 +1102,20 @@ "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" + }, + "examples": { + "internalError": { + "summary": "Unexpected server error", + "value": { + "success": false, + "error": { + "code": "INTERNAL_SERVER_ERROR", + "message": "Internal server error" + }, + "requestId": "req-01hx9r2k3m4n5p6q7r8s9t3e", + "timestamp": "2026-07-01T10:00:00.000Z" + } + } } } } @@ -3180,20 +3535,25 @@ ], "properties": { "id": { - "type": "string" + "type": "string", + "description": "Unique identifier for the usage event." }, "apiId": { - "type": "string" + "type": "string", + "description": "Identifier of the API that was called." }, "endpoint": { - "type": "string" + "type": "string", + "description": "API endpoint path that was invoked." }, "occurredAt": { "type": "string", - "format": "date-time" + "format": "date-time", + "description": "ISO 8601 timestamp when the call occurred." }, "revenue": { - "type": "string" + "type": "string", + "description": "Revenue generated by this event in smallest USDC units (string to avoid precision loss)." } } } @@ -3207,13 +3567,16 @@ ], "properties": { "totalCalls": { - "type": "integer" + "type": "integer", + "description": "Total number of API calls in the period." }, "totalSpent": { - "type": "string" + "type": "string", + "description": "Total spend in smallest USDC units (string to avoid precision loss)." }, "breakdownByApi": { "type": "array", + "description": "Per-API aggregated call count and revenue.", "items": { "type": "object", "required": [ @@ -3236,6 +3599,7 @@ }, "buckets": { "type": "array", + "description": "Time-bucketed aggregation (present when groupBy is supplied).", "items": { "type": "object", "required": [ @@ -3245,7 +3609,8 @@ ], "properties": { "period": { - "type": "string" + "type": "string", + "description": "Bucket label (e.g. YYYY-MM-DD for groupBy=day)." }, "calls": { "type": "integer" @@ -3264,6 +3629,7 @@ "from", "to" ], + "description": "The effective time range covered by this response.", "properties": { "from": { "type": "string", @@ -3274,6 +3640,32 @@ "format": "date-time" } } + }, + "pagination": { + "type": "object", + "description": "Pagination metadata. Shape varies by pagination mode: offset pagination includes limit/offset/hasMore; cursor pagination includes limit/nextCursor.", + "properties": { + "limit": { + "type": "integer", + "description": "Page size used for this request." + }, + "offset": { + "type": "integer", + "description": "Offset applied for this request (offset pagination only)." + }, + "hasMore": { + "type": "boolean", + "description": "Whether additional pages exist." + }, + "nextCursor": { + "type": "string", + "description": "Opaque base64 cursor to pass as the `cursor` query parameter for the next page (cursor pagination only)." + } + } + }, + "requestId": { + "type": "string", + "description": "Correlation ID for this request, propagated from the X-Request-Id header." } } }, diff --git a/src/routes/usage.openapi.test.ts b/src/routes/usage.openapi.test.ts new file mode 100644 index 00000000..1e718b03 --- /dev/null +++ b/src/routes/usage.openapi.test.ts @@ -0,0 +1,452 @@ +/** + * OpenAPI contract tests for usage endpoints. + * + * Verifies that docs/openapi.json contains well-formed, named examples for + * every usage-related path: + * + * GET /api/usage — 200 (withEvents, withBuckets, empty, withCursorPagination) + * 400 (invalidDateRange, invalidGroupBy, invalidCursor) + * 401 (missingToken, expiredToken) + * 500 (internalError) + * + * GET /api/usage/sse — 200 SSE event-stream (connected, usageEvent) + * 401 (missingToken) + * + * GET /api/usage/by-endpoint — 200 (topEndpoints, filteredByApi, empty) + * 400 (invalidDateRange, invalidLimit, invalidDate) + * 401 (missingToken) + * 500 (internalError) + * + * Also validates: + * - UsageResponse schema defines pagination and requestId fields + * - All example values contain the required keys/types + * - No accidental nesting or stray "responses" keys inside examples + * + * Closes #650 + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +type OpenApiSpec = Record; + +function loadSpec(): OpenApiSpec { + const specPath = path.join(process.cwd(), 'docs', 'openapi.json'); + return JSON.parse(fs.readFileSync(specPath, 'utf8')) as OpenApiSpec; +} + +interface NamedExample { + summary?: string; + value?: Record; +} + +function getExamples( + spec: OpenApiSpec, + apiPath: string, + method: string, + statusCode: string, + contentType = 'application/json', +): Record { + const paths = spec.paths as Record>; + const pathObj = paths[apiPath] as Record | undefined; + if (!pathObj) throw new Error(`Path not found in spec: ${apiPath}`); + const methodObj = pathObj[method] as Record | undefined; + if (!methodObj) throw new Error(`Method ${method} not found at ${apiPath}`); + const responses = methodObj.responses as Record>; + const response = responses[statusCode]; + if (!response) throw new Error(`Status ${statusCode} not found at ${method.toUpperCase()} ${apiPath}`); + const content = response.content as Record> | undefined; + if (!content) throw new Error(`No content at ${statusCode} of ${method.toUpperCase()} ${apiPath}`); + const mediaType = content[contentType]; + if (!mediaType) throw new Error(`Content-type ${contentType} not found at ${statusCode}`); + const examples = mediaType.examples as Record | undefined; + if (!examples) throw new Error(`No named examples at ${statusCode} of ${method.toUpperCase()} ${apiPath} [${contentType}]`); + return examples; +} + +// --------------------------------------------------------------------------- +// Test suites +// --------------------------------------------------------------------------- + +describe('OpenAPI examples — GET /api/usage', () => { + let spec: OpenApiSpec; + + beforeAll(() => { + spec = loadSpec(); + }); + + // ── 200 ────────────────────────────────────────────────────────────────── + + describe('200 response', () => { + it('defines named examples for the 200 response', () => { + const examples = getExamples(spec, '/api/usage', 'get', '200'); + expect(examples).toBeDefined(); + }); + + it('includes a "withEvents" example with valid shape', () => { + const examples = getExamples(spec, '/api/usage', 'get', '200'); + expect(examples.withEvents).toBeDefined(); + expect(examples.withEvents.summary).toBe('Typical response with two usage events'); + + const val = examples.withEvents.value!; + expect(Array.isArray(val.events)).toBe(true); + expect((val.events as unknown[]).length).toBeGreaterThan(0); + + const firstEvent = (val.events as Array>)[0]; + expect(typeof firstEvent.id).toBe('string'); + expect(typeof firstEvent.apiId).toBe('string'); + expect(typeof firstEvent.endpoint).toBe('string'); + expect(typeof firstEvent.occurredAt).toBe('string'); + expect(typeof firstEvent.revenue).toBe('string'); + + const stats = val.stats as Record; + expect(typeof stats.totalCalls).toBe('number'); + expect(typeof stats.totalSpent).toBe('string'); + expect(Array.isArray(stats.breakdownByApi)).toBe(true); + + const period = val.period as Record; + expect(typeof period.from).toBe('string'); + expect(typeof period.to).toBe('string'); + }); + + it('includes a "withBuckets" example containing stats.buckets', () => { + const examples = getExamples(spec, '/api/usage', 'get', '200'); + expect(examples.withBuckets).toBeDefined(); + + const val = examples.withBuckets.value!; + const stats = val.stats as Record; + expect(Array.isArray(stats.buckets)).toBe(true); + const bucket = (stats.buckets as Array>)[0]; + expect(typeof bucket.period).toBe('string'); + expect(typeof bucket.calls).toBe('number'); + expect(typeof bucket.revenue).toBe('string'); + }); + + it('includes an "empty" example with empty events and zero stats', () => { + const examples = getExamples(spec, '/api/usage', 'get', '200'); + expect(examples.empty).toBeDefined(); + + const val = examples.empty.value!; + expect(val.events).toEqual([]); + + const stats = val.stats as Record; + expect(stats.totalCalls).toBe(0); + expect(stats.totalSpent).toBe('0'); + expect(stats.breakdownByApi).toEqual([]); + }); + + it('includes a "withCursorPagination" example with nextCursor in pagination', () => { + const examples = getExamples(spec, '/api/usage', 'get', '200'); + expect(examples.withCursorPagination).toBeDefined(); + + const val = examples.withCursorPagination.value!; + const pagination = val.pagination as Record; + expect(typeof pagination.nextCursor).toBe('string'); + expect((pagination.nextCursor as string).length).toBeGreaterThan(0); + }); + + it('all 200 examples include a pagination object', () => { + const examples = getExamples(spec, '/api/usage', 'get', '200'); + for (const [name, ex] of Object.entries(examples)) { + const val = ex.value!; + expect(val.pagination).toBeDefined(), `pagination missing in example "${name}"`; + expect(typeof val.pagination).toBe('object'); + } + }); + + it('all 200 examples include a requestId string', () => { + const examples = getExamples(spec, '/api/usage', 'get', '200'); + for (const [name, ex] of Object.entries(examples)) { + expect(typeof ex.value!.requestId).toBe('string'), `requestId missing in example "${name}"`; + } + }); + }); + + // ── 400 ────────────────────────────────────────────────────────────────── + + describe('400 response', () => { + it('defines named examples for the 400 response', () => { + const examples = getExamples(spec, '/api/usage', 'get', '400'); + expect(examples).toBeDefined(); + }); + + it('includes an "invalidDateRange" example', () => { + const examples = getExamples(spec, '/api/usage', 'get', '400'); + expect(examples.invalidDateRange).toBeDefined(); + const val = examples.invalidDateRange.value!; + expect(val.success).toBe(false); + const error = val.error as Record; + expect(error.code).toMatch(/VALIDATION_ERROR|BAD_REQUEST/); + expect(typeof error.message).toBe('string'); + }); + + it('includes an "invalidGroupBy" example', () => { + const examples = getExamples(spec, '/api/usage', 'get', '400'); + expect(examples.invalidGroupBy).toBeDefined(); + const val = examples.invalidGroupBy.value!; + expect(val.success).toBe(false); + }); + + it('includes an "invalidCursor" example', () => { + const examples = getExamples(spec, '/api/usage', 'get', '400'); + expect(examples.invalidCursor).toBeDefined(); + const val = examples.invalidCursor.value!; + expect(val.success).toBe(false); + const error = val.error as Record; + expect(error.code).toBe('BAD_REQUEST'); + }); + + it('all 400 examples have success=false', () => { + const examples = getExamples(spec, '/api/usage', 'get', '400'); + for (const [name, ex] of Object.entries(examples)) { + expect(ex.value!.success).toBe(false), `success should be false in example "${name}"`; + } + }); + }); + + // ── 401 ────────────────────────────────────────────────────────────────── + + describe('401 response', () => { + it('defines named examples for the 401 response', () => { + const examples = getExamples(spec, '/api/usage', 'get', '401'); + expect(examples).toBeDefined(); + }); + + it('includes a "missingToken" example with UNAUTHORIZED code', () => { + const examples = getExamples(spec, '/api/usage', 'get', '401'); + expect(examples.missingToken).toBeDefined(); + const error = examples.missingToken.value!.error as Record; + expect(error.code).toBe('UNAUTHORIZED'); + }); + + it('includes an "expiredToken" example with TOKEN_EXPIRED code', () => { + const examples = getExamples(spec, '/api/usage', 'get', '401'); + expect(examples.expiredToken).toBeDefined(); + const error = examples.expiredToken.value!.error as Record; + expect(error.code).toBe('TOKEN_EXPIRED'); + }); + }); + + // ── 500 ────────────────────────────────────────────────────────────────── + + describe('500 response', () => { + it('defines named examples for the 500 response', () => { + const examples = getExamples(spec, '/api/usage', 'get', '500'); + expect(examples).toBeDefined(); + }); + + it('includes an "internalError" example with INTERNAL_SERVER_ERROR code', () => { + const examples = getExamples(spec, '/api/usage', 'get', '500'); + expect(examples.internalError).toBeDefined(); + const error = examples.internalError.value!.error as Record; + expect(error.code).toBe('INTERNAL_SERVER_ERROR'); + }); + }); +}); + +// --------------------------------------------------------------------------- + +describe('OpenAPI examples — GET /api/usage/sse', () => { + let spec: OpenApiSpec; + + beforeAll(() => { + spec = loadSpec(); + }); + + describe('200 text/event-stream response', () => { + it('defines named examples for the SSE 200 response', () => { + const examples = getExamples(spec, '/api/usage/sse', 'get', '200', 'text/event-stream'); + expect(examples).toBeDefined(); + }); + + it('includes a "connected" example that is an SSE-formatted string', () => { + const examples = getExamples(spec, '/api/usage/sse', 'get', '200', 'text/event-stream'); + expect(examples.connected).toBeDefined(); + expect(typeof examples.connected.value).toBe('string'); + expect((examples.connected.value as unknown as string)).toMatch(/event:\s*connected/); + }); + + it('includes a "usageEvent" example that is an SSE-formatted string with usage event data', () => { + const examples = getExamples(spec, '/api/usage/sse', 'get', '200', 'text/event-stream'); + expect(examples.usageEvent).toBeDefined(); + expect(typeof examples.usageEvent.value).toBe('string'); + const raw = examples.usageEvent.value as unknown as string; + expect(raw).toMatch(/event:\s*usage/); + expect(raw).toContain('"apiId"'); + expect(raw).toContain('"occurredAt"'); + }); + }); + + describe('401 response', () => { + it('defines named examples for the SSE 401 response', () => { + const examples = getExamples(spec, '/api/usage/sse', 'get', '401'); + expect(examples).toBeDefined(); + }); + + it('includes a "missingToken" example with UNAUTHORIZED code', () => { + const examples = getExamples(spec, '/api/usage/sse', 'get', '401'); + expect(examples.missingToken).toBeDefined(); + const error = examples.missingToken.value!.error as Record; + expect(error.code).toBe('UNAUTHORIZED'); + }); + }); +}); + +// --------------------------------------------------------------------------- + +describe('OpenAPI examples — GET /api/usage/by-endpoint', () => { + let spec: OpenApiSpec; + + beforeAll(() => { + spec = loadSpec(); + }); + + describe('200 response', () => { + it('defines named examples for the 200 response', () => { + const examples = getExamples(spec, '/api/usage/by-endpoint', 'get', '200'); + expect(examples).toBeDefined(); + }); + + it('includes a "topEndpoints" example with ranked endpoint data', () => { + const examples = getExamples(spec, '/api/usage/by-endpoint', 'get', '200'); + expect(examples.topEndpoints).toBeDefined(); + + const val = examples.topEndpoints.value!; + expect(Array.isArray(val.data)).toBe(true); + expect((val.data as unknown[]).length).toBeGreaterThan(0); + + const firstItem = (val.data as Array>)[0]; + expect(typeof firstItem.endpoint).toBe('string'); + expect(typeof firstItem.calls).toBe('number'); + expect(typeof firstItem.revenue).toBe('string'); + + // First item must have more calls than second (ranked descending) + if ((val.data as unknown[]).length > 1) { + const second = (val.data as Array>)[1]; + expect(firstItem.calls as number).toBeGreaterThanOrEqual(second.calls as number); + } + + const period = val.period as Record; + expect(typeof period.from).toBe('string'); + expect(typeof period.to).toBe('string'); + }); + + it('includes a "filteredByApi" example', () => { + const examples = getExamples(spec, '/api/usage/by-endpoint', 'get', '200'); + expect(examples.filteredByApi).toBeDefined(); + const val = examples.filteredByApi.value!; + expect(Array.isArray(val.data)).toBe(true); + }); + + it('includes an "empty" example with no data', () => { + const examples = getExamples(spec, '/api/usage/by-endpoint', 'get', '200'); + expect(examples.empty).toBeDefined(); + const val = examples.empty.value!; + expect(val.data).toEqual([]); + }); + }); + + describe('400 response', () => { + it('defines named examples for the 400 response', () => { + const examples = getExamples(spec, '/api/usage/by-endpoint', 'get', '400'); + expect(examples).toBeDefined(); + }); + + it('includes "invalidDateRange", "invalidLimit", and "invalidDate" examples', () => { + const examples = getExamples(spec, '/api/usage/by-endpoint', 'get', '400'); + expect(examples.invalidDateRange).toBeDefined(); + expect(examples.invalidLimit).toBeDefined(); + expect(examples.invalidDate).toBeDefined(); + }); + + it('all 400 examples have success=false with an error code', () => { + const examples = getExamples(spec, '/api/usage/by-endpoint', 'get', '400'); + for (const [name, ex] of Object.entries(examples)) { + expect(ex.value!.success).toBe(false), `success should be false in example "${name}"`; + const error = ex.value!.error as Record; + expect(typeof error.code).toBe('string'), `error.code missing in example "${name}"`; + expect(typeof error.message).toBe('string'), `error.message missing in example "${name}"`; + } + }); + }); + + describe('401 response', () => { + it('defines named examples for the 401 response', () => { + const examples = getExamples(spec, '/api/usage/by-endpoint', 'get', '401'); + expect(examples).toBeDefined(); + }); + + it('includes a "missingToken" example', () => { + const examples = getExamples(spec, '/api/usage/by-endpoint', 'get', '401'); + expect(examples.missingToken).toBeDefined(); + const error = examples.missingToken.value!.error as Record; + expect(error.code).toBe('UNAUTHORIZED'); + }); + }); + + describe('500 response', () => { + it('defines named examples for the 500 response', () => { + const examples = getExamples(spec, '/api/usage/by-endpoint', 'get', '500'); + expect(examples).toBeDefined(); + }); + + it('includes an "internalError" example', () => { + const examples = getExamples(spec, '/api/usage/by-endpoint', 'get', '500'); + expect(examples.internalError).toBeDefined(); + const error = examples.internalError.value!.error as Record; + expect(error.code).toBe('INTERNAL_SERVER_ERROR'); + }); + }); +}); + +// --------------------------------------------------------------------------- + +describe('OpenAPI spec integrity — usage-related schemas', () => { + let spec: OpenApiSpec; + + beforeAll(() => { + spec = loadSpec(); + }); + + it('UsageResponse schema includes a pagination field', () => { + const schemas = (spec.components as Record) + .schemas as Record>; + const usageResponse = schemas.UsageResponse; + expect(usageResponse).toBeDefined(); + const props = usageResponse.properties as Record; + expect(props.pagination).toBeDefined(); + }); + + it('UsageResponse schema includes a requestId field', () => { + const schemas = (spec.components as Record) + .schemas as Record>; + const usageResponse = schemas.UsageResponse; + const props = usageResponse.properties as Record; + expect(props.requestId).toBeDefined(); + const reqId = props.requestId as Record; + expect(reqId.type).toBe('string'); + }); + + it('all usage path responses are valid JSON (no stray "responses" nesting)', () => { + const paths = spec.paths as Record>; + const usagePaths = ['/api/usage', '/api/usage/sse', '/api/usage/by-endpoint']; + for (const apiPath of usagePaths) { + const pathObj = paths[apiPath] as Record; + const getMethod = pathObj.get as Record; + const responses = getMethod.responses as Record>; + for (const [code, response] of Object.entries(responses)) { + expect((response as Record).responses).toBeUndefined(), + `Stray "responses" key found inside status ${code} of GET ${apiPath}`; + } + } + }); + + it('the spec file is valid JSON', () => { + const specPath = path.join(process.cwd(), 'docs', 'openapi.json'); + expect(() => JSON.parse(fs.readFileSync(specPath, 'utf8'))).not.toThrow(); + }); +});