From 531417dd1248aa125b5c2f4403c440426ff3e016 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Fri, 31 Jul 2026 09:18:06 +0200 Subject: [PATCH 1/4] fix(openapi): inherit path-item level parameters into every operation A path item's shared `parameters` were never merged into the operations under it, so declaring a path parameter once - the idiomatic way to avoid repeating it per method - produced an operation with no parameters at all. The generated context interface came out empty, giving the caller no way to supply the value, and the URL kept the raw template: requests went out to `/pets/{petId}` with the literal placeholder in the path. It compiled cleanly and nothing warned, despite docs/inputs/openapi.md promising that unsupported constructs are "reported with a warning rather than dropped silently". Add `collectOperationParameters`, which merges path-item and operation parameters with the specification's override rule (an operation wins over an inherited parameter of the same `name` + `in`), and use it for parameters, headers and the Swagger 2.0 body parameter. While here, stop treating non-method path item keys as operations. All three processors iterated `Object.entries(pathItem)`, so `parameters`, `summary`, `servers` and `$ref` were each processed as if they were an HTTP method - harmless until path-item parameters started being read, and wrong regardless. `HTTP_METHODS` moves to the shared utils so the filter module and the processors agree on one list. Also warn when a non-JSON content type is dropped because a JSON one was picked alongside it. The existing warnings only fire when JSON is absent entirely, so `application/json` + `multipart/form-data` on one operation silently generated a JSON-only client. Co-Authored-By: Claude Opus 5 (1M context) --- src/codegen/inputs/openapi/filter.ts | 23 ++--- .../inputs/openapi/generators/headers.ts | 15 +++- .../inputs/openapi/generators/parameters.ts | 15 +++- .../inputs/openapi/generators/payloads.ts | 62 +++++++++++-- src/codegen/inputs/openapi/utils.ts | 75 ++++++++++++++++ .../codegen/inputs/openapi/parameters.spec.ts | 90 +++++++++++++++++++ 6 files changed, 253 insertions(+), 27 deletions(-) diff --git a/src/codegen/inputs/openapi/filter.ts b/src/codegen/inputs/openapi/filter.ts index 12542845..fadf8d97 100644 --- a/src/codegen/inputs/openapi/filter.ts +++ b/src/codegen/inputs/openapi/filter.ts @@ -6,7 +6,11 @@ import { normalizeFilter, collectExtensionValues } from '../../filter'; -import {chooseComponentModelNames, deriveOperationId} from './utils'; +import { + chooseComponentModelNames, + deriveOperationId, + HTTP_METHODS +} from './utils'; import {Logger} from '../../../LoggingInterface'; type OpenAPIDocument = @@ -16,23 +20,6 @@ type OpenAPIDocument = const MODELINA_INFERRED_NAME = 'x-modelgen-inferred-name'; -/** - * HTTP methods treated as operations on a path item. Explicit whitelist so - * non-method keys (`parameters`, `servers`, `summary`, `description`) are never - * mistaken for operations. `trace` is included for v3 tolerance; deleting a - * method key that is not present is a no-op. - */ -const HTTP_METHODS = [ - 'get', - 'post', - 'put', - 'patch', - 'delete', - 'options', - 'head', - 'trace' -]; - /** * Locate the component-schema map for the document version: `components.schemas` * for OpenAPI 3.x, `definitions` for Swagger/OpenAPI 2.0. diff --git a/src/codegen/inputs/openapi/generators/headers.ts b/src/codegen/inputs/openapi/generators/headers.ts index fdd6f65d..9f71ae12 100644 --- a/src/codegen/inputs/openapi/generators/headers.ts +++ b/src/codegen/inputs/openapi/generators/headers.ts @@ -5,7 +5,11 @@ import { defaultCodegenTypescriptModelinaOptions, pascalCase } from '../../../generators/typescript/utils'; -import {deriveOperationId} from '../utils'; +import { + collectOperationParameters, + deriveOperationId, + isHttpMethod +} from '../utils'; import { ConstrainedObjectModel, TS_DESCRIPTION_PRESET, @@ -81,12 +85,19 @@ function extractHeadersFromOperations( for (const [pathKey, pathItem] of Object.entries(paths)) { for (const [method, operation] of Object.entries(pathItem)) { + if (!isHttpMethod(method)) { + continue; + } + const operationObj = operation as | OpenAPIV3.OperationObject | OpenAPIV2.OperationObject | OpenAPIV3_1.OperationObject; // Collect header parameters from operation and path-level - const allParameters = operationObj.parameters ?? []; + const allParameters = collectOperationParameters({ + pathItem, + operation: operationObj + }); const headerParams = allParameters.filter((param: any) => { return param.in === 'header'; diff --git a/src/codegen/inputs/openapi/generators/parameters.ts b/src/codegen/inputs/openapi/generators/parameters.ts index 3e6b54fe..507612ad 100644 --- a/src/codegen/inputs/openapi/generators/parameters.ts +++ b/src/codegen/inputs/openapi/generators/parameters.ts @@ -6,7 +6,11 @@ import { pascalCase } from '../../../generators/typescript/utils'; import {ProcessedParameterSchemaData} from '../../asyncapi/generators/parameters'; -import {deriveOperationId} from '../utils'; +import { + collectOperationParameters, + deriveOperationId, + isHttpMethod +} from '../utils'; import {Logger} from '../../../../LoggingInterface'; import { ConstrainedObjectModel, @@ -68,13 +72,20 @@ export function processOpenAPIParameters( openapiDocument.paths ?? {} )) { for (const [method, operation] of Object.entries(pathItem)) { + if (!isHttpMethod(method)) { + continue; + } + const operationObj = operation as | OpenAPIV3.OperationObject | OpenAPIV2.OperationObject | OpenAPIV3_1.OperationObject; // Collect parameters from operation and path-level - const allParameters = operationObj.parameters ?? []; + const allParameters = collectOperationParameters({ + pathItem, + operation: operationObj + }); // Cookie parameters have no generated handling; warn (once per operation) // rather than dropping them silently. diff --git a/src/codegen/inputs/openapi/generators/payloads.ts b/src/codegen/inputs/openapi/generators/payloads.ts index fa2d8b01..958a4d5e 100644 --- a/src/codegen/inputs/openapi/generators/payloads.ts +++ b/src/codegen/inputs/openapi/generators/payloads.ts @@ -4,7 +4,11 @@ import {OpenAPIV2, OpenAPIV3, OpenAPIV3_1} from 'openapi-types'; import {ProcessedPayloadSchemaData} from '../../asyncapi/generators/payloads'; import {pascalCase} from '../../../generators/typescript/utils'; import {onlyUnique} from '../../../utils'; -import {deriveOperationId} from '../utils'; +import { + collectOperationParameters, + deriveOperationId, + isHttpMethod +} from '../utils'; import {Logger} from '../../../../LoggingInterface'; // Constants @@ -43,6 +47,36 @@ function pickJsonSchema(content: Record): any | null { return preferred[1].schema ?? null; } +/** + * Warn about content types that were dropped because a JSON one was picked + * alongside them. + * + * The "no JSON-compatible content type" warnings only fire when JSON is absent + * entirely, so an operation declaring both `application/json` and, say, + * `multipart/form-data` would otherwise generate a JSON-only client with no + * indication that the other variant existed. + */ +function warnDroppedContentTypes({ + content, + location +}: { + content: Record | undefined; + location: string; +}): void { + if (!content) { + return; + } + const dropped = Object.keys(content).filter( + (contentType) => !isJsonContentType(contentType) + ); + if (dropped.length === 0) { + return; + } + Logger.warn( + `${location} declares content type(s) [${dropped.join(', ')}] alongside a JSON one; only the JSON variant was generated` + ); +} + // Helper function to extract schema from OpenAPI 2.0 response function extractOpenAPI2ResponseSchema( response: OpenAPIV2.ResponseObject @@ -135,7 +169,11 @@ function extractPayloadsFromOperations( } for (const [method, operation] of Object.entries(pathItem)) { - if (!operation || typeof operation !== 'object') { + if ( + !operation || + typeof operation !== 'object' || + !isHttpMethod(method) + ) { continue; } @@ -172,11 +210,20 @@ function extractPayloadsFromOperations( Logger.warn( `OpenAPI operation '${method.toUpperCase()} ${pathKey}' request body has no JSON-compatible content type (found: ${Object.keys(requestBody.content).join(', ')}); no request payload was generated` ); + } else if (requestSchema) { + warnDroppedContentTypes({ + content: requestBody.content, + location: `OpenAPI operation '${method.toUpperCase()} ${pathKey}' request body` + }); } - } else if ('parameters' in operationObj && operationObj.parameters) { - // OpenAPI 2.0 style (body carried as a `in: 'body'` parameter) + } else { + // OpenAPI 2.0 style (body carried as a `in: 'body'` parameter, which + // may be inherited from the path item like any other parameter). requestSchema = extractOpenAPI2RequestSchema( - operationObj.parameters as OpenAPIV2.ParameterObject[] + collectOperationParameters({ + pathItem, + operation: operationObj + }) as OpenAPIV2.ParameterObject[] ); } @@ -226,6 +273,11 @@ function extractPayloadsFromOperations( Logger.warn( `OpenAPI operation '${method.toUpperCase()} ${pathKey}' response '${statusCode}' has no JSON-compatible content type (found: ${Object.keys(responseObj.content).join(', ')}); no response payload was generated` ); + } else if (responseSchema) { + warnDroppedContentTypes({ + content: responseObj.content, + location: `OpenAPI operation '${method.toUpperCase()} ${pathKey}' response '${statusCode}'` + }); } } diff --git a/src/codegen/inputs/openapi/utils.ts b/src/codegen/inputs/openapi/utils.ts index e906983d..81d564fb 100644 --- a/src/codegen/inputs/openapi/utils.ts +++ b/src/codegen/inputs/openapi/utils.ts @@ -86,6 +86,81 @@ export function chooseComponentModelNames( return chosen; } +/** + * HTTP methods treated as operations on a path item. Explicit whitelist so + * non-method keys (`parameters`, `servers`, `summary`, `description`, `$ref`) + * are never mistaken for operations. `trace` is included for v3 tolerance. + */ +export const HTTP_METHODS = [ + 'get', + 'post', + 'put', + 'patch', + 'delete', + 'options', + 'head', + 'trace' +]; + +/** + * Whether a path item key names an operation rather than path-item metadata. + */ +export function isHttpMethod(key: string): boolean { + return HTTP_METHODS.includes(key.toLowerCase()); +} + +/** + * Merge a path item's shared `parameters` with an operation's own, as required + * by the OpenAPI specification: parameters declared on the path item apply to + * every operation under it, and an operation may override one by redeclaring + * the same `name` + `in` pair. + * + * Without this, declaring a path parameter once on the path item - the + * idiomatic way to avoid repeating it on every method - yields an operation + * with no parameters at all, so `{petId}` is never substituted and the request + * goes out with the literal placeholder in its URL. + */ +export function collectOperationParameters({ + pathItem, + operation +}: { + pathItem: unknown; + operation: unknown; +}): any[] { + const pathLevel = readParameters(pathItem); + const operationLevel = readParameters(operation); + + if (pathLevel.length === 0) { + return operationLevel; + } + + // An operation-level parameter wins over the path-level one it shadows. + const overridden = new Set( + operationLevel.map((parameter: any) => parameterKey(parameter)) + ); + const inherited = pathLevel.filter( + (parameter: any) => !overridden.has(parameterKey(parameter)) + ); + + return [...inherited, ...operationLevel]; +} + +/** + * Identity of a parameter for override purposes. Per the specification a + * parameter is unique by the combination of `name` and `in`. + */ +function parameterKey(parameter: any): string { + return `${parameter?.in}:${parameter?.name}`; +} + +function readParameters(container: unknown): any[] { + if (!container || typeof container !== 'object') { + return []; + } + const parameters = (container as {parameters?: unknown}).parameters; + return Array.isArray(parameters) ? parameters : []; +} + /** * Derive the operation identifier used to correlate payloads, parameters, * headers and channel functions for a single OpenAPI operation. diff --git a/test/codegen/inputs/openapi/parameters.spec.ts b/test/codegen/inputs/openapi/parameters.spec.ts index cd58a5fb..f9409004 100644 --- a/test/codegen/inputs/openapi/parameters.spec.ts +++ b/test/codegen/inputs/openapi/parameters.spec.ts @@ -39,4 +39,94 @@ describe('OpenAPI parameter extraction', () => { expect(warned.toLowerCase()).toContain('cookie'); expect(warned).toContain('session'); }); + + it('inherits path-item level parameters into every operation under it', () => { + // Declaring a shared parameter once on the path item is the idiomatic way to + // avoid repeating it per method. Dropping it left the operation with no + // parameter model, so `{petId}` was never substituted and the request went + // out with the literal placeholder in its URL. + const document: OpenAPIV3.Document = { + openapi: '3.0.0', + info: {title: 'Pet API', version: '1.0.0'}, + paths: { + '/pets/{petId}': { + parameters: [ + {name: 'petId', in: 'path', required: true, schema: {type: 'string'}} + ], + get: { + operationId: 'getPet', + responses: {200: {description: 'OK'}} + }, + delete: { + operationId: 'deletePet', + responses: {204: {description: 'Deleted'}} + } + } + } + } as OpenAPIV3.Document; + + const {channelParameters} = processOpenAPIParameters(document); + + for (const operationId of ['getPet', 'deletePet']) { + // eslint-disable-next-line security/detect-object-injection + const parameters = channelParameters[operationId]; + expect(parameters).toBeDefined(); + expect(Object.keys(parameters.schema.properties)).toEqual(['petId']); + } + }); + + it('lets an operation override an inherited parameter of the same name and location', () => { + const document: OpenAPIV3.Document = { + openapi: '3.0.0', + info: {title: 'Pet API', version: '1.0.0'}, + paths: { + '/pets/{petId}': { + parameters: [ + {name: 'petId', in: 'path', required: true, schema: {type: 'string'}} + ], + get: { + operationId: 'getPet', + parameters: [ + { + name: 'petId', + in: 'path', + required: true, + schema: {type: 'integer'} + } + ], + responses: {200: {description: 'OK'}} + } + } + } + } as OpenAPIV3.Document; + + const {channelParameters} = processOpenAPIParameters(document); + const properties = channelParameters['getPet'].schema.properties; + + // Declared once, with the operation's own type winning. + expect(Object.keys(properties)).toEqual(['petId']); + expect(properties.petId.type).toEqual('integer'); + }); + + it('does not treat non-method path item keys as operations', () => { + const document: OpenAPIV3.Document = { + openapi: '3.0.0', + info: {title: 'Pet API', version: '1.0.0'}, + paths: { + '/pets/{petId}': { + summary: 'A pet', + description: 'Operations on one pet', + servers: [{url: 'https://pets.example'}], + parameters: [ + {name: 'petId', in: 'path', required: true, schema: {type: 'string'}} + ], + get: {operationId: 'getPet', responses: {200: {description: 'OK'}}} + } + } + } as OpenAPIV3.Document; + + const {channelParameters} = processOpenAPIParameters(document); + + expect(Object.keys(channelParameters)).toEqual(['getPet']); + }); }); From adfd61143362156f1d65cf6774c724b156e6dbc2 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Fri, 31 Jul 2026 09:18:06 +0200 Subject: [PATCH 2/4] fix(openapi): make the generated HTTP client compile and succeed on ordinary documents Three independent failures, each triggered by a shape that is entirely ordinary in an OpenAPI document. A component schema named `Error` - the conventional name for one - broke compilation. Its generated model is imported into `http_client.ts`, which shadows the global `Error` for the whole module, and the same file declares `class HttpError extends Error` plus several `new Error(...)` calls. Six TypeScript errors, none of them in code the user wrote. Renaming the schema and changing nothing else made the identical document compile. The emitted file now captures the global once, as `HttpGlobalError`, and routes every reference through it. An array-typed success response threw on the success path. `extractStatusCodeValue` read the status decoration only from a reference to an object model, so an array member was filtered out and the emitted dispatch had no branch for its code - a plain `200` from a list endpoint raised "No matching type found for status code: 200", while the schema constant in the same file proved the generator knew about it. The decoration is now read from the member itself for arrays, primitives and enums, which have no `unmarshal` of their own and so are parsed structurally. Responses with no body were mishandled twice. An operation whose declared responses are all bodyless - the common `DELETE` -> `204` - was dropped entirely, leaving no function and a diagnostic telling the user to set `protocols`, which they already had. One that mixed `204` with an error code was typed with the *error* model as its success type and threw `SyntaxError: Unexpected end of JSON input`, because `.json()` on an empty body throws. Bodyless success codes are now collected per operation, the function is always generated, `data` is typed `undefined` (or widened to include it), and the body is read through `readOptionalJsonBody`. Also read the base URL from a Swagger 2.0 `host`/`basePath`/`schemes`. Only `servers` was consulted, an OpenAPI 3.x field, so every 2.0 document silently fell through to `http://localhost:3000`. No tier could see any of this: test/blackbox/test_files.ts built its corpus from `schemas/asyncapi` and `schemas/jsonschema` only, so the harness's `inputType === 'openapi'` arm was dead code and the OpenAPI client was compile-checked at breadth nowhere. Add an OpenAPI corpus carrying all of these shapes, two configs, and an `openapi` CI bucket. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/blackbox-testing.yml | 4 +- .../src/generated/http_client.ts | 81 ++++--- .../generators/typescript/channels/openapi.ts | 102 ++++++++- .../channels/protocols/http/client.ts | 126 ++++++++--- .../channels/protocols/http/common-types.ts | 47 +++- .../channels/protocols/http/security.ts | 16 +- .../generators/typescript/channels/types.ts | 14 +- src/codegen/modelina/presets/union.ts | 42 ++-- .../configs/typescript/openapi-http.config.js | 31 +++ .../configs/typescript/openapi-sdk.config.js | 15 ++ .../schemas/openapi/awkward-responses.json | 115 ++++++++++ .../schemas/openapi/swagger-2-legacy.json | 45 ++++ test/blackbox/test_files.ts | 3 +- .../__snapshots__/channels.spec.ts.snap | 150 +++++++++---- .../generators/typescript/channels.spec.ts | 8 +- .../openapi-http-client-responses.spec.ts | 200 ++++++++++++++++++ .../channels/http_client.ts | 81 ++++--- .../openapi-primitive/channels/http_client.ts | 75 +++++-- .../channels/http_client.ts | 81 ++++--- .../src/openapi/channels/http_client.ts | 81 ++++--- .../src/request-reply/channels/http_client.ts | 123 +++++++---- 21 files changed, 1156 insertions(+), 284 deletions(-) create mode 100644 test/blackbox/configs/typescript/openapi-http.config.js create mode 100644 test/blackbox/configs/typescript/openapi-sdk.config.js create mode 100644 test/blackbox/schemas/openapi/awkward-responses.json create mode 100644 test/blackbox/schemas/openapi/swagger-2-legacy.json create mode 100644 test/codegen/generators/typescript/channels/openapi-http-client-responses.spec.ts diff --git a/.github/workflows/blackbox-testing.yml b/.github/workflows/blackbox-testing.yml index 22c0cbdd..cd66cdce 100644 --- a/.github/workflows/blackbox-testing.yml +++ b/.github/workflows/blackbox-testing.yml @@ -10,7 +10,7 @@ jobs: strategy: fail-fast: false matrix: - # The full 12-config x 23-file matrix is sharded into config buckets so + # The full config x file matrix is sharded into config buckets so # no single job runs the whole (npm-install-heavy) cross-product. Each # bucket lists config-name substrings (see test/blackbox/test_files.ts). include: @@ -22,6 +22,8 @@ jobs: configs: 'payload,modelina' - bucket: headers-params-jsonschema configs: 'headers,parameters,jsonschema' + - bucket: openapi + configs: 'openapi' steps: - name: Checkout repository uses: actions/checkout@v3 diff --git a/examples/openapi-http-client/src/generated/http_client.ts b/examples/openapi-http-client/src/generated/http_client.ts index 63ebf1a1..3181a8f0 100644 --- a/examples/openapi-http-client/src/generated/http_client.ts +++ b/examples/openapi-http-client/src/generated/http_client.ts @@ -15,6 +15,17 @@ import {PostV2ConnectHeaders, serializePostV2ConnectHeadersHeaders} from './head // Common Types - Shared across all HTTP client functions // ============================================================================ +/** + * The global `Error`, captured under a name a payload model cannot take. + * + * A document is free to declare a schema called `Error` (it is the + * conventional name for one), and its generated model is imported into this + * module, shadowing the global for the whole file. Every reference below goes + * through these aliases so that is harmless. + */ +const HttpGlobalError = globalThis.Error; +type HttpGlobalError = InstanceType; + /** * Standard HTTP response interface that wraps fetch-like responses */ @@ -49,7 +60,7 @@ export interface HttpClientResponse { * (when the error response had a JSON body). Thrown by `handleHttpError` and * routed through the `onError` hook / retry logic unchanged. */ -export class HttpError extends Error { +export class HttpError extends HttpGlobalError { status: number; statusText: string; body?: unknown; @@ -185,7 +196,7 @@ export interface RetryConfig { backoffMultiplier?: number; // Multiplier for exponential backoff (default: 2) retryableStatusCodes?: number[]; // Status codes to retry (default: [408, 429, 500, 502, 503, 504]) retryOnNetworkError?: boolean; // Retry on network errors (default: true) - onRetry?: (attempt: number, delay: number, error: Error) => void; // Callback on each retry + onRetry?: (attempt: number, delay: number, error: HttpGlobalError) => void; // Callback on each retry } // ============================================================================ @@ -217,7 +228,7 @@ export interface HttpHooks { /** * Called on request error for logging, error transformation, etc. */ - onError?: (error: Error, params: HttpRequestParams) => Error | Promise; + onError?: (error: HttpGlobalError, params: HttpRequestParams) => HttpGlobalError | Promise; } // ============================================================================ @@ -377,7 +388,7 @@ function calculateBackoffDelay( * Determine if a request should be retried based on error/response */ function shouldRetry( - error: Error | null, + error: HttpGlobalError | null, response: HttpResponse | null, config: Required, attempt: number @@ -400,14 +411,14 @@ async function executeWithRetry( retryConfig?: RetryConfig ): Promise { const config = { ...DEFAULT_RETRY_CONFIG, ...retryConfig }; - let lastError: Error | null = null; + let lastError: HttpGlobalError | null = null; let lastResponse: HttpResponse | null = null; for (let attempt = 0; attempt <= config.maxRetries; attempt++) { try { if (attempt > 0) { const delay = calculateBackoffDelay(attempt, config); - config.onRetry(attempt, delay, lastError ?? new Error('Retry attempt')); + config.onRetry(attempt, delay, lastError ?? new HttpGlobalError('Retry attempt')); await sleep(delay); } @@ -419,9 +430,9 @@ async function executeWithRetry( } lastResponse = response; - lastError = new Error(`HTTP Error: ${response.status} ${response.statusText}`); + lastError = new HttpGlobalError(`HTTP Error: ${response.status} ${response.statusText}`); } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); + lastError = error instanceof HttpGlobalError ? error : new HttpGlobalError(String(error)); if (!shouldRetry(lastError, null, config, attempt + 1)) { throw lastError; @@ -433,7 +444,7 @@ async function executeWithRetry( if (lastResponse) { return lastResponse; } - throw lastError ?? new Error('Request failed after retries'); + throw lastError ?? new HttpGlobalError('Request failed after retries'); } /** @@ -452,6 +463,24 @@ function handleHttpError(status: number, statusText: string, body?: unknown): ne } } +/** + * Read a JSON body only when the response actually carries one. + * + * `204 No Content`, `205 Reset Content` and `304 Not Modified` are defined to + * have no body, and an empty body makes `response.json()` throw - so a + * successful bodyless response would otherwise surface as a JSON parse error. + */ +async function readOptionalJsonBody(response: HttpResponse): Promise | undefined> { + if ([204, 205, 304].includes(response.status)) { + return undefined; + } + try { + return await response.json(); + } catch { + return undefined; + } +} + /** * Extract headers from response into a plain object */ @@ -520,15 +549,15 @@ function validateOAuth2Config(auth: OAuth2Auth): void { // If using a flow, validate required fields switch (auth.flow) { case 'client_credentials': - if (!auth.tokenUrl) throw new Error('OAuth2 Client Credentials flow requires tokenUrl'); - if (!auth.clientId) throw new Error('OAuth2 Client Credentials flow requires clientId'); + if (!auth.tokenUrl) throw new HttpGlobalError('OAuth2 Client Credentials flow requires tokenUrl'); + if (!auth.clientId) throw new HttpGlobalError('OAuth2 Client Credentials flow requires clientId'); break; case 'password': - if (!auth.tokenUrl) throw new Error('OAuth2 Password flow requires tokenUrl'); - if (!auth.clientId) throw new Error('OAuth2 Password flow requires clientId'); - if (!auth.username) throw new Error('OAuth2 Password flow requires username'); - if (!auth.password) throw new Error('OAuth2 Password flow requires password'); + if (!auth.tokenUrl) throw new HttpGlobalError('OAuth2 Password flow requires tokenUrl'); + if (!auth.clientId) throw new HttpGlobalError('OAuth2 Password flow requires clientId'); + if (!auth.username) throw new HttpGlobalError('OAuth2 Password flow requires username'); + if (!auth.password) throw new HttpGlobalError('OAuth2 Password flow requires password'); break; default: @@ -592,7 +621,7 @@ async function handleOAuth2TokenFlow( }); if (!tokenResponse.ok) { - throw new Error(`OAuth2 token request failed: ${tokenResponse.statusText}`); + throw new HttpGlobalError(`OAuth2 token request failed: ${tokenResponse.statusText}`); } const tokenData = await tokenResponse.json(); @@ -639,7 +668,7 @@ async function handleTokenRefresh( }); if (!refreshResponse.ok) { - throw new Error('Unauthorized'); + throw new HttpGlobalError('Unauthorized'); } const tokenData = await refreshResponse.json(); @@ -741,7 +770,7 @@ async function postV2Connect(context: PostV2ConnectContext): Promise + ['http', 'https'].includes(scheme.toLowerCase()) + ); + if (httpSchemes.length === 0) { + Logger.warn( + `Swagger 2.0 document declares host '${host}' but no http/https scheme; no default baseURL was generated` + ); + return []; + } + + const orderedSchemes = httpSchemes.includes('https') + ? ['https', ...httpSchemes.filter((scheme) => scheme !== 'https')] + : httpSchemes; + + // A basePath is required by 2.0 to start with `/`; tolerate one that does not. + let normalizedBasePath = basePath ?? ''; + if (normalizedBasePath === '/') { + normalizedBasePath = ''; + } else if (normalizedBasePath && !normalizedBasePath.startsWith('/')) { + normalizedBasePath = `/${normalizedBasePath}`; + } + + return orderedSchemes.map( + (scheme) => `'${scheme.toLowerCase()}://${host}${normalizedBasePath}'` + ); +} + function processOpenAPIOperations( openapiDocument: OpenAPIDocument, payloads: TypeScriptPayloadRenderType, @@ -347,8 +393,15 @@ function processOperation( includesStatusCodes: replyIncludesStatusCodes } = responseMessageInfo; - // Skip if no response type (nothing to generate) - if (!replyMessageType) { + // Status codes this operation declares without a response body. An operation + // whose responses are all bodyless (the common `DELETE` -> `204` shape) still + // deserves a client function - dropping it left the user with no function and + // a warning telling them to set `protocols`, which they already had. + const noContentStatusCodes = getNoContentStatusCodes(operation); + + // Skip only when the operation declares no responses at all, so there is + // genuinely nothing to call. + if (!replyMessageType && noContentStatusCodes.length === 0) { return undefined; } @@ -384,7 +437,8 @@ function processOperation( description, deprecated, oauth2Enabled, - hasSerializeHeaders: headersModel !== undefined + hasSerializeHeaders: headersModel !== undefined, + noContentStatusCodes }); // Grouping metadata for the `organization` option (consumed in @@ -397,6 +451,46 @@ function processOperation( return render; } +/** + * Collect the success status codes an operation declares without a response + * body. A response is bodyless when it declares no `content` (3.x) and no + * `schema` (2.0) - the shape of `204 No Content`, `205`, `304` and of `202 + * Accepted` responses that return nothing. + * + * Error codes are excluded: those are thrown as `HttpError` before the body is + * ever unmarshalled, so whether they carry a body does not affect the success + * type. + */ +function getNoContentStatusCodes(operation: OpenAPIOperation): number[] { + const responses = (operation as {responses?: Record}) + .responses; + if (!responses) { + return []; + } + + const codes: number[] = []; + for (const [statusCode, response] of Object.entries(responses)) { + const code = Number(statusCode); + if (isNaN(code) || code >= 400) { + continue; + } + if (!response || typeof response !== 'object') { + continue; + } + const {content, schema} = response as { + content?: Record; + schema?: unknown; + }; + const hasBody = + (content !== undefined && Object.keys(content).length > 0) || + schema !== undefined; + if (!hasBody) { + codes.push(code); + } + } + return codes; +} + /** * Validates the context is for OpenAPI input and has a parsed document. */ diff --git a/src/codegen/generators/typescript/channels/protocols/http/client.ts b/src/codegen/generators/typescript/channels/protocols/http/client.ts index f2515785..430d91f8 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/client.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/client.ts @@ -33,7 +33,8 @@ export function renderHttpFetchClient({ description, deprecated, oauth2Enabled = true, - hasSerializeHeaders = false + hasSerializeHeaders = false, + noContentStatusCodes = [] }: RenderHttpParameters): HttpRenderType { const messageType = requestMessageModule ? `${requestMessageModule}.${requestMessageType}` @@ -49,9 +50,15 @@ export function renderHttpFetchClient({ widenPayload && requestMessageType ? payloadUnionType({messageType: requestMessageType}) : messageType; - const replyType = replyMessageModule - ? `${replyMessageModule}.${replyMessageType}` - : replyMessageType; + // An operation whose every declared response is bodyless has no payload type + // at all; one that mixes bodyless and body-carrying responses may return + // either, so `data` widens to include `undefined`. + const hasNoContentResponses = noContentStatusCodes.length > 0; + const replyType = resolveReplyType({ + replyMessageType, + replyMessageModule, + hasNoContentResponses + }); // Generate context interface name const contextInterfaceName = `${pascalCase(functionName)}Context`; @@ -96,7 +103,8 @@ export function renderHttpFetchClient({ servers, includesStatusCodes, jsDoc, - oauth2Enabled + oauth2Enabled, + hasNoContentResponses }); const code = `${contextInterface} @@ -115,6 +123,80 @@ ${functionCode}`; }; } +/** + * Generate the statements that read the response body and unmarshal it. + * + * `unmarshal` receives the raw JSON text (`JSON.stringify(rawData)`) rather than + * the parsed object: object and array models accept either, but a primitive + * payload (e.g. `type X = string`) generates `unmarshal(json: string)` which + * JSON.parses its argument, so an already-parsed value would both fail to + * type-check and throw at runtime. + * + * A bodyless response (204/205/304, or simply an empty body) has no JSON to + * parse, and `.json()` throws on one - which would make a successful request + * look like a failure. So the body is read defensively whenever the operation + * declares any no-content response, and not read at all when it declares + * nothing but. + */ +function generateResponseParsing({ + replyMessageType, + replyMessageModule, + includesStatusCodes, + hasNoContentResponses +}: { + replyMessageType: string | undefined; + replyMessageModule: string | undefined; + includesStatusCodes: boolean; + hasNoContentResponses: boolean; +}): string { + if (!replyMessageType) { + return `// This operation declares no response body. + const rawData = await readOptionalJsonBody(response); + const responseData = undefined;`; + } + + let unmarshalExpression: string; + if (!replyMessageModule) { + unmarshalExpression = `${replyMessageType}.unmarshal(JSON.stringify(rawData))`; + } else if (includesStatusCodes) { + unmarshalExpression = `${replyMessageModule}.unmarshalByStatusCode(JSON.stringify(rawData), response.status)`; + } else { + unmarshalExpression = `${replyMessageModule}.unmarshal(JSON.stringify(rawData))`; + } + + if (hasNoContentResponses) { + return `const rawData = await readOptionalJsonBody(response); + const responseData = rawData === undefined ? undefined : ${unmarshalExpression};`; + } + return `const rawData = await response.json(); + const responseData = ${unmarshalExpression};`; +} + +/** + * The type carried in `HttpClientResponse<...>` for an operation. + * + * An operation whose every declared response is bodyless has no payload type at + * all; one that mixes bodyless and body-carrying responses may return either, so + * the type widens to include `undefined`. + */ +function resolveReplyType({ + replyMessageType, + replyMessageModule, + hasNoContentResponses +}: { + replyMessageType: string | undefined; + replyMessageModule: string | undefined; + hasNoContentResponses: boolean; +}): string { + if (!replyMessageType) { + return 'undefined'; + } + const payloadType = replyMessageModule + ? `${replyMessageModule}.${replyMessageType}` + : replyMessageType; + return hasNoContentResponses ? `${payloadType} | undefined` : payloadType; +} + /** * Generate the context interface for an HTTP operation */ @@ -185,7 +267,8 @@ function generateFunctionImplementation(params: { contextInterfaceName: string; replyType: string; replyMessageModule: string | undefined; - replyMessageType: string; + replyMessageType: string | undefined; + hasNoContentResponses: boolean; messageType: string | undefined; requestMessageType: string | undefined; requestMessageModule: string | undefined; @@ -220,7 +303,8 @@ function generateFunctionImplementation(params: { servers, includesStatusCodes, jsDoc, - oauth2Enabled + oauth2Enabled, + hasNoContentResponses } = params; const defaultServer = servers[0] ?? "'http://localhost:3000'"; @@ -263,21 +347,12 @@ function generateFunctionImplementation(params: { })}\n const body = payload?.marshal();` : `const body = undefined;`; - // Generate response parsing. - // Use unmarshalByStatusCode if the payload is a union type with status code support. - // unmarshal receives the raw JSON text (JSON.stringify(rawData)) rather than the - // parsed object: object/array models accept either, but primitive-typed payloads - // (e.g. `type X = string`) generate `unmarshal(json: string)` which JSON.parses its - // argument, so passing the already-parsed value would both fail to type-check and - // throw at runtime. - let responseParseCode: string; - if (replyMessageModule) { - responseParseCode = includesStatusCodes - ? `const responseData = ${replyMessageModule}.unmarshalByStatusCode(JSON.stringify(rawData), response.status);` - : `const responseData = ${replyMessageModule}.unmarshal(JSON.stringify(rawData));`; - } else { - responseParseCode = `const responseData = ${replyMessageType}.unmarshal(JSON.stringify(rawData));`; - } + const responseParseCode = generateResponseParsing({ + replyMessageType, + replyMessageModule, + includesStatusCodes, + hasNoContentResponses + }); // Generate default context for optional context parameter const contextDefault = !hasBody && !hasParameters ? ' = {}' : ''; @@ -312,7 +387,7 @@ function generateFunctionImplementation(params: { response = refreshResponse; } } catch { - throw new Error('Unauthorized'); + throw new HttpGlobalError('Unauthorized'); } } ` @@ -373,7 +448,6 @@ ${oauth2TokenBlock} } // Parse response - const rawData = await response.json(); ${responseParseCode} // Extract response metadata @@ -384,14 +458,14 @@ ${oauth2TokenBlock} status: response.status, statusText: response.statusText, headers: responseHeaders, - rawData, + rawData: rawData ?? {}, }; return result; } catch (error) { // Apply onError hook if present - if (config.hooks?.onError && error instanceof Error) { + if (config.hooks?.onError && error instanceof HttpGlobalError) { throw await config.hooks.onError(error, requestParams); } throw error; diff --git a/src/codegen/generators/typescript/channels/protocols/http/common-types.ts b/src/codegen/generators/typescript/channels/protocols/http/common-types.ts index c52b1acd..d6757273 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/common-types.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/common-types.ts @@ -154,6 +154,17 @@ const API_KEY_DEFAULTS = { // Common Types - Shared across all HTTP client functions // ============================================================================ +/** + * The global \`Error\`, captured under a name a payload model cannot take. + * + * A document is free to declare a schema called \`Error\` (it is the + * conventional name for one), and its generated model is imported into this + * module, shadowing the global for the whole file. Every reference below goes + * through these aliases so that is harmless. + */ +const HttpGlobalError = globalThis.Error; +type HttpGlobalError = InstanceType; + /** * Standard HTTP response interface that wraps fetch-like responses */ @@ -188,7 +199,7 @@ export interface HttpClientResponse { * (when the error response had a JSON body). Thrown by \`handleHttpError\` and * routed through the \`onError\` hook / retry logic unchanged. */ -export class HttpError extends Error { +export class HttpError extends HttpGlobalError { status: number; statusText: string; body?: unknown; @@ -238,7 +249,7 @@ export interface RetryConfig { backoffMultiplier?: number; // Multiplier for exponential backoff (default: 2) retryableStatusCodes?: number[]; // Status codes to retry (default: [408, 429, 500, 502, 503, 504]) retryOnNetworkError?: boolean; // Retry on network errors (default: true) - onRetry?: (attempt: number, delay: number, error: Error) => void; // Callback on each retry + onRetry?: (attempt: number, delay: number, error: HttpGlobalError) => void; // Callback on each retry } // ============================================================================ @@ -270,7 +281,7 @@ export interface HttpHooks { /** * Called on request error for logging, error transformation, etc. */ - onError?: (error: Error, params: HttpRequestParams) => Error | Promise; + onError?: (error: HttpGlobalError, params: HttpRequestParams) => HttpGlobalError | Promise; } // ============================================================================ @@ -398,7 +409,7 @@ function calculateBackoffDelay( * Determine if a request should be retried based on error/response */ function shouldRetry( - error: Error | null, + error: HttpGlobalError | null, response: HttpResponse | null, config: Required, attempt: number @@ -421,14 +432,14 @@ async function executeWithRetry( retryConfig?: RetryConfig ): Promise { const config = { ...DEFAULT_RETRY_CONFIG, ...retryConfig }; - let lastError: Error | null = null; + let lastError: HttpGlobalError | null = null; let lastResponse: HttpResponse | null = null; for (let attempt = 0; attempt <= config.maxRetries; attempt++) { try { if (attempt > 0) { const delay = calculateBackoffDelay(attempt, config); - config.onRetry(attempt, delay, lastError ?? new Error('Retry attempt')); + config.onRetry(attempt, delay, lastError ?? new HttpGlobalError('Retry attempt')); await sleep(delay); } @@ -440,9 +451,9 @@ async function executeWithRetry( } lastResponse = response; - lastError = new Error(\`HTTP Error: \${response.status} \${response.statusText}\`); + lastError = new HttpGlobalError(\`HTTP Error: \${response.status} \${response.statusText}\`); } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); + lastError = error instanceof HttpGlobalError ? error : new HttpGlobalError(String(error)); if (!shouldRetry(lastError, null, config, attempt + 1)) { throw lastError; @@ -454,7 +465,7 @@ async function executeWithRetry( if (lastResponse) { return lastResponse; } - throw lastError ?? new Error('Request failed after retries'); + throw lastError ?? new HttpGlobalError('Request failed after retries'); } /** @@ -466,6 +477,24 @@ function handleHttpError(status: number, statusText: string, body?: unknown): ne ${renderHandleHttpErrorBody(errorStatusCodes)} } +/** + * Read a JSON body only when the response actually carries one. + * + * \`204 No Content\`, \`205 Reset Content\` and \`304 Not Modified\` are defined to + * have no body, and an empty body makes \`response.json()\` throw - so a + * successful bodyless response would otherwise surface as a JSON parse error. + */ +async function readOptionalJsonBody(response: HttpResponse): Promise | undefined> { + if ([204, 205, 304].includes(response.status)) { + return undefined; + } + try { + return await response.json(); + } catch { + return undefined; + } +} + /** * Extract headers from response into a plain object */ diff --git a/src/codegen/generators/typescript/channels/protocols/http/security.ts b/src/codegen/generators/typescript/channels/protocols/http/security.ts index 140ae3fb..4334c18b 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/security.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/security.ts @@ -466,15 +466,15 @@ function validateOAuth2Config(auth: OAuth2Auth): void { // If using a flow, validate required fields switch (auth.flow) { case 'client_credentials': - if (!auth.tokenUrl) throw new Error('OAuth2 Client Credentials flow requires tokenUrl'); - if (!auth.clientId) throw new Error('OAuth2 Client Credentials flow requires clientId'); + if (!auth.tokenUrl) throw new HttpGlobalError('OAuth2 Client Credentials flow requires tokenUrl'); + if (!auth.clientId) throw new HttpGlobalError('OAuth2 Client Credentials flow requires clientId'); break; case 'password': - if (!auth.tokenUrl) throw new Error('OAuth2 Password flow requires tokenUrl'); - if (!auth.clientId) throw new Error('OAuth2 Password flow requires clientId'); - if (!auth.username) throw new Error('OAuth2 Password flow requires username'); - if (!auth.password) throw new Error('OAuth2 Password flow requires password'); + if (!auth.tokenUrl) throw new HttpGlobalError('OAuth2 Password flow requires tokenUrl'); + if (!auth.clientId) throw new HttpGlobalError('OAuth2 Password flow requires clientId'); + if (!auth.username) throw new HttpGlobalError('OAuth2 Password flow requires username'); + if (!auth.password) throw new HttpGlobalError('OAuth2 Password flow requires password'); break; default: @@ -538,7 +538,7 @@ async function handleOAuth2TokenFlow( }); if (!tokenResponse.ok) { - throw new Error(\`OAuth2 token request failed: \${tokenResponse.statusText}\`); + throw new HttpGlobalError(\`OAuth2 token request failed: \${tokenResponse.statusText}\`); } const tokenData = await tokenResponse.json(); @@ -585,7 +585,7 @@ async function handleTokenRefresh( }); if (!refreshResponse.ok) { - throw new Error('Unauthorized'); + throw new HttpGlobalError('Unauthorized'); } const tokenData = await refreshResponse.json(); diff --git a/src/codegen/generators/typescript/channels/types.ts b/src/codegen/generators/typescript/channels/types.ts index e5369055..e4125657 100644 --- a/src/codegen/generators/typescript/channels/types.ts +++ b/src/codegen/generators/typescript/channels/types.ts @@ -301,8 +301,20 @@ export interface RenderHttpParameters { requestMessageType?: string; servers?: string[]; requestMessageModule: string | undefined; - replyMessageType: string; + /** + * The response payload type, or `undefined` when the operation declares no + * response body at all (every declared response is bodyless, e.g. a `DELETE` + * whose only response is `204 No Content`). Such an operation still gets a + * client function; its `data` is typed `undefined`. + */ + replyMessageType?: string; replyMessageModule: string | undefined; + /** + * Status codes the operation declares without a response body. When any are + * present the response body is read defensively and `data` widens to include + * `undefined`, because calling `.json()` on a bodyless response throws. + */ + noContentStatusCodes?: number[]; channelParameters: ConstrainedObjectModel | undefined; channelHeaders?: ConstrainedObjectModel | undefined; subName?: string; diff --git a/src/codegen/modelina/presets/union.ts b/src/codegen/modelina/presets/union.ts index fba96709..d44b5e1f 100644 --- a/src/codegen/modelina/presets/union.ts +++ b/src/codegen/modelina/presets/union.ts @@ -154,21 +154,22 @@ function renderUnionUnmarshal( } /** - * Extract status code value from union member + * Extract status code value from union member. + * + * The decoration is read from the referenced model when the member is a + * reference to an object (the common case: a response body modelled as a + * class), and from the member itself otherwise. Non-object responses - an + * array of items, a bare string, an enum - carry the decoration directly, and + * skipping them used to emit a dispatch with no branch for that status code, so + * a perfectly ordinary `200` threw "No matching type found for status code". */ function extractStatusCodeValue( unionMember: ConstrainedMetaModel ): number | null { - if ( - !( - unionMember instanceof ConstrainedReferenceModel && - unionMember.ref instanceof ConstrainedObjectModel - ) - ) { - return null; - } - - const memberOriginalInput = unionMember.ref.originalInput; + const memberOriginalInput = + unionMember instanceof ConstrainedReferenceModel + ? unionMember.ref.originalInput + : unionMember.originalInput; const statusCode = memberOriginalInput?.['x-modelina-status-codes']; if (!statusCode) { @@ -186,6 +187,18 @@ function extractStatusCodeValue( return null; } +/** + * Whether a union member is a model that exposes its own static `unmarshal`. + * Only classes rendered from object models do; everything else has to be + * parsed structurally. + */ +function hasOwnUnmarshal(unionMember: ConstrainedMetaModel): boolean { + return ( + unionMember instanceof ConstrainedReferenceModel && + unionMember.ref instanceof ConstrainedObjectModel + ); +} + /** * Generate status code check string for a union member */ @@ -193,8 +206,13 @@ function generateStatusCodeCheck( unionMember: ConstrainedMetaModel, codeValue: number ): string { + const parseExpression = hasOwnUnmarshal(unionMember) + ? `${unionMember.type}.unmarshal(json)` + : // Arrays, primitives and enums have no unmarshal of their own; parse + // structurally, mirroring the union's generic `unmarshal`. + `JSON.parse(json) as ${unionMember.type}`; return ` if (statusCode === ${codeValue}) { - return ${unionMember.type}.unmarshal(json); + return ${parseExpression}; }`; } diff --git a/test/blackbox/configs/typescript/openapi-http.config.js b/test/blackbox/configs/typescript/openapi-http.config.js new file mode 100644 index 00000000..331769c8 --- /dev/null +++ b/test/blackbox/configs/typescript/openapi-http.config.js @@ -0,0 +1,31 @@ +// Exercises the OpenAPI -> HTTP client path: array-typed and primitive success +// responses, 204/202 responses with no body, a component schema named `Error` +// (which shadows the global in the generated module), path-item level shared +// parameters, and Swagger 2.0 host/basePath resolution. +/** @type {import("@the-codegen-project/cli").TheCodegenConfiguration} TheCodegenConfiguration **/ +export default { + inputType: 'openapi', + inputPath: 'openapi.json', + language: 'typescript', + generators: [ + { + preset: 'payloads', + outputPath: './payload', + serializationType: 'json' + }, + { + preset: 'parameters', + outputPath: './parameters', + serializationType: 'json' + }, + { + preset: 'headers', + outputPath: './headers' + }, + { + preset: 'channels', + outputPath: './', + protocols: ['http_client'] + } + ] +}; diff --git a/test/blackbox/configs/typescript/openapi-sdk.config.js b/test/blackbox/configs/typescript/openapi-sdk.config.js new file mode 100644 index 00000000..48dd610a --- /dev/null +++ b/test/blackbox/configs/typescript/openapi-sdk.config.js @@ -0,0 +1,15 @@ +// Exercises the `client` preset over OpenAPI: one API class per document, with +// the channels/payloads/parameters/headers generators auto-included. +/** @type {import("@the-codegen-project/cli").TheCodegenConfiguration} TheCodegenConfiguration **/ +export default { + inputType: 'openapi', + inputPath: 'openapi.json', + language: 'typescript', + generators: [ + { + preset: 'client', + outputPath: './client', + protocols: ['http'] + } + ] +}; diff --git a/test/blackbox/schemas/openapi/awkward-responses.json b/test/blackbox/schemas/openapi/awkward-responses.json new file mode 100644 index 00000000..1828a852 --- /dev/null +++ b/test/blackbox/schemas/openapi/awkward-responses.json @@ -0,0 +1,115 @@ +{ + "openapi": "3.0.3", + "info": {"title": "Awkward Responses API", "version": "1.0.0"}, + "servers": [{"url": "https://api.awkward.example/v1"}], + "components": { + "securitySchemes": {"bearerAuth": {"type": "http", "scheme": "bearer"}}, + "schemas": { + "Book": { + "type": "object", + "required": ["id", "title"], + "properties": { + "id": {"type": "string"}, + "title": {"type": "string"}, + "tags": {"type": "array", "items": {"type": "string"}} + } + }, + "Error": { + "type": "object", + "required": ["code", "message"], + "properties": {"code": {"type": "string"}, "message": {"type": "string"}} + } + } + }, + "security": [{"bearerAuth": []}], + "paths": { + "/books": { + "get": { + "operationId": "listBooks", + "summary": "An array-typed success response alongside an error code", + "parameters": [ + {"name": "limit", "in": "query", "schema": {"type": "integer"}}, + {"name": "X-Request-Id", "in": "header", "required": true, "schema": {"type": "string"}} + ], + "responses": { + "200": { + "description": "OK", + "content": {"application/json": {"schema": {"type": "array", "items": {"$ref": "#/components/schemas/Book"}}}} + }, + "400": { + "description": "Bad request", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + } + }, + "post": { + "operationId": "createBook", + "requestBody": { + "required": true, + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Book"}}} + }, + "responses": { + "201": { + "description": "Created", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Book"}}} + }, + "409": { + "description": "Conflict", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + } + } + }, + "/books/{bookId}": { + "parameters": [ + {"name": "bookId", "in": "path", "required": true, "schema": {"type": "string"}} + ], + "get": { + "operationId": "getBook", + "responses": { + "200": { + "description": "OK", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Book"}}} + }, + "404": { + "description": "Not found", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + } + }, + "delete": { + "operationId": "deleteBook", + "summary": "No content on success, error body on failure", + "responses": { + "204": {"description": "Deleted"}, + "404": { + "description": "Not found", + "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}} + } + } + } + }, + "/books/{bookId}/archive": { + "parameters": [ + {"name": "bookId", "in": "path", "required": true, "schema": {"type": "string"}} + ], + "post": { + "operationId": "archiveBook", + "summary": "Every declared response is bodyless", + "responses": { + "202": {"description": "Accepted"}, + "204": {"description": "Already archived"} + } + } + }, + "/health": { + "get": { + "operationId": "health", + "summary": "A primitive success response", + "responses": { + "200": {"description": "OK", "content": {"application/json": {"schema": {"type": "string"}}}} + } + } + } + } +} diff --git a/test/blackbox/schemas/openapi/swagger-2-legacy.json b/test/blackbox/schemas/openapi/swagger-2-legacy.json new file mode 100644 index 00000000..0cb46d8b --- /dev/null +++ b/test/blackbox/schemas/openapi/swagger-2-legacy.json @@ -0,0 +1,45 @@ +{ + "swagger": "2.0", + "info": {"title": "Legacy Store API", "version": "1.0.0"}, + "host": "api.legacy.example", + "basePath": "/v1", + "schemes": ["https"], + "definitions": { + "Item": { + "type": "object", + "required": ["id"], + "properties": {"id": {"type": "string"}, "name": {"type": "string"}} + }, + "Error": { + "type": "object", + "properties": {"code": {"type": "string"}, "message": {"type": "string"}} + } + }, + "paths": { + "/items/{itemId}": { + "parameters": [ + {"name": "itemId", "in": "path", "required": true, "type": "string"} + ], + "get": { + "operationId": "getItem", + "responses": { + "200": {"description": "OK", "schema": {"$ref": "#/definitions/Item"}}, + "404": {"description": "Missing", "schema": {"$ref": "#/definitions/Error"}} + } + }, + "delete": { + "operationId": "deleteItem", + "responses": {"204": {"description": "Deleted"}} + } + }, + "/items": { + "get": { + "operationId": "listItems", + "parameters": [{"name": "q", "in": "query", "type": "string"}], + "responses": { + "200": {"description": "OK", "schema": {"type": "array", "items": {"$ref": "#/definitions/Item"}}} + } + } + } + } +} diff --git a/test/blackbox/test_files.ts b/test/blackbox/test_files.ts index 1fe6d93b..551e6894 100644 --- a/test/blackbox/test_files.ts +++ b/test/blackbox/test_files.ts @@ -56,7 +56,8 @@ function readConfigInputType(configPath: string): string { export const filesToTest = [ ...readFilesInFolder('./schemas/asyncapi', 'asyncapi'), - ...readFilesInFolder('./schemas/jsonschema', 'jsonschema') + ...readFilesInFolder('./schemas/jsonschema', 'jsonschema'), + ...readFilesInFolder('./schemas/openapi', 'openapi') ].filter((value) => { if (TEST_SPECIFIC_FILE !== '') return value.file.includes(TEST_SPECIFIC_FILE); return true; diff --git a/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap b/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap index a17cb1cc..ec179b25 100644 --- a/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap +++ b/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap @@ -17,6 +17,17 @@ import {FindPetsByStatusAndCategoryHeaders, serializeFindPetsByStatusAndCategory // Common Types - Shared across all HTTP client functions // ============================================================================ +/** + * The global \`Error\`, captured under a name a payload model cannot take. + * + * A document is free to declare a schema called \`Error\` (it is the + * conventional name for one), and its generated model is imported into this + * module, shadowing the global for the whole file. Every reference below goes + * through these aliases so that is harmless. + */ +const HttpGlobalError = globalThis.Error; +type HttpGlobalError = InstanceType; + /** * Standard HTTP response interface that wraps fetch-like responses */ @@ -51,7 +62,7 @@ export interface HttpClientResponse { * (when the error response had a JSON body). Thrown by \`handleHttpError\` and * routed through the \`onError\` hook / retry logic unchanged. */ -export class HttpError extends Error { +export class HttpError extends HttpGlobalError { status: number; statusText: string; body?: unknown; @@ -171,7 +182,7 @@ export interface RetryConfig { backoffMultiplier?: number; // Multiplier for exponential backoff (default: 2) retryableStatusCodes?: number[]; // Status codes to retry (default: [408, 429, 500, 502, 503, 504]) retryOnNetworkError?: boolean; // Retry on network errors (default: true) - onRetry?: (attempt: number, delay: number, error: Error) => void; // Callback on each retry + onRetry?: (attempt: number, delay: number, error: HttpGlobalError) => void; // Callback on each retry } // ============================================================================ @@ -203,7 +214,7 @@ export interface HttpHooks { /** * Called on request error for logging, error transformation, etc. */ - onError?: (error: Error, params: HttpRequestParams) => Error | Promise; + onError?: (error: HttpGlobalError, params: HttpRequestParams) => HttpGlobalError | Promise; } // ============================================================================ @@ -353,7 +364,7 @@ function calculateBackoffDelay( * Determine if a request should be retried based on error/response */ function shouldRetry( - error: Error | null, + error: HttpGlobalError | null, response: HttpResponse | null, config: Required, attempt: number @@ -376,14 +387,14 @@ async function executeWithRetry( retryConfig?: RetryConfig ): Promise { const config = { ...DEFAULT_RETRY_CONFIG, ...retryConfig }; - let lastError: Error | null = null; + let lastError: HttpGlobalError | null = null; let lastResponse: HttpResponse | null = null; for (let attempt = 0; attempt <= config.maxRetries; attempt++) { try { if (attempt > 0) { const delay = calculateBackoffDelay(attempt, config); - config.onRetry(attempt, delay, lastError ?? new Error('Retry attempt')); + config.onRetry(attempt, delay, lastError ?? new HttpGlobalError('Retry attempt')); await sleep(delay); } @@ -395,9 +406,9 @@ async function executeWithRetry( } lastResponse = response; - lastError = new Error(\`HTTP Error: \${response.status} \${response.statusText}\`); + lastError = new HttpGlobalError(\`HTTP Error: \${response.status} \${response.statusText}\`); } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); + lastError = error instanceof HttpGlobalError ? error : new HttpGlobalError(String(error)); if (!shouldRetry(lastError, null, config, attempt + 1)) { throw lastError; @@ -409,7 +420,7 @@ async function executeWithRetry( if (lastResponse) { return lastResponse; } - throw lastError ?? new Error('Request failed after retries'); + throw lastError ?? new HttpGlobalError('Request failed after retries'); } /** @@ -430,6 +441,24 @@ function handleHttpError(status: number, statusText: string, body?: unknown): ne } } +/** + * Read a JSON body only when the response actually carries one. + * + * \`204 No Content\`, \`205 Reset Content\` and \`304 Not Modified\` are defined to + * have no body, and an empty body makes \`response.json()\` throw - so a + * successful bodyless response would otherwise surface as a JSON parse error. + */ +async function readOptionalJsonBody(response: HttpResponse): Promise | undefined> { + if ([204, 205, 304].includes(response.status)) { + return undefined; + } + try { + return await response.json(); + } catch { + return undefined; + } +} + /** * Extract headers from response into a plain object */ @@ -498,15 +527,15 @@ function validateOAuth2Config(auth: OAuth2Auth): void { // If using a flow, validate required fields switch (auth.flow) { case 'client_credentials': - if (!auth.tokenUrl) throw new Error('OAuth2 Client Credentials flow requires tokenUrl'); - if (!auth.clientId) throw new Error('OAuth2 Client Credentials flow requires clientId'); + if (!auth.tokenUrl) throw new HttpGlobalError('OAuth2 Client Credentials flow requires tokenUrl'); + if (!auth.clientId) throw new HttpGlobalError('OAuth2 Client Credentials flow requires clientId'); break; case 'password': - if (!auth.tokenUrl) throw new Error('OAuth2 Password flow requires tokenUrl'); - if (!auth.clientId) throw new Error('OAuth2 Password flow requires clientId'); - if (!auth.username) throw new Error('OAuth2 Password flow requires username'); - if (!auth.password) throw new Error('OAuth2 Password flow requires password'); + if (!auth.tokenUrl) throw new HttpGlobalError('OAuth2 Password flow requires tokenUrl'); + if (!auth.clientId) throw new HttpGlobalError('OAuth2 Password flow requires clientId'); + if (!auth.username) throw new HttpGlobalError('OAuth2 Password flow requires username'); + if (!auth.password) throw new HttpGlobalError('OAuth2 Password flow requires password'); break; default: @@ -570,7 +599,7 @@ async function handleOAuth2TokenFlow( }); if (!tokenResponse.ok) { - throw new Error(\`OAuth2 token request failed: \${tokenResponse.statusText}\`); + throw new HttpGlobalError(\`OAuth2 token request failed: \${tokenResponse.statusText}\`); } const tokenData = await tokenResponse.json(); @@ -617,7 +646,7 @@ async function handleTokenRefresh( }); if (!refreshResponse.ok) { - throw new Error('Unauthorized'); + throw new HttpGlobalError('Unauthorized'); } const tokenData = await refreshResponse.json(); @@ -718,7 +747,7 @@ async function addPet(context: AddPetContext): Promise> response = refreshResponse; } } catch { - throw new Error('Unauthorized'); + throw new HttpGlobalError('Unauthorized'); } } @@ -740,14 +769,14 @@ async function addPet(context: AddPetContext): Promise> status: response.status, statusText: response.statusText, headers: responseHeaders, - rawData, + rawData: rawData ?? {}, }; return result; } catch (error) { // Apply onError hook if present - if (config.hooks?.onError && error instanceof Error) { + if (config.hooks?.onError && error instanceof HttpGlobalError) { throw await config.hooks.onError(error, requestParams); } throw error; @@ -830,7 +859,7 @@ async function updatePet(context: UpdatePetContext): Promise; + /** * Standard HTTP response interface that wraps fetch-like responses */ @@ -1611,7 +1651,7 @@ export interface HttpClientResponse { * (when the error response had a JSON body). Thrown by \`handleHttpError\` and * routed through the \`onError\` hook / retry logic unchanged. */ -export class HttpError extends Error { +export class HttpError extends HttpGlobalError { status: number; statusText: string; body?: unknown; @@ -1747,7 +1787,7 @@ export interface RetryConfig { backoffMultiplier?: number; // Multiplier for exponential backoff (default: 2) retryableStatusCodes?: number[]; // Status codes to retry (default: [408, 429, 500, 502, 503, 504]) retryOnNetworkError?: boolean; // Retry on network errors (default: true) - onRetry?: (attempt: number, delay: number, error: Error) => void; // Callback on each retry + onRetry?: (attempt: number, delay: number, error: HttpGlobalError) => void; // Callback on each retry } // ============================================================================ @@ -1779,7 +1819,7 @@ export interface HttpHooks { /** * Called on request error for logging, error transformation, etc. */ - onError?: (error: Error, params: HttpRequestParams) => Error | Promise; + onError?: (error: HttpGlobalError, params: HttpRequestParams) => HttpGlobalError | Promise; } // ============================================================================ @@ -1939,7 +1979,7 @@ function calculateBackoffDelay( * Determine if a request should be retried based on error/response */ function shouldRetry( - error: Error | null, + error: HttpGlobalError | null, response: HttpResponse | null, config: Required, attempt: number @@ -1962,14 +2002,14 @@ async function executeWithRetry( retryConfig?: RetryConfig ): Promise { const config = { ...DEFAULT_RETRY_CONFIG, ...retryConfig }; - let lastError: Error | null = null; + let lastError: HttpGlobalError | null = null; let lastResponse: HttpResponse | null = null; for (let attempt = 0; attempt <= config.maxRetries; attempt++) { try { if (attempt > 0) { const delay = calculateBackoffDelay(attempt, config); - config.onRetry(attempt, delay, lastError ?? new Error('Retry attempt')); + config.onRetry(attempt, delay, lastError ?? new HttpGlobalError('Retry attempt')); await sleep(delay); } @@ -1981,9 +2021,9 @@ async function executeWithRetry( } lastResponse = response; - lastError = new Error(\`HTTP Error: \${response.status} \${response.statusText}\`); + lastError = new HttpGlobalError(\`HTTP Error: \${response.status} \${response.statusText}\`); } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); + lastError = error instanceof HttpGlobalError ? error : new HttpGlobalError(String(error)); if (!shouldRetry(lastError, null, config, attempt + 1)) { throw lastError; @@ -1995,7 +2035,7 @@ async function executeWithRetry( if (lastResponse) { return lastResponse; } - throw lastError ?? new Error('Request failed after retries'); + throw lastError ?? new HttpGlobalError('Request failed after retries'); } /** @@ -2007,6 +2047,24 @@ function handleHttpError(status: number, statusText: string, body?: unknown): ne throw new HttpError(\`HTTP Error: \${status} \${statusText}\`, status, statusText, body); } +/** + * Read a JSON body only when the response actually carries one. + * + * \`204 No Content\`, \`205 Reset Content\` and \`304 Not Modified\` are defined to + * have no body, and an empty body makes \`response.json()\` throw - so a + * successful bodyless response would otherwise surface as a JSON parse error. + */ +async function readOptionalJsonBody(response: HttpResponse): Promise | undefined> { + if ([204, 205, 304].includes(response.status)) { + return undefined; + } + try { + return await response.json(); + } catch { + return undefined; + } +} + /** * Extract headers from response into a plain object */ @@ -2075,15 +2133,15 @@ function validateOAuth2Config(auth: OAuth2Auth): void { // If using a flow, validate required fields switch (auth.flow) { case 'client_credentials': - if (!auth.tokenUrl) throw new Error('OAuth2 Client Credentials flow requires tokenUrl'); - if (!auth.clientId) throw new Error('OAuth2 Client Credentials flow requires clientId'); + if (!auth.tokenUrl) throw new HttpGlobalError('OAuth2 Client Credentials flow requires tokenUrl'); + if (!auth.clientId) throw new HttpGlobalError('OAuth2 Client Credentials flow requires clientId'); break; case 'password': - if (!auth.tokenUrl) throw new Error('OAuth2 Password flow requires tokenUrl'); - if (!auth.clientId) throw new Error('OAuth2 Password flow requires clientId'); - if (!auth.username) throw new Error('OAuth2 Password flow requires username'); - if (!auth.password) throw new Error('OAuth2 Password flow requires password'); + if (!auth.tokenUrl) throw new HttpGlobalError('OAuth2 Password flow requires tokenUrl'); + if (!auth.clientId) throw new HttpGlobalError('OAuth2 Password flow requires clientId'); + if (!auth.username) throw new HttpGlobalError('OAuth2 Password flow requires username'); + if (!auth.password) throw new HttpGlobalError('OAuth2 Password flow requires password'); break; default: @@ -2147,7 +2205,7 @@ async function handleOAuth2TokenFlow( }); if (!tokenResponse.ok) { - throw new Error(\`OAuth2 token request failed: \${tokenResponse.statusText}\`); + throw new HttpGlobalError(\`OAuth2 token request failed: \${tokenResponse.statusText}\`); } const tokenData = await tokenResponse.json(); @@ -2194,7 +2252,7 @@ async function handleTokenRefresh( }); if (!refreshResponse.ok) { - throw new Error('Unauthorized'); + throw new HttpGlobalError('Unauthorized'); } const tokenData = await refreshResponse.json(); @@ -2292,7 +2350,7 @@ async function getPingRequest(context: GetPingRequestContext = {}): Promise { // default-only (no explicit numeric cases / switch), but the typed // HttpError class is still exported. const httpProtocolCode = generatedChannels.protocolFiles['http_client']; - expect(httpProtocolCode).toContain('export class HttpError extends Error'); + // The base class is aliased from `globalThis` so a payload model named + // `Error` (imported into this same file) cannot shadow it. + expect(httpProtocolCode).toContain('export class HttpError extends HttpGlobalError'); expect(httpProtocolCode).toContain( 'function handleHttpError(status: number, statusText: string, body?: unknown): never {\n throw new HttpError(`HTTP Error: ${status} ${statusText}`, status, statusText, body);\n}' ); @@ -795,7 +797,9 @@ describe('channels', () => { // across its operations, so handleHttpError emits an explicit case per // code (aggregated document-wide) throwing a typed HttpError with the // standard reason phrase. The typed HttpError class is always exported. - expect(httpProtocolCode).toContain('export class HttpError extends Error'); + // The base class is aliased from `globalThis` so a payload model named + // `Error` (imported into this same file) cannot shadow it. + expect(httpProtocolCode).toContain('export class HttpError extends HttpGlobalError'); expect(httpProtocolCode).toContain( 'case 400:\n throw new HttpError("Bad Request", status, statusText, body);' ); diff --git a/test/codegen/generators/typescript/channels/openapi-http-client-responses.spec.ts b/test/codegen/generators/typescript/channels/openapi-http-client-responses.spec.ts new file mode 100644 index 00000000..a0993a26 --- /dev/null +++ b/test/codegen/generators/typescript/channels/openapi-http-client-responses.spec.ts @@ -0,0 +1,200 @@ +/** + * End-to-end checks for the OpenAPI -> HTTP client response handling, driven + * through the in-memory generation pipeline so the assertions run against the + * file contents a user actually receives. + * + * Each case here corresponds to a way the generated client used to fail on an + * entirely ordinary document: a schema named `Error`, an array-typed success + * response, and responses with no body at all. + */ +import {generate, BrowserGenerateInput} from '../../../../../src/browser/generate'; + +/** + * A list endpoint returning an array alongside a declared error code, a create + * endpoint, a delete endpoint whose success is `204`, and - deliberately - a + * component schema called `Error`, which is the conventional name for one. + */ +const bookstoreSpec = JSON.stringify({ + openapi: '3.0.3', + info: {title: 'Bookstore API', version: '1.0.0'}, + servers: [{url: 'https://api.bookstore.example/v1'}], + components: { + schemas: { + Book: { + type: 'object', + required: ['id', 'title'], + properties: {id: {type: 'string'}, title: {type: 'string'}} + }, + Error: { + type: 'object', + required: ['code', 'message'], + properties: {code: {type: 'string'}, message: {type: 'string'}} + } + } + }, + paths: { + '/books': { + get: { + operationId: 'listBooks', + responses: { + 200: { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'array', + items: {$ref: '#/components/schemas/Book'} + } + } + } + }, + 400: { + description: 'Bad request', + content: { + 'application/json': {schema: {$ref: '#/components/schemas/Error'}} + } + } + } + } + }, + '/books/{bookId}': { + parameters: [ + {name: 'bookId', in: 'path', required: true, schema: {type: 'string'}} + ], + delete: { + operationId: 'deleteBook', + responses: {204: {description: 'No content'}} + } + } + } +}); + +async function generateHttpClient(spec: string): Promise { + const input: BrowserGenerateInput = { + spec, + specFormat: 'openapi', + config: { + inputType: 'openapi', + inputPath: '', + language: 'typescript', + generators: [ + { + preset: 'channels', + outputPath: 'src/channels', + protocols: ['http_client'] + } + ] + } + }; + + const output = await generate(input); + expect(output.errors).toHaveLength(0); + + const httpClientPath = Object.keys(output.files).find((file) => + file.endsWith('http_client.ts') + ); + expect(httpClientPath).toBeDefined(); + // eslint-disable-next-line security/detect-object-injection + return output.files[httpClientPath as string]; +} + +describe('OpenAPI HTTP client response handling', () => { + it('does not depend on the ambient Error binding, so a schema named Error cannot shadow it', async () => { + const code = await generateHttpClient(bookstoreSpec); + + // The payload model named `Error` is imported into this very file, which + // shadows the global for the whole module - so nothing may reference `Error` + // by its bare name. + expect(code).toContain('const HttpGlobalError = globalThis.Error;'); + expect(code).toContain('export class HttpError extends HttpGlobalError'); + expect(code).not.toMatch(/extends Error\b/); + expect(code).not.toMatch(/new Error\(/); + expect(code).not.toMatch(/instanceof Error\b/); + }); + + it('emits a status code branch for an array-typed success response', async () => { + const code = await generateHttpClient(bookstoreSpec); + expect(code).toContain('unmarshalByStatusCode'); + expect(code).toContain('listBooks'); + + // The branch itself lives on the response union model. It used to be + // omitted for non-object members, so a plain `200` threw "No matching type + // found for status code" on the success path. + const input: BrowserGenerateInput = { + spec: bookstoreSpec, + specFormat: 'openapi', + config: { + inputType: 'openapi', + inputPath: '', + language: 'typescript', + generators: [{preset: 'payloads', outputPath: 'src/payloads'}] + } + }; + const output = await generate(input); + expect(output.errors).toHaveLength(0); + + const unionPath = Object.keys(output.files).find((file) => + file.endsWith('ListBooksResponse.ts') + ); + expect(unionPath).toBeDefined(); + // eslint-disable-next-line security/detect-object-injection + const unionCode = output.files[unionPath as string]; + + expect(unionCode).toContain('if (statusCode === 200)'); + expect(unionCode).toContain('if (statusCode === 400)'); + // An array has no `unmarshal` of its own, so it is parsed structurally. + expect(unionCode).toMatch(/statusCode === 200\)\s*\{\s*return JSON\.parse\(json\) as/); + }); + + it('generates a function for an operation whose only response has no body', async () => { + const code = await generateHttpClient(bookstoreSpec); + + // A `DELETE` answering `204 No Content` is ordinary; the operation used to + // be dropped entirely, leaving no function to call. + expect(code).toContain('async function deleteBook'); + expect(code).toContain('HttpClientResponse'); + + // ...and its body must not be parsed, because `.json()` on an empty body + // throws and would make the successful delete look like a failure. + expect(code).toContain('readOptionalJsonBody'); + expect(code).toContain('[204, 205, 304].includes(response.status)'); + }); + + it('substitutes a path parameter declared on the path item', async () => { + const code = await generateHttpClient(bookstoreSpec); + + // `bookId` is declared once on the path item, not on the operation. + expect(code).toContain("buildUrlWithParameters(config.baseUrl, '/books/{bookId}'"); + expect(code).toContain('DeleteBookParameters'); + }); + + it('reads the base URL from a Swagger 2.0 host, basePath and schemes', async () => { + const swagger2Spec = JSON.stringify({ + swagger: '2.0', + info: {title: 'Legacy API', version: '1.0.0'}, + host: 'api.legacy.example', + basePath: '/v1', + schemes: ['https'], + paths: { + '/things': { + get: { + operationId: 'listThings', + responses: { + 200: { + description: 'OK', + schema: {type: 'object', properties: {id: {type: 'string'}}} + } + } + } + } + } + }); + + const code = await generateHttpClient(swagger2Spec); + + // 2.0 has no `servers`, and falling through to localhost silently pointed + // every generated call at the wrong host. + expect(code).toContain("baseUrl: 'https://api.legacy.example/v1'"); + expect(code).not.toContain('http://localhost:3000'); + }); +}); diff --git a/test/runtime/typescript/src/openapi-path-organization/channels/http_client.ts b/test/runtime/typescript/src/openapi-path-organization/channels/http_client.ts index 7a7cbc7c..a74e7587 100644 --- a/test/runtime/typescript/src/openapi-path-organization/channels/http_client.ts +++ b/test/runtime/typescript/src/openapi-path-organization/channels/http_client.ts @@ -14,6 +14,17 @@ import {FindPetsByStatusAndCategoryHeaders, serializeFindPetsByStatusAndCategory // Common Types - Shared across all HTTP client functions // ============================================================================ +/** + * The global `Error`, captured under a name a payload model cannot take. + * + * A document is free to declare a schema called `Error` (it is the + * conventional name for one), and its generated model is imported into this + * module, shadowing the global for the whole file. Every reference below goes + * through these aliases so that is harmless. + */ +const HttpGlobalError = globalThis.Error; +type HttpGlobalError = InstanceType; + /** * Standard HTTP response interface that wraps fetch-like responses */ @@ -48,7 +59,7 @@ export interface HttpClientResponse { * (when the error response had a JSON body). Thrown by `handleHttpError` and * routed through the `onError` hook / retry logic unchanged. */ -export class HttpError extends Error { +export class HttpError extends HttpGlobalError { status: number; statusText: string; body?: unknown; @@ -168,7 +179,7 @@ export interface RetryConfig { backoffMultiplier?: number; // Multiplier for exponential backoff (default: 2) retryableStatusCodes?: number[]; // Status codes to retry (default: [408, 429, 500, 502, 503, 504]) retryOnNetworkError?: boolean; // Retry on network errors (default: true) - onRetry?: (attempt: number, delay: number, error: Error) => void; // Callback on each retry + onRetry?: (attempt: number, delay: number, error: HttpGlobalError) => void; // Callback on each retry } // ============================================================================ @@ -200,7 +211,7 @@ export interface HttpHooks { /** * Called on request error for logging, error transformation, etc. */ - onError?: (error: Error, params: HttpRequestParams) => Error | Promise; + onError?: (error: HttpGlobalError, params: HttpRequestParams) => HttpGlobalError | Promise; } // ============================================================================ @@ -350,7 +361,7 @@ function calculateBackoffDelay( * Determine if a request should be retried based on error/response */ function shouldRetry( - error: Error | null, + error: HttpGlobalError | null, response: HttpResponse | null, config: Required, attempt: number @@ -373,14 +384,14 @@ async function executeWithRetry( retryConfig?: RetryConfig ): Promise { const config = { ...DEFAULT_RETRY_CONFIG, ...retryConfig }; - let lastError: Error | null = null; + let lastError: HttpGlobalError | null = null; let lastResponse: HttpResponse | null = null; for (let attempt = 0; attempt <= config.maxRetries; attempt++) { try { if (attempt > 0) { const delay = calculateBackoffDelay(attempt, config); - config.onRetry(attempt, delay, lastError ?? new Error('Retry attempt')); + config.onRetry(attempt, delay, lastError ?? new HttpGlobalError('Retry attempt')); await sleep(delay); } @@ -392,9 +403,9 @@ async function executeWithRetry( } lastResponse = response; - lastError = new Error(`HTTP Error: ${response.status} ${response.statusText}`); + lastError = new HttpGlobalError(`HTTP Error: ${response.status} ${response.statusText}`); } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); + lastError = error instanceof HttpGlobalError ? error : new HttpGlobalError(String(error)); if (!shouldRetry(lastError, null, config, attempt + 1)) { throw lastError; @@ -406,7 +417,7 @@ async function executeWithRetry( if (lastResponse) { return lastResponse; } - throw lastError ?? new Error('Request failed after retries'); + throw lastError ?? new HttpGlobalError('Request failed after retries'); } /** @@ -427,6 +438,24 @@ function handleHttpError(status: number, statusText: string, body?: unknown): ne } } +/** + * Read a JSON body only when the response actually carries one. + * + * `204 No Content`, `205 Reset Content` and `304 Not Modified` are defined to + * have no body, and an empty body makes `response.json()` throw - so a + * successful bodyless response would otherwise surface as a JSON parse error. + */ +async function readOptionalJsonBody(response: HttpResponse): Promise | undefined> { + if ([204, 205, 304].includes(response.status)) { + return undefined; + } + try { + return await response.json(); + } catch { + return undefined; + } +} + /** * Extract headers from response into a plain object */ @@ -495,15 +524,15 @@ function validateOAuth2Config(auth: OAuth2Auth): void { // If using a flow, validate required fields switch (auth.flow) { case 'client_credentials': - if (!auth.tokenUrl) throw new Error('OAuth2 Client Credentials flow requires tokenUrl'); - if (!auth.clientId) throw new Error('OAuth2 Client Credentials flow requires clientId'); + if (!auth.tokenUrl) throw new HttpGlobalError('OAuth2 Client Credentials flow requires tokenUrl'); + if (!auth.clientId) throw new HttpGlobalError('OAuth2 Client Credentials flow requires clientId'); break; case 'password': - if (!auth.tokenUrl) throw new Error('OAuth2 Password flow requires tokenUrl'); - if (!auth.clientId) throw new Error('OAuth2 Password flow requires clientId'); - if (!auth.username) throw new Error('OAuth2 Password flow requires username'); - if (!auth.password) throw new Error('OAuth2 Password flow requires password'); + if (!auth.tokenUrl) throw new HttpGlobalError('OAuth2 Password flow requires tokenUrl'); + if (!auth.clientId) throw new HttpGlobalError('OAuth2 Password flow requires clientId'); + if (!auth.username) throw new HttpGlobalError('OAuth2 Password flow requires username'); + if (!auth.password) throw new HttpGlobalError('OAuth2 Password flow requires password'); break; default: @@ -567,7 +596,7 @@ async function handleOAuth2TokenFlow( }); if (!tokenResponse.ok) { - throw new Error(`OAuth2 token request failed: ${tokenResponse.statusText}`); + throw new HttpGlobalError(`OAuth2 token request failed: ${tokenResponse.statusText}`); } const tokenData = await tokenResponse.json(); @@ -614,7 +643,7 @@ async function handleTokenRefresh( }); if (!refreshResponse.ok) { - throw new Error('Unauthorized'); + throw new HttpGlobalError('Unauthorized'); } const tokenData = await refreshResponse.json(); @@ -715,7 +744,7 @@ async function addPet(context: AddPetContext): Promise> response = refreshResponse; } } catch { - throw new Error('Unauthorized'); + throw new HttpGlobalError('Unauthorized'); } } @@ -737,14 +766,14 @@ async function addPet(context: AddPetContext): Promise> status: response.status, statusText: response.statusText, headers: responseHeaders, - rawData, + rawData: rawData ?? {}, }; return result; } catch (error) { // Apply onError hook if present - if (config.hooks?.onError && error instanceof Error) { + if (config.hooks?.onError && error instanceof HttpGlobalError) { throw await config.hooks.onError(error, requestParams); } throw error; @@ -827,7 +856,7 @@ async function updatePet(context: UpdatePetContext): Promise; + /** * Standard HTTP response interface that wraps fetch-like responses */ @@ -39,7 +50,7 @@ export interface HttpClientResponse { * (when the error response had a JSON body). Thrown by `handleHttpError` and * routed through the `onError` hook / retry logic unchanged. */ -export class HttpError extends Error { +export class HttpError extends HttpGlobalError { status: number; statusText: string; body?: unknown; @@ -175,7 +186,7 @@ export interface RetryConfig { backoffMultiplier?: number; // Multiplier for exponential backoff (default: 2) retryableStatusCodes?: number[]; // Status codes to retry (default: [408, 429, 500, 502, 503, 504]) retryOnNetworkError?: boolean; // Retry on network errors (default: true) - onRetry?: (attempt: number, delay: number, error: Error) => void; // Callback on each retry + onRetry?: (attempt: number, delay: number, error: HttpGlobalError) => void; // Callback on each retry } // ============================================================================ @@ -207,7 +218,7 @@ export interface HttpHooks { /** * Called on request error for logging, error transformation, etc. */ - onError?: (error: Error, params: HttpRequestParams) => Error | Promise; + onError?: (error: HttpGlobalError, params: HttpRequestParams) => HttpGlobalError | Promise; } // ============================================================================ @@ -367,7 +378,7 @@ function calculateBackoffDelay( * Determine if a request should be retried based on error/response */ function shouldRetry( - error: Error | null, + error: HttpGlobalError | null, response: HttpResponse | null, config: Required, attempt: number @@ -390,14 +401,14 @@ async function executeWithRetry( retryConfig?: RetryConfig ): Promise { const config = { ...DEFAULT_RETRY_CONFIG, ...retryConfig }; - let lastError: Error | null = null; + let lastError: HttpGlobalError | null = null; let lastResponse: HttpResponse | null = null; for (let attempt = 0; attempt <= config.maxRetries; attempt++) { try { if (attempt > 0) { const delay = calculateBackoffDelay(attempt, config); - config.onRetry(attempt, delay, lastError ?? new Error('Retry attempt')); + config.onRetry(attempt, delay, lastError ?? new HttpGlobalError('Retry attempt')); await sleep(delay); } @@ -409,9 +420,9 @@ async function executeWithRetry( } lastResponse = response; - lastError = new Error(`HTTP Error: ${response.status} ${response.statusText}`); + lastError = new HttpGlobalError(`HTTP Error: ${response.status} ${response.statusText}`); } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); + lastError = error instanceof HttpGlobalError ? error : new HttpGlobalError(String(error)); if (!shouldRetry(lastError, null, config, attempt + 1)) { throw lastError; @@ -423,7 +434,7 @@ async function executeWithRetry( if (lastResponse) { return lastResponse; } - throw lastError ?? new Error('Request failed after retries'); + throw lastError ?? new HttpGlobalError('Request failed after retries'); } /** @@ -435,6 +446,24 @@ function handleHttpError(status: number, statusText: string, body?: unknown): ne throw new HttpError(`HTTP Error: ${status} ${statusText}`, status, statusText, body); } +/** + * Read a JSON body only when the response actually carries one. + * + * `204 No Content`, `205 Reset Content` and `304 Not Modified` are defined to + * have no body, and an empty body makes `response.json()` throw - so a + * successful bodyless response would otherwise surface as a JSON parse error. + */ +async function readOptionalJsonBody(response: HttpResponse): Promise | undefined> { + if ([204, 205, 304].includes(response.status)) { + return undefined; + } + try { + return await response.json(); + } catch { + return undefined; + } +} + /** * Extract headers from response into a plain object */ @@ -503,15 +532,15 @@ function validateOAuth2Config(auth: OAuth2Auth): void { // If using a flow, validate required fields switch (auth.flow) { case 'client_credentials': - if (!auth.tokenUrl) throw new Error('OAuth2 Client Credentials flow requires tokenUrl'); - if (!auth.clientId) throw new Error('OAuth2 Client Credentials flow requires clientId'); + if (!auth.tokenUrl) throw new HttpGlobalError('OAuth2 Client Credentials flow requires tokenUrl'); + if (!auth.clientId) throw new HttpGlobalError('OAuth2 Client Credentials flow requires clientId'); break; case 'password': - if (!auth.tokenUrl) throw new Error('OAuth2 Password flow requires tokenUrl'); - if (!auth.clientId) throw new Error('OAuth2 Password flow requires clientId'); - if (!auth.username) throw new Error('OAuth2 Password flow requires username'); - if (!auth.password) throw new Error('OAuth2 Password flow requires password'); + if (!auth.tokenUrl) throw new HttpGlobalError('OAuth2 Password flow requires tokenUrl'); + if (!auth.clientId) throw new HttpGlobalError('OAuth2 Password flow requires clientId'); + if (!auth.username) throw new HttpGlobalError('OAuth2 Password flow requires username'); + if (!auth.password) throw new HttpGlobalError('OAuth2 Password flow requires password'); break; default: @@ -575,7 +604,7 @@ async function handleOAuth2TokenFlow( }); if (!tokenResponse.ok) { - throw new Error(`OAuth2 token request failed: ${tokenResponse.statusText}`); + throw new HttpGlobalError(`OAuth2 token request failed: ${tokenResponse.statusText}`); } const tokenData = await tokenResponse.json(); @@ -622,7 +651,7 @@ async function handleTokenRefresh( }); if (!refreshResponse.ok) { - throw new Error('Unauthorized'); + throw new HttpGlobalError('Unauthorized'); } const tokenData = await refreshResponse.json(); @@ -720,7 +749,7 @@ async function getEcho(context: GetEchoContext = {}): Promise; + /** * Standard HTTP response interface that wraps fetch-like responses */ @@ -48,7 +59,7 @@ export interface HttpClientResponse { * (when the error response had a JSON body). Thrown by `handleHttpError` and * routed through the `onError` hook / retry logic unchanged. */ -export class HttpError extends Error { +export class HttpError extends HttpGlobalError { status: number; statusText: string; body?: unknown; @@ -168,7 +179,7 @@ export interface RetryConfig { backoffMultiplier?: number; // Multiplier for exponential backoff (default: 2) retryableStatusCodes?: number[]; // Status codes to retry (default: [408, 429, 500, 502, 503, 504]) retryOnNetworkError?: boolean; // Retry on network errors (default: true) - onRetry?: (attempt: number, delay: number, error: Error) => void; // Callback on each retry + onRetry?: (attempt: number, delay: number, error: HttpGlobalError) => void; // Callback on each retry } // ============================================================================ @@ -200,7 +211,7 @@ export interface HttpHooks { /** * Called on request error for logging, error transformation, etc. */ - onError?: (error: Error, params: HttpRequestParams) => Error | Promise; + onError?: (error: HttpGlobalError, params: HttpRequestParams) => HttpGlobalError | Promise; } // ============================================================================ @@ -350,7 +361,7 @@ function calculateBackoffDelay( * Determine if a request should be retried based on error/response */ function shouldRetry( - error: Error | null, + error: HttpGlobalError | null, response: HttpResponse | null, config: Required, attempt: number @@ -373,14 +384,14 @@ async function executeWithRetry( retryConfig?: RetryConfig ): Promise { const config = { ...DEFAULT_RETRY_CONFIG, ...retryConfig }; - let lastError: Error | null = null; + let lastError: HttpGlobalError | null = null; let lastResponse: HttpResponse | null = null; for (let attempt = 0; attempt <= config.maxRetries; attempt++) { try { if (attempt > 0) { const delay = calculateBackoffDelay(attempt, config); - config.onRetry(attempt, delay, lastError ?? new Error('Retry attempt')); + config.onRetry(attempt, delay, lastError ?? new HttpGlobalError('Retry attempt')); await sleep(delay); } @@ -392,9 +403,9 @@ async function executeWithRetry( } lastResponse = response; - lastError = new Error(`HTTP Error: ${response.status} ${response.statusText}`); + lastError = new HttpGlobalError(`HTTP Error: ${response.status} ${response.statusText}`); } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); + lastError = error instanceof HttpGlobalError ? error : new HttpGlobalError(String(error)); if (!shouldRetry(lastError, null, config, attempt + 1)) { throw lastError; @@ -406,7 +417,7 @@ async function executeWithRetry( if (lastResponse) { return lastResponse; } - throw lastError ?? new Error('Request failed after retries'); + throw lastError ?? new HttpGlobalError('Request failed after retries'); } /** @@ -427,6 +438,24 @@ function handleHttpError(status: number, statusText: string, body?: unknown): ne } } +/** + * Read a JSON body only when the response actually carries one. + * + * `204 No Content`, `205 Reset Content` and `304 Not Modified` are defined to + * have no body, and an empty body makes `response.json()` throw - so a + * successful bodyless response would otherwise surface as a JSON parse error. + */ +async function readOptionalJsonBody(response: HttpResponse): Promise | undefined> { + if ([204, 205, 304].includes(response.status)) { + return undefined; + } + try { + return await response.json(); + } catch { + return undefined; + } +} + /** * Extract headers from response into a plain object */ @@ -495,15 +524,15 @@ function validateOAuth2Config(auth: OAuth2Auth): void { // If using a flow, validate required fields switch (auth.flow) { case 'client_credentials': - if (!auth.tokenUrl) throw new Error('OAuth2 Client Credentials flow requires tokenUrl'); - if (!auth.clientId) throw new Error('OAuth2 Client Credentials flow requires clientId'); + if (!auth.tokenUrl) throw new HttpGlobalError('OAuth2 Client Credentials flow requires tokenUrl'); + if (!auth.clientId) throw new HttpGlobalError('OAuth2 Client Credentials flow requires clientId'); break; case 'password': - if (!auth.tokenUrl) throw new Error('OAuth2 Password flow requires tokenUrl'); - if (!auth.clientId) throw new Error('OAuth2 Password flow requires clientId'); - if (!auth.username) throw new Error('OAuth2 Password flow requires username'); - if (!auth.password) throw new Error('OAuth2 Password flow requires password'); + if (!auth.tokenUrl) throw new HttpGlobalError('OAuth2 Password flow requires tokenUrl'); + if (!auth.clientId) throw new HttpGlobalError('OAuth2 Password flow requires clientId'); + if (!auth.username) throw new HttpGlobalError('OAuth2 Password flow requires username'); + if (!auth.password) throw new HttpGlobalError('OAuth2 Password flow requires password'); break; default: @@ -567,7 +596,7 @@ async function handleOAuth2TokenFlow( }); if (!tokenResponse.ok) { - throw new Error(`OAuth2 token request failed: ${tokenResponse.statusText}`); + throw new HttpGlobalError(`OAuth2 token request failed: ${tokenResponse.statusText}`); } const tokenData = await tokenResponse.json(); @@ -614,7 +643,7 @@ async function handleTokenRefresh( }); if (!refreshResponse.ok) { - throw new Error('Unauthorized'); + throw new HttpGlobalError('Unauthorized'); } const tokenData = await refreshResponse.json(); @@ -715,7 +744,7 @@ async function addPet(context: AddPetContext): Promise> response = refreshResponse; } } catch { - throw new Error('Unauthorized'); + throw new HttpGlobalError('Unauthorized'); } } @@ -737,14 +766,14 @@ async function addPet(context: AddPetContext): Promise> status: response.status, statusText: response.statusText, headers: responseHeaders, - rawData, + rawData: rawData ?? {}, }; return result; } catch (error) { // Apply onError hook if present - if (config.hooks?.onError && error instanceof Error) { + if (config.hooks?.onError && error instanceof HttpGlobalError) { throw await config.hooks.onError(error, requestParams); } throw error; @@ -827,7 +856,7 @@ async function updatePet(context: UpdatePetContext): Promise; + /** * Standard HTTP response interface that wraps fetch-like responses */ @@ -48,7 +59,7 @@ export interface HttpClientResponse { * (when the error response had a JSON body). Thrown by `handleHttpError` and * routed through the `onError` hook / retry logic unchanged. */ -export class HttpError extends Error { +export class HttpError extends HttpGlobalError { status: number; statusText: string; body?: unknown; @@ -168,7 +179,7 @@ export interface RetryConfig { backoffMultiplier?: number; // Multiplier for exponential backoff (default: 2) retryableStatusCodes?: number[]; // Status codes to retry (default: [408, 429, 500, 502, 503, 504]) retryOnNetworkError?: boolean; // Retry on network errors (default: true) - onRetry?: (attempt: number, delay: number, error: Error) => void; // Callback on each retry + onRetry?: (attempt: number, delay: number, error: HttpGlobalError) => void; // Callback on each retry } // ============================================================================ @@ -200,7 +211,7 @@ export interface HttpHooks { /** * Called on request error for logging, error transformation, etc. */ - onError?: (error: Error, params: HttpRequestParams) => Error | Promise; + onError?: (error: HttpGlobalError, params: HttpRequestParams) => HttpGlobalError | Promise; } // ============================================================================ @@ -350,7 +361,7 @@ function calculateBackoffDelay( * Determine if a request should be retried based on error/response */ function shouldRetry( - error: Error | null, + error: HttpGlobalError | null, response: HttpResponse | null, config: Required, attempt: number @@ -373,14 +384,14 @@ async function executeWithRetry( retryConfig?: RetryConfig ): Promise { const config = { ...DEFAULT_RETRY_CONFIG, ...retryConfig }; - let lastError: Error | null = null; + let lastError: HttpGlobalError | null = null; let lastResponse: HttpResponse | null = null; for (let attempt = 0; attempt <= config.maxRetries; attempt++) { try { if (attempt > 0) { const delay = calculateBackoffDelay(attempt, config); - config.onRetry(attempt, delay, lastError ?? new Error('Retry attempt')); + config.onRetry(attempt, delay, lastError ?? new HttpGlobalError('Retry attempt')); await sleep(delay); } @@ -392,9 +403,9 @@ async function executeWithRetry( } lastResponse = response; - lastError = new Error(`HTTP Error: ${response.status} ${response.statusText}`); + lastError = new HttpGlobalError(`HTTP Error: ${response.status} ${response.statusText}`); } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); + lastError = error instanceof HttpGlobalError ? error : new HttpGlobalError(String(error)); if (!shouldRetry(lastError, null, config, attempt + 1)) { throw lastError; @@ -406,7 +417,7 @@ async function executeWithRetry( if (lastResponse) { return lastResponse; } - throw lastError ?? new Error('Request failed after retries'); + throw lastError ?? new HttpGlobalError('Request failed after retries'); } /** @@ -427,6 +438,24 @@ function handleHttpError(status: number, statusText: string, body?: unknown): ne } } +/** + * Read a JSON body only when the response actually carries one. + * + * `204 No Content`, `205 Reset Content` and `304 Not Modified` are defined to + * have no body, and an empty body makes `response.json()` throw - so a + * successful bodyless response would otherwise surface as a JSON parse error. + */ +async function readOptionalJsonBody(response: HttpResponse): Promise | undefined> { + if ([204, 205, 304].includes(response.status)) { + return undefined; + } + try { + return await response.json(); + } catch { + return undefined; + } +} + /** * Extract headers from response into a plain object */ @@ -495,15 +524,15 @@ function validateOAuth2Config(auth: OAuth2Auth): void { // If using a flow, validate required fields switch (auth.flow) { case 'client_credentials': - if (!auth.tokenUrl) throw new Error('OAuth2 Client Credentials flow requires tokenUrl'); - if (!auth.clientId) throw new Error('OAuth2 Client Credentials flow requires clientId'); + if (!auth.tokenUrl) throw new HttpGlobalError('OAuth2 Client Credentials flow requires tokenUrl'); + if (!auth.clientId) throw new HttpGlobalError('OAuth2 Client Credentials flow requires clientId'); break; case 'password': - if (!auth.tokenUrl) throw new Error('OAuth2 Password flow requires tokenUrl'); - if (!auth.clientId) throw new Error('OAuth2 Password flow requires clientId'); - if (!auth.username) throw new Error('OAuth2 Password flow requires username'); - if (!auth.password) throw new Error('OAuth2 Password flow requires password'); + if (!auth.tokenUrl) throw new HttpGlobalError('OAuth2 Password flow requires tokenUrl'); + if (!auth.clientId) throw new HttpGlobalError('OAuth2 Password flow requires clientId'); + if (!auth.username) throw new HttpGlobalError('OAuth2 Password flow requires username'); + if (!auth.password) throw new HttpGlobalError('OAuth2 Password flow requires password'); break; default: @@ -567,7 +596,7 @@ async function handleOAuth2TokenFlow( }); if (!tokenResponse.ok) { - throw new Error(`OAuth2 token request failed: ${tokenResponse.statusText}`); + throw new HttpGlobalError(`OAuth2 token request failed: ${tokenResponse.statusText}`); } const tokenData = await tokenResponse.json(); @@ -614,7 +643,7 @@ async function handleTokenRefresh( }); if (!refreshResponse.ok) { - throw new Error('Unauthorized'); + throw new HttpGlobalError('Unauthorized'); } const tokenData = await refreshResponse.json(); @@ -715,7 +744,7 @@ async function addPet(context: AddPetContext): Promise> response = refreshResponse; } } catch { - throw new Error('Unauthorized'); + throw new HttpGlobalError('Unauthorized'); } } @@ -737,14 +766,14 @@ async function addPet(context: AddPetContext): Promise> status: response.status, statusText: response.statusText, headers: responseHeaders, - rawData, + rawData: rawData ?? {}, }; return result; } catch (error) { // Apply onError hook if present - if (config.hooks?.onError && error instanceof Error) { + if (config.hooks?.onError && error instanceof HttpGlobalError) { throw await config.hooks.onError(error, requestParams); } throw error; @@ -827,7 +856,7 @@ async function updatePet(context: UpdatePetContext): Promise; + /** * Standard HTTP response interface that wraps fetch-like responses */ @@ -49,7 +60,7 @@ export interface HttpClientResponse { * (when the error response had a JSON body). Thrown by `handleHttpError` and * routed through the `onError` hook / retry logic unchanged. */ -export class HttpError extends Error { +export class HttpError extends HttpGlobalError { status: number; statusText: string; body?: unknown; @@ -185,7 +196,7 @@ export interface RetryConfig { backoffMultiplier?: number; // Multiplier for exponential backoff (default: 2) retryableStatusCodes?: number[]; // Status codes to retry (default: [408, 429, 500, 502, 503, 504]) retryOnNetworkError?: boolean; // Retry on network errors (default: true) - onRetry?: (attempt: number, delay: number, error: Error) => void; // Callback on each retry + onRetry?: (attempt: number, delay: number, error: HttpGlobalError) => void; // Callback on each retry } // ============================================================================ @@ -217,7 +228,7 @@ export interface HttpHooks { /** * Called on request error for logging, error transformation, etc. */ - onError?: (error: Error, params: HttpRequestParams) => Error | Promise; + onError?: (error: HttpGlobalError, params: HttpRequestParams) => HttpGlobalError | Promise; } // ============================================================================ @@ -377,7 +388,7 @@ function calculateBackoffDelay( * Determine if a request should be retried based on error/response */ function shouldRetry( - error: Error | null, + error: HttpGlobalError | null, response: HttpResponse | null, config: Required, attempt: number @@ -400,14 +411,14 @@ async function executeWithRetry( retryConfig?: RetryConfig ): Promise { const config = { ...DEFAULT_RETRY_CONFIG, ...retryConfig }; - let lastError: Error | null = null; + let lastError: HttpGlobalError | null = null; let lastResponse: HttpResponse | null = null; for (let attempt = 0; attempt <= config.maxRetries; attempt++) { try { if (attempt > 0) { const delay = calculateBackoffDelay(attempt, config); - config.onRetry(attempt, delay, lastError ?? new Error('Retry attempt')); + config.onRetry(attempt, delay, lastError ?? new HttpGlobalError('Retry attempt')); await sleep(delay); } @@ -419,9 +430,9 @@ async function executeWithRetry( } lastResponse = response; - lastError = new Error(`HTTP Error: ${response.status} ${response.statusText}`); + lastError = new HttpGlobalError(`HTTP Error: ${response.status} ${response.statusText}`); } catch (error) { - lastError = error instanceof Error ? error : new Error(String(error)); + lastError = error instanceof HttpGlobalError ? error : new HttpGlobalError(String(error)); if (!shouldRetry(lastError, null, config, attempt + 1)) { throw lastError; @@ -433,7 +444,7 @@ async function executeWithRetry( if (lastResponse) { return lastResponse; } - throw lastError ?? new Error('Request failed after retries'); + throw lastError ?? new HttpGlobalError('Request failed after retries'); } /** @@ -445,6 +456,24 @@ function handleHttpError(status: number, statusText: string, body?: unknown): ne throw new HttpError(`HTTP Error: ${status} ${statusText}`, status, statusText, body); } +/** + * Read a JSON body only when the response actually carries one. + * + * `204 No Content`, `205 Reset Content` and `304 Not Modified` are defined to + * have no body, and an empty body makes `response.json()` throw - so a + * successful bodyless response would otherwise surface as a JSON parse error. + */ +async function readOptionalJsonBody(response: HttpResponse): Promise | undefined> { + if ([204, 205, 304].includes(response.status)) { + return undefined; + } + try { + return await response.json(); + } catch { + return undefined; + } +} + /** * Extract headers from response into a plain object */ @@ -513,15 +542,15 @@ function validateOAuth2Config(auth: OAuth2Auth): void { // If using a flow, validate required fields switch (auth.flow) { case 'client_credentials': - if (!auth.tokenUrl) throw new Error('OAuth2 Client Credentials flow requires tokenUrl'); - if (!auth.clientId) throw new Error('OAuth2 Client Credentials flow requires clientId'); + if (!auth.tokenUrl) throw new HttpGlobalError('OAuth2 Client Credentials flow requires tokenUrl'); + if (!auth.clientId) throw new HttpGlobalError('OAuth2 Client Credentials flow requires clientId'); break; case 'password': - if (!auth.tokenUrl) throw new Error('OAuth2 Password flow requires tokenUrl'); - if (!auth.clientId) throw new Error('OAuth2 Password flow requires clientId'); - if (!auth.username) throw new Error('OAuth2 Password flow requires username'); - if (!auth.password) throw new Error('OAuth2 Password flow requires password'); + if (!auth.tokenUrl) throw new HttpGlobalError('OAuth2 Password flow requires tokenUrl'); + if (!auth.clientId) throw new HttpGlobalError('OAuth2 Password flow requires clientId'); + if (!auth.username) throw new HttpGlobalError('OAuth2 Password flow requires username'); + if (!auth.password) throw new HttpGlobalError('OAuth2 Password flow requires password'); break; default: @@ -585,7 +614,7 @@ async function handleOAuth2TokenFlow( }); if (!tokenResponse.ok) { - throw new Error(`OAuth2 token request failed: ${tokenResponse.statusText}`); + throw new HttpGlobalError(`OAuth2 token request failed: ${tokenResponse.statusText}`); } const tokenData = await tokenResponse.json(); @@ -632,7 +661,7 @@ async function handleTokenRefresh( }); if (!refreshResponse.ok) { - throw new Error('Unauthorized'); + throw new HttpGlobalError('Unauthorized'); } const tokenData = await refreshResponse.json(); @@ -733,7 +762,7 @@ async function postPingPostRequest(context: PostPingPostRequestContext): Promise response = refreshResponse; } } catch { - throw new Error('Unauthorized'); + throw new HttpGlobalError('Unauthorized'); } } @@ -755,14 +784,14 @@ async function postPingPostRequest(context: PostPingPostRequestContext): Promise status: response.status, statusText: response.statusText, headers: responseHeaders, - rawData, + rawData: rawData ?? {}, }; return result; } catch (error) { // Apply onError hook if present - if (config.hooks?.onError && error instanceof Error) { + if (config.hooks?.onError && error instanceof HttpGlobalError) { throw await config.hooks.onError(error, requestParams); } throw error; @@ -842,7 +871,7 @@ async function getPingGetRequest(context: GetPingGetRequestContext = {}): Promis response = refreshResponse; } } catch { - throw new Error('Unauthorized'); + throw new HttpGlobalError('Unauthorized'); } } @@ -864,14 +893,14 @@ async function getPingGetRequest(context: GetPingGetRequestContext = {}): Promis status: response.status, statusText: response.statusText, headers: responseHeaders, - rawData, + rawData: rawData ?? {}, }; return result; } catch (error) { // Apply onError hook if present - if (config.hooks?.onError && error instanceof Error) { + if (config.hooks?.onError && error instanceof HttpGlobalError) { throw await config.hooks.onError(error, requestParams); } throw error; @@ -954,7 +983,7 @@ async function putPingPutRequest(context: PutPingPutRequestContext): Promise Date: Fri, 31 Jul 2026 09:18:06 +0200 Subject: [PATCH 3/4] fix(website): repair dead links, placeholder metadata and the MCP endpoint The landing page shipped unedited Docusaurus scaffolding: the tab read "Hello from The Codegen Project" and every social preview and search snippet read "Description will go into a meta tag in ". Twenty-odd links were dead, including three of the four "Explore Further" links closing the quickstart that the homepage's only call to action points at. Rather than rewrite each one, give the `protocols` and `inputs` categories real routes via `slug` (relative to the docs `routeBasePath`), which fixes them at the source and makes `/docs/protocols` resolve for the blog too. `examples/` is not published to the site - move_docs.js copies only `docs/` and `assets/` - so those links now point at GitHub, which works from the site and the repo alike. Blog URLs are flat, so `../slug` and `./slug` both escaped `/blog`; they are absolute now, which also fixes them on the tag and author listing pages that re-render the excerpts. Three broken anchors: `#dependencies` targeted the page's only h1 among h2 siblings, and the ToCs of docs/usage.md and docs/migrations/v0.md linked their own page title, which gets no anchor. Moving those titles into front matter leaves markdown-toc nothing to mislink, so regeneration keeps them correct. `/api/mcp` - which docs/ai-assistants.md tells every visitor to add to Claude Code, Cursor or Windsurf - returned `DEPLOYMENT_NOT_FOUND`, because vercel.json rewrote it to a preview deployment that no longer exists. It now points at a live deployment (verified: the MCP initialize handshake returns 200). None of this could be caught. The website workflow was path-filtered to `website/**`, but the published docs live at the repo root and are copied in at build time, so a docs-only change never built the site - and broken links were configured as warnings, letting them accumulate. Broken links and markdown links now fail the build, and `docs/**`/`assets/**` trigger the workflow. Anchors stay a warning, since the two generated ToCs surface here before anyone has had a chance to regenerate them. Also refresh the playground's copied configuration schema, which predated the root-config `filter` feature, and label the feature-card SVGs from their card titles - the stock illustrations carry unrelated internal titles, so a screen reader announced "Powered by React" for "Powered by Open Source". Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/website-pr-testing.yml | 5 ++++ docs/contributing.md | 2 +- docs/generators/README.md | 2 +- docs/generators/custom.md | 2 +- docs/getting-started/README.md | 4 +-- docs/getting-started/generators.md | 2 +- docs/getting-started/protocols.md | 2 +- docs/inputs/_category_.json | 3 +- docs/migrations/v0.md | 29 ++++++++++--------- docs/protocols/_category_.json | 3 +- docs/usage.md | 5 ++-- .../2024-10-25-the-codegen-project/index.md | 4 +-- .../index.md | 4 +-- .../index.md | 2 +- .../index.md | 10 +++---- .../index.md | 2 +- website/docusaurus.config.ts | 11 +++++-- .../src/components/HomepageFeatures/index.tsx | 5 +++- website/src/pages/index.tsx | 4 +-- website/src/schemas/configuration-schema.json | 27 +++++++++++++++++ website/vercel.json | 3 +- 21 files changed, 88 insertions(+), 43 deletions(-) diff --git a/.github/workflows/website-pr-testing.yml b/.github/workflows/website-pr-testing.yml index 552a02c4..85491cb7 100644 --- a/.github/workflows/website-pr-testing.yml +++ b/.github/workflows/website-pr-testing.yml @@ -6,6 +6,11 @@ on: - 'website/**' - 'src/browser/**' - 'esbuild.browser.mjs' + # The published docs live at the repo root and are copied into the site by + # website/scripts/move_docs.js at build time, so a docs-only change can + # break the site (dead links, bad anchors) without touching website/. + - 'docs/**' + - 'assets/**' branches: - main types: [opened, reopened, synchronize, ready_for_review] diff --git a/docs/contributing.md b/docs/contributing.md index 8745b44b..f2c0801c 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -43,7 +43,7 @@ The Acceptance Criteria for _adding new features_ requires a few things in order 1. **Not all feature requests from the community (or maintainers!) are accepted:** Even though you are welcome to create a new feature without an issue, it might be rejected and turn out to be a waste of your time. We don't want that to happen, so make sure to create an issue first and wait to see if it's accepted after community discussion of the proposal. 1. **When creating tests for your new feature, aim for as high coverage numbers as possible:** When you run the tests (`npm run test`), you should see a `./coverage/lcov-report/index.html` file being generated. Use this to see in depth where your tests are not covering your implementation. 1. **No documentation, no feature:** If a user cannot understand a new feature, that feature basically doesn't exist! Remember to make sure that any and all relevant [documentation](./) is consistently updated. - - New features such as new generators or inputs, etc, need associated use case documentation along side [examples](../examples). + - New features such as new generators or inputs, etc, need associated use case documentation along side [examples](https://github.com/the-codegen-project/cli/tree/main/examples). ## Repository Architecture diff --git a/docs/generators/README.md b/docs/generators/README.md index 1ec4ffde..02044fd6 100644 --- a/docs/generators/README.md +++ b/docs/generators/README.md @@ -24,7 +24,7 @@ All available generators, across languages and inputs: | OpenAPI | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | JSON Schema | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ✅ | -> OpenAPI `channels` and `client` generate an HTTP client — see the [`openapi-http-client` example](../../examples/openapi-http-client/). +> OpenAPI `channels` and `client` generate an HTTP client — see the [`openapi-http-client` example](https://github.com/the-codegen-project/cli/tree/main/examples/openapi-http-client). | **Languages** | [`payloads`](./payloads.md) | [`parameters`](./parameters.md) | [`headers`](./headers.md) | [`types`](./types.md) | [`channels`](./channels.md) | [`client`](./client.md) | [`models`](./models.md) | [`custom`](./custom.md) | |---|---|---|---|---|---|---|---|---| diff --git a/docs/generators/custom.md b/docs/generators/custom.md index dd9985a1..2a63570a 100644 --- a/docs/generators/custom.md +++ b/docs/generators/custom.md @@ -27,7 +27,7 @@ export default { }; ``` -# Dependencies +## Dependencies In each generator (don't manually use it unless you use `preset: custom`), you can add `dependencies` property, which takes an array of `id`'s that the rendering engine ensures are rendered before the dependant one. diff --git a/docs/getting-started/README.md b/docs/getting-started/README.md index 600ea40e..c994e13b 100644 --- a/docs/getting-started/README.md +++ b/docs/getting-started/README.md @@ -153,7 +153,7 @@ Customize it to your heart's desire! [Each generator has unique set of options]( ## Integrate -With your configuration file in hand, time to integrate it into your project and generate some code! Checkout [all the integrations](../../examples/) for inspiration how to do it. +With your configuration file in hand, time to integrate it into your project and generate some code! Checkout [all the integrations](https://github.com/the-codegen-project/cli/tree/main/examples) for inspiration how to do it. ### Generate Code @@ -200,5 +200,5 @@ Discover how The Codegen Project supports various messaging protocols like NATS, - **[Generator Documentation](../generators/README.md)** - Detailed documentation for each generator type - **[Protocol Documentation](../protocols/)** - Complete protocol reference and implementation details - **[Input Types](../inputs/)** - Learn about AsyncAPI, OpenAPI, and JSON Schema support -- **[Examples](../../examples/)** - Real-world examples and integration patterns +- **[Examples](https://github.com/the-codegen-project/cli/tree/main/examples)** - Real-world examples and integration patterns diff --git a/docs/getting-started/generators.md b/docs/getting-started/generators.md index 48e1b7df..c20c29f0 100644 --- a/docs/getting-started/generators.md +++ b/docs/getting-started/generators.md @@ -147,5 +147,5 @@ export default { - **[Explore Generator Documentation](../generators/README.md)** - Detailed docs for each generator - **[Learn about Protocol Support](./protocols.md)** - How generators work with messaging protocols -- **[Check Out Examples](../../examples/)** - See generators in action +- **[Check Out Examples](https://github.com/the-codegen-project/cli/tree/main/examples)** - See generators in action diff --git a/docs/getting-started/protocols.md b/docs/getting-started/protocols.md index a879e6e2..4e8a8027 100644 --- a/docs/getting-started/protocols.md +++ b/docs/getting-started/protocols.md @@ -152,6 +152,6 @@ export const Protocols = { - **[Explore Protocol Documentation](../protocols/)** - Detailed docs for each protocol - **[Learn about Channels Generator](../generators/channels.md)** - How to configure protocol generation -- **[Check Out Examples](../../examples/)** - See the code generation in action +- **[Check Out Examples](https://github.com/the-codegen-project/cli/tree/main/examples)** - See the code generation in action - **[Understanding Generators](./generators.md)** - Learn how generators work diff --git a/docs/inputs/_category_.json b/docs/inputs/_category_.json index 2b4760ca..eba33c17 100644 --- a/docs/inputs/_category_.json +++ b/docs/inputs/_category_.json @@ -3,6 +3,7 @@ "position": 99, "link": { "type": "generated-index", - "description": "All the available inputs you can use along side which generators are supported." + "description": "All the available inputs you can use along side which generators are supported.", + "slug": "/inputs" } } diff --git a/docs/migrations/v0.md b/docs/migrations/v0.md index e06b7383..35bc87ac 100644 --- a/docs/migrations/v0.md +++ b/docs/migrations/v0.md @@ -1,27 +1,26 @@ --- sidebar_position: 99 +title: Migrating between v0 --- -- [Migrating between v0](#migrating-between-v0) - * [Breaking Changes 0.39.0](#breaking-changes-0390) - + [Functions Parameters](#functions-parameters) - * [Breaking Changes 0.55.1](#breaking-changes-0551) - * [Breaking Changes 0.61.0](#breaking-changes-0610) - + [Channels Multi-File Output](#channels-multi-file-output) - * [Breaking Changes 0.64.2](#breaking-changes-0642) - * [Breaking Changes 0.71.0](#breaking-changes-0710) - + [Library API Type Changes](#library-api-type-changes) - * [Breaking Changes 0.72.3](#breaking-changes-0723) - + [OpenAPI Operation Names](#openapi-operation-names) - * [Breaking Changes 0.72.6](#breaking-changes-0726) - + [Generated HTTP Client Uses Native fetch](#generated-http-client-uses-native-fetch) +- [Breaking Changes 0.39.0](#breaking-changes-0390) + * [Functions Parameters](#functions-parameters) +- [Breaking Changes 0.55.1](#breaking-changes-0551) +- [Breaking Changes 0.61.0](#breaking-changes-0610) + * [Channels Multi-File Output](#channels-multi-file-output) +- [Breaking Changes 0.64.2](#breaking-changes-0642) +- [Breaking Changes 0.71.0](#breaking-changes-0710) + * [Library API Type Changes](#library-api-type-changes) +- [Breaking Changes 0.72.3](#breaking-changes-0723) + * [OpenAPI Operation Names](#openapi-operation-names) +- [Breaking Changes 0.72.6](#breaking-changes-0726) + * [Generated HTTP Client Uses Native fetch](#generated-http-client-uses-native-fetch) -# Migrating between v0 These are all the breaking changes in v0 and how to migrate between them ## Breaking Changes 0.39.0 @@ -249,6 +248,8 @@ import * as NodeFetch from 'node-fetch'; + + diff --git a/docs/protocols/_category_.json b/docs/protocols/_category_.json index 611b86b8..767023f9 100644 --- a/docs/protocols/_category_.json +++ b/docs/protocols/_category_.json @@ -3,6 +3,7 @@ "position": 99, "link": { "type": "generated-index", - "description": "All the available protocols you can use along side which inputs and generators are supported." + "description": "All the available protocols you can use along side which inputs and generators are supported.", + "slug": "/protocols" } } diff --git a/docs/usage.md b/docs/usage.md index 943245fb..8bd48bc8 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,8 +1,7 @@ --- sidebar_position: 3 +title: CLI Usage --- -# CLI Usage - ```sh-session $ npm install -g @the-codegen-project/cli @@ -20,7 +19,7 @@ USAGE ## Table of contents -* [CLI Usage](#cli-usage) + ## Commands diff --git a/website/blog/2024-10-25-the-codegen-project/index.md b/website/blog/2024-10-25-the-codegen-project/index.md index 3e37cf8f..69369bfb 100644 --- a/website/blog/2024-10-25-the-codegen-project/index.md +++ b/website/blog/2024-10-25-the-codegen-project/index.md @@ -11,8 +11,8 @@ tags: [the-codegen-project] [Apollo GraphQL code generator](https://www.apollographql.com/tutorials/lift-off-part1/09-codegen) has always been a pleasure to use, but when it comes to standards such as OpenAPI and AsyncAPI, the same level of code generator or simplicity during the implemenation phase is non-existing. That is what this project wants to bring; -- ⚔️ [Support multiple protocols](/docs/category/protocols) (not just HTTP) -- 📖 [Support multiple input standards](/docs/category/inputs) (not just focused on a specific such as AsyncAPI and OpenAPI) +- ⚔️ [Support multiple protocols](/docs/protocols) (not just HTTP) +- 📖 [Support multiple input standards](/docs/inputs) (not just focused on a specific such as AsyncAPI and OpenAPI) - 🔧 [Integrate into any project](/docs/category/integrations) (regardless of language) A bit ambitious perhaps... So how? diff --git a/website/blog/2025-06-11-asyncapi-headers-generator/index.md b/website/blog/2025-06-11-asyncapi-headers-generator/index.md index b3e71125..32516721 100644 --- a/website/blog/2025-06-11-asyncapi-headers-generator/index.md +++ b/website/blog/2025-06-11-asyncapi-headers-generator/index.md @@ -5,7 +5,7 @@ authors: [jonaslagoni] tags: [the-codegen-project, asyncapi, headers, typescript, messaging, automation] --- -Building robust event-driven applications requires more than just payload validation - you need proper header management for authentication, tracing, routing, and metadata. In our [previous post about payload generation](../asyncapi-payload-generator), we showed how to generate type-safe data models. Now let's explore how The Codegen Project's headers generator can streamline your messaging infrastructure by handling the metadata side of your messages. +Building robust event-driven applications requires more than just payload validation - you need proper header management for authentication, tracing, routing, and metadata. In our [previous post about payload generation](/blog/asyncapi-payload-generator), we showed how to generate type-safe data models. Now let's explore how The Codegen Project's headers generator can streamline your messaging infrastructure by handling the metadata side of your messages. @@ -849,4 +849,4 @@ This will generate the header models and run a comprehensive demonstration showi - **[E-commerce Headers Example](https://github.com/the-codegen-project/cli/tree/main/examples/ecommerce-asyncapi-headers)** - Complete working example from this blog post - **[NATS Protocol](/docs/protocols/nats)** - Using generated headers with NATS messaging - **[Kafka Protocol](/docs/protocols/kafka)** - Using generated headers with Apache Kafka -- **[HTTP Protocol](/docs/protocols/http)** - Using generated headers with HTTP APIs +- **[HTTP Protocol](/docs/protocols/http_client)** - Using generated headers with HTTP APIs diff --git a/website/blog/2025-06-20-asyncapi-types-generator/index.md b/website/blog/2025-06-20-asyncapi-types-generator/index.md index 0012221a..7c289bc2 100644 --- a/website/blog/2025-06-20-asyncapi-types-generator/index.md +++ b/website/blog/2025-06-20-asyncapi-types-generator/index.md @@ -5,7 +5,7 @@ authors: [jonaslagoni] tags: [the-codegen-project, asyncapi, types, typescript, channels, routing] --- -Building event-driven applications with multiple channels often leads to a common but critical problem: hardcoded channel names scattered throughout your codebase. One typo in a channel name can send events to the wrong destination or create silent failures. We've already explored generating [models for payloads](./asyncapi-payload-generator) and [headers](./asyncapi-headers-generator). Now let's see how The Codegen Project's types generator provides compile-time safety for all your channel routing. +Building event-driven applications with multiple channels often leads to a common but critical problem: hardcoded channel names scattered throughout your codebase. One typo in a channel name can send events to the wrong destination or create silent failures. We've already explored generating [models for payloads](/blog/asyncapi-payload-generator) and [headers](/blog/asyncapi-headers-generator). Now let's see how The Codegen Project's types generator provides compile-time safety for all your channel routing. diff --git a/website/blog/2025-07-01-asyncapi-parameters-generator/index.md b/website/blog/2025-07-01-asyncapi-parameters-generator/index.md index d0ec3689..f3077d24 100644 --- a/website/blog/2025-07-01-asyncapi-parameters-generator/index.md +++ b/website/blog/2025-07-01-asyncapi-parameters-generator/index.md @@ -5,7 +5,7 @@ authors: [jonaslagoni] tags: [the-codegen-project, asyncapi, parameters, typescript, channels, routing] --- -Building event-driven applications often requires dynamic channel routing based on parameters like user IDs, tenant identifiers, or resource keys. Manually constructing these parameterized channels is error-prone and makes your code fragile. We've explored generating [payload models](../asyncapi-payload-generator), [headers](../asyncapi-headers-generator), and [type-safe channels](../asyncapi-types-generator). Now let's see how The Codegen Project's parameters generator creates type-safe models for dynamic channel construction. +Building event-driven applications often requires dynamic channel routing based on parameters like user IDs, tenant identifiers, or resource keys. Manually constructing these parameterized channels is error-prone and makes your code fragile. We've explored generating [payload models](/blog/asyncapi-payload-generator), [headers](/blog/asyncapi-headers-generator), and [type-safe channels](/blog/asyncapi-types-generator). Now let's see how The Codegen Project's parameters generator creates type-safe models for dynamic channel construction. @@ -500,9 +500,9 @@ This will generate the parameter models and run a comprehensive demonstration sh ### Related Protocols - **[NATS Protocol](/docs/protocols/nats)** - Using generated parameters with NATS messaging - **[Kafka Protocol](/docs/protocols/kafka)** - Using generated parameters with Apache Kafka -- **[HTTP Protocol](/docs/protocols/http)** - Using generated parameters with HTTP APIs +- **[HTTP Protocol](/docs/protocols/http_client)** - Using generated parameters with HTTP APIs ### Related Generators -- **[AsyncAPI Payload Generator](../asyncapi-payload-generator)** - Generate type-safe payload models -- **[AsyncAPI Headers Generator](../asyncapi-headers-generator)** - Generate type-safe header models -- **[AsyncAPI Types Generator](../asyncapi-types-generator)** - Generate unified type definitions \ No newline at end of file +- **[AsyncAPI Payload Generator](/blog/asyncapi-payload-generator)** - Generate type-safe payload models +- **[AsyncAPI Headers Generator](/blog/asyncapi-headers-generator)** - Generate type-safe header models +- **[AsyncAPI Types Generator](/blog/asyncapi-types-generator)** - Generate unified type definitions \ No newline at end of file diff --git a/website/blog/2025-07-08-asyncapi-channel-generator/index.md b/website/blog/2025-07-08-asyncapi-channel-generator/index.md index 9494d3a1..c3969e41 100644 --- a/website/blog/2025-07-08-asyncapi-channel-generator/index.md +++ b/website/blog/2025-07-08-asyncapi-channel-generator/index.md @@ -5,7 +5,7 @@ authors: [jonaslagoni] tags: [the-codegen-project, asyncapi, channels, typescript, messaging, protocols, automation] --- -Building scalable event-driven applications requires robust messaging infrastructure that works seamlessly with your chosen protocols. We've covered [payload generation](../asyncapi-payload-generator), [header management](../asyncapi-headers-generator), and [type-safe routing](../asyncapi-types-generator). Now let's explore how The Codegen Project's channels generator creates protocol-specific functions that streamline your messaging architecture while working with your favorite messaging systems. +Building scalable event-driven applications requires robust messaging infrastructure that works seamlessly with your chosen protocols. We've covered [payload generation](/blog/asyncapi-payload-generator), [header management](/blog/asyncapi-headers-generator), and [type-safe routing](/blog/asyncapi-types-generator). Now let's explore how The Codegen Project's channels generator creates protocol-specific functions that streamline your messaging architecture while working with your favorite messaging systems. diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index 5ba4c40d..1b4c0151 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -72,8 +72,15 @@ const config: Config = { // }, // ] ], - onBrokenLinks: 'warn', - onBrokenMarkdownLinks: 'warn', + // Fail the build on a dead link rather than logging it. The docs are copied in + // from the repo root at build time, so a link that resolves in the repo can + // still 404 on the site - warnings let a batch of those accumulate unnoticed. + onBrokenLinks: 'throw', + onBrokenMarkdownLinks: 'throw', + // Anchors stay a warning: the ToCs in docs/usage.md and docs/migrations/v0.md + // are machine-generated, so a heading rename surfaces here before anyone has + // had the chance to regenerate them. + onBrokenAnchors: 'warn', // Even if you don't use internationalization, you can use this field to set // useful metadata like html lang. For example, if your site is Chinese, you diff --git a/website/src/components/HomepageFeatures/index.tsx b/website/src/components/HomepageFeatures/index.tsx index 130f35d9..f7789f8e 100644 --- a/website/src/components/HomepageFeatures/index.tsx +++ b/website/src/components/HomepageFeatures/index.tsx @@ -42,7 +42,10 @@ function Feature({title, Svg, description}: FeatureItem) { return (
- + {/* Label from the card title: the stock illustrations carry unrelated + internal titles, so without this a screen reader announces the wrong + thing (e.g. "Powered by React" for "Powered by Open Source"). */} +
{title} diff --git a/website/src/pages/index.tsx b/website/src/pages/index.tsx index c1aacc03..235b6cff 100644 --- a/website/src/pages/index.tsx +++ b/website/src/pages/index.tsx @@ -32,8 +32,8 @@ export default function Home(): JSX.Element { const {siteConfig} = useDocusaurusContext(); return ( + title={siteConfig.tagline} + description="Generate TypeScript models, protocol helpers and full clients from your AsyncAPI, OpenAPI and JSON Schema documents.">
diff --git a/website/src/schemas/configuration-schema.json b/website/src/schemas/configuration-schema.json index b9057439..1fb2b009 100644 --- a/website/src/schemas/configuration-schema.json +++ b/website/src/schemas/configuration-schema.json @@ -22,6 +22,9 @@ "auth": { "$ref": "#/definitions/AsyncAPICodegenConfiguration/properties/auth" }, + "filter": { + "$ref": "#/definitions/AsyncAPICodegenConfiguration/properties/filter" + }, "language": { "$ref": "#/definitions/AsyncAPICodegenConfiguration/properties/language" }, @@ -199,6 +202,30 @@ ], "markdownDescription": "Authentication for fetching remote input specifications via http(s). Ignored for local file paths. WARNING: these credentials are sent to every URL the loader fetches, including external $ref targets on other hosts. See https://the-codegen-project.org/docs/configurations#auth-scope-and-security-considerations for details." }, + "filter": { + "type": "object", + "properties": { + "include": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "markdownDescription": "Glob patterns (minimatch) selecting which channels/operations (AsyncAPI) or paths/operations (OpenAPI) to include. An empty list includes everything." + }, + "exclude": { + "type": "array", + "items": { + "type": "string" + }, + "default": [], + "markdownDescription": "Glob patterns (minimatch) selecting which channels/operations (AsyncAPI) or paths/operations (OpenAPI) to exclude. Exclude is applied after include, so an excluded item is always dropped. An empty list excludes nothing." + } + }, + "additionalProperties": false, + "default": {}, + "markdownDescription": "Restrict code generation to a subset of the input document using glob patterns. For AsyncAPI, patterns are matched against channel address, channel id, or operation id. For OpenAPI, patterns are matched against the path template or operationId. Component schemas/messages left orphaned by filtering are pruned. Omitting this field (or leaving both lists empty) generates everything, unchanged. [Read more about configurations here](https://the-codegen-project.org/docs/configurations)" + }, "language": { "type": "string", "const": "typescript", diff --git a/website/vercel.json b/website/vercel.json index adebfc9e..2450c598 100644 --- a/website/vercel.json +++ b/website/vercel.json @@ -4,10 +4,11 @@ "outputDirectory": "build", "installCommand": "npm install", "framework": "docusaurus-2", + "_comment": "The /api/mcp rewrite targets the separately deployed mcp-server app. Note this is a deployment-specific URL, so it stops tracking the MCP server after its next deploy and has to be updated by hand; pointing it at that project's production alias instead would make it self-maintaining.", "rewrites": [ { "source": "/api/mcp", - "destination": "https://the-codegen-project-pc9xv8h9a-jonas-lagonis-projects.vercel.app/api/mcp" + "destination": "https://the-codegen-project-bpinxqu5t-jonas-lagonis-projects.vercel.app/api/mcp" } ] } From a93b11345be2a28d6033e61710d107b39aa8f59b Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Fri, 31 Jul 2026 09:36:34 +0200 Subject: [PATCH 4/4] fix(website): drop the invalid vercel.json comment key Vercel validates vercel.json against a schema with `additionalProperties: false`, so the `_comment` key I added alongside the /api/mcp rewrite failed the deployment outright ("Deployment failed" on the-codegen-project, while the-codegen-project-mcp built fine). The note it carried - that the rewrite targets a deployment-specific URL and so needs updating by hand after each MCP deploy - moves to mcp-server/README.md, which can hold prose, along with the reason it cannot live in vercel.json. Co-Authored-By: Claude Opus 5 (1M context) --- mcp-server/README.md | 16 ++++++++++++++++ website/vercel.json | 1 - 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/mcp-server/README.md b/mcp-server/README.md index 493678fc..cb12cc5d 100644 --- a/mcp-server/README.md +++ b/mcp-server/README.md @@ -94,3 +94,19 @@ Add to `~/.claude/mcp.json`: } } ``` + +## Hosting and the public endpoint + +The documented public endpoint, `https://the-codegen-project.org/api/mcp`, is not +served by this app directly. The website rewrites that path to this app's own +deployment (`rewrites` in `website/vercel.json`), because the two are separate +Vercel projects. + +That rewrite currently targets a **deployment-specific** URL, which means it +stops tracking this app the moment it is redeployed, and the endpoint starts +returning `DEPLOYMENT_NOT_FOUND` until someone updates it by hand. Pointing it at +this project's production alias instead would make it self-maintaining. + +Note that `website/vercel.json` is validated against Vercel's schema, which sets +`additionalProperties: false` — an unrecognised key (including one added purely +as a comment) fails the deployment, so this note lives here rather than there. diff --git a/website/vercel.json b/website/vercel.json index 2450c598..b44e0e92 100644 --- a/website/vercel.json +++ b/website/vercel.json @@ -4,7 +4,6 @@ "outputDirectory": "build", "installCommand": "npm install", "framework": "docusaurus-2", - "_comment": "The /api/mcp rewrite targets the separately deployed mcp-server app. Note this is a deployment-specific URL, so it stops tracking the MCP server after its next deploy and has to be updated by hand; pointing it at that project's production alias instead would make it self-maintaining.", "rewrites": [ { "source": "/api/mcp",