From 5edec0a8fd420bfcbb917f5011625dc122955c0e Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Sat, 1 Aug 2026 19:21:21 +0200 Subject: [PATCH 01/16] Phase 1: register the http_server protocol, function type and render parameters Co-Authored-By: Claude Opus 5 (1M context) --- .../generators/typescript/channels/types.ts | 61 ++++++++++++++++++- .../generators/typescript/channels/utils.ts | 5 +- src/codegen/types.ts | 6 ++ 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/codegen/generators/typescript/channels/types.ts b/src/codegen/generators/typescript/channels/types.ts index e4125657..83aa3f43 100644 --- a/src/codegen/generators/typescript/channels/types.ts +++ b/src/codegen/generators/typescript/channels/types.ts @@ -28,6 +28,7 @@ export enum ChannelFunctionTypes { AMQP_QUEUE_SUBSCRIBE = 'amqp_queue_subscribe', AMQP_EXCHANGE_PUBLISH = 'amqp_exchange_publish', HTTP_CLIENT = 'http_client', + HTTP_SERVER = 'http_server', EVENT_SOURCE_FETCH = 'event_source_fetch', EVENT_SOURCE_EXPRESS = 'event_source_express', WEBSOCKET_PUBLISH = 'websocket_publish', @@ -58,7 +59,12 @@ export const receivingFunctionTypes = [ ChannelFunctionTypes.KAFKA_SUBSCRIBE, ChannelFunctionTypes.EVENT_SOURCE_FETCH, ChannelFunctionTypes.AMQP_QUEUE_SUBSCRIBE, - ChannelFunctionTypes.WEBSOCKET_SUBSCRIBE + ChannelFunctionTypes.WEBSOCKET_SUBSCRIBE, + // The HTTP server receives requests. These two lists only feed the + // AsyncAPI-only `shouldRenderFunctionType`, so the classification is cosmetic + // for an OpenAPI-only function type — it is registered to keep both lists + // exhaustive. + ChannelFunctionTypes.HTTP_SERVER ]; export const zodTypescriptChannelsGenerator = z.object({ @@ -101,6 +107,7 @@ export const zodTypescriptChannelsGenerator = z.object({ 'amqp', 'event_source', 'http_client', + 'http_server', 'websocket' ]) ) @@ -344,6 +351,57 @@ export interface RenderHttpParameters { hasSerializeHeaders?: boolean; } +/** + * One returnable response of a generated server handler, derived from the + * operation's declared responses. A variant without a `bodyType` is a declared + * but bodyless status code (e.g. `405` on the petstore's `addPet`) — those exist + * only in the raw document, never in the payload models. + * + * Kept deliberately data-only (no functions) so the parameter object stays plain + * and snapshot-friendly; the renderer derives the marshal expression from it. + */ +export interface HttpServerResponseVariant { + /** The declared status code, or `'default'` for the catch-all response. */ + statusCode: number | 'default'; + /** The response body type as written in generated code, module-qualified where needed. */ + bodyType?: string; + /** The user-facing input type (the `Interface | Class` widening for object models). */ + bodyInputType?: string; + /** The module alias to marshal through, when the body model is module-qualified. */ + bodyModule?: string; + /** + * Whether the body model is an object model with a `marshal()` method. When + * false the body is marshalled with `JSON.stringify` instead. + */ + isObjectModel?: boolean; +} + +export interface RenderHttpServerParameters { + requestTopic: string; + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD'; + /** The request body payload type, or undefined when the operation declares no request body. */ + requestMessageType?: string; + requestMessageModule: string | undefined; + channelParameters: ConstrainedObjectModel | undefined; + channelHeaders?: ConstrainedObjectModel | undefined; + subName?: string; + functionName?: string; + /** Operation description from API specification for JSDoc generation */ + description?: string; + /** Whether the operation is marked as deprecated in the API specification */ + deprecated?: boolean; + /** Read for `includeValidation`, mirroring `RenderRequestReplyParameters`. */ + payloadGenerator: TypeScriptPayloadRenderType; + /** + * Whether the header model exports a standalone `deserializeXHeaders` + * function (OpenAPI plain-object style). When false no inbound header + * deserialization is emitted. Defaults to false. + */ + hasDeserializeHeaders?: boolean; + /** The ordered set of responses the generated handler may return. */ + responses: HttpServerResponseVariant[]; +} + export type SupportedProtocols = | 'nats' | 'kafka' @@ -351,4 +409,5 @@ export type SupportedProtocols = | 'amqp' | 'event_source' | 'http_client' + | 'http_server' | 'websocket'; diff --git a/src/codegen/generators/typescript/channels/utils.ts b/src/codegen/generators/typescript/channels/utils.ts index 133dcae4..d35445d9 100644 --- a/src/codegen/generators/typescript/channels/utils.ts +++ b/src/codegen/generators/typescript/channels/utils.ts @@ -680,8 +680,9 @@ export function groupByTag( * Clean, action-style leaf keys for the `path` organization when a render has * no HTTP method (i.e. AsyncAPI). Mirrors the OpenAPI method leaf so that * `nats.user.signedup.publish` reads like `http_client.pet.put` instead of - * repeating the verbose function name. `HTTP_CLIENT` is intentionally absent: - * it always carries a `method`, which takes precedence over this map. + * repeating the verbose function name. `HTTP_CLIENT` and `HTTP_SERVER` are + * intentionally absent: both always carry a `method`, which takes precedence + * over this map. */ const FUNCTION_TYPE_PATH_LEAF: Partial> = { [ChannelFunctionTypes.NATS_PUBLISH]: 'publish', diff --git a/src/codegen/types.ts b/src/codegen/types.ts index ceaa70d9..2734325d 100644 --- a/src/codegen/types.ts +++ b/src/codegen/types.ts @@ -247,6 +247,12 @@ export interface HttpRenderType { // path parameters. Consumers (e.g. README generation) rely on this to know an // operation requires a `parameters` argument. parameterType?: string; + /** + * The header model type used by the rendered function, when the operation + * declares header parameters. Read by `addRendersToExternal` so the header + * type reaches `TypeScriptChannelRenderedFunctionType`. + */ + headerType?: string; } const SCHEMA_DESCRIPTION = From 64753c6d99f5b54d8941b4c7e57a923603099373 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Sat, 1 Aug 2026 19:43:35 +0200 Subject: [PATCH 02/16] feat: add inbound header deserialization for OpenAPI header models Phases 1-4 of the http_server plan: register the protocol/function type and render parameters, add the runtime behavioral spec (still RED), and emit a deserializeHeaders counterpart to the existing serializer. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/runtime-testing.yml | 4 + src/codegen/generators/typescript/headers.ts | 19 +- .../inputs/openapi/generators/headers.ts | 138 ++- .../__snapshots__/channels.spec.ts.snap | 2 +- .../generators/typescript/channels.spec.ts | 5 +- .../generators/typescript/headers.spec.ts | 44 + .../header-functions.spec.ts.snap | 57 + .../inputs/openapi/header-functions.spec.ts | 123 ++ test/configs/openapi-header-types.json | 58 + .../typescript/codegen-openapi-server.mjs | 37 + test/runtime/typescript/package.json | 6 +- .../openapi-server/channels/http_client.ts | 1009 +++++++++++++++++ .../src/openapi-server/channels/index.ts | 3 + .../FindPetsByStatusAndCategoryHeaders.ts | 41 + .../FindPetsByStatusAndCategoryParameters.ts | 393 +++++++ .../src/openapi-server/parameters/Format.ts | 6 + .../src/openapi-server/parameters/SortBy.ts | 6 + .../openapi-server/parameters/SortOrder.ts | 6 + .../src/openapi-server/parameters/Status.ts | 6 + .../src/openapi-server/payloads/APet.ts | 164 +++ .../src/openapi-server/payloads/AUser.ts | 176 +++ .../payloads/AnUploadedResponse.ts | 113 ++ ...FindPetsByStatusAndCategoryResponse_200.ts | 48 + .../src/openapi-server/payloads/ItemStatus.ts | 10 + .../openapi-server/payloads/PetCategory.ts | 101 ++ .../src/openapi-server/payloads/PetOrder.ts | 153 +++ .../src/openapi-server/payloads/PetTag.ts | 101 ++ .../src/openapi-server/payloads/Status.ts | 10 + .../test/channels/http_server/basics.spec.ts | 486 ++++++++ 29 files changed, 3314 insertions(+), 11 deletions(-) create mode 100644 test/codegen/inputs/openapi/__snapshots__/header-functions.spec.ts.snap create mode 100644 test/codegen/inputs/openapi/header-functions.spec.ts create mode 100644 test/configs/openapi-header-types.json create mode 100644 test/runtime/typescript/codegen-openapi-server.mjs create mode 100644 test/runtime/typescript/src/openapi-server/channels/http_client.ts create mode 100644 test/runtime/typescript/src/openapi-server/channels/index.ts create mode 100644 test/runtime/typescript/src/openapi-server/headers/FindPetsByStatusAndCategoryHeaders.ts create mode 100644 test/runtime/typescript/src/openapi-server/parameters/FindPetsByStatusAndCategoryParameters.ts create mode 100644 test/runtime/typescript/src/openapi-server/parameters/Format.ts create mode 100644 test/runtime/typescript/src/openapi-server/parameters/SortBy.ts create mode 100644 test/runtime/typescript/src/openapi-server/parameters/SortOrder.ts create mode 100644 test/runtime/typescript/src/openapi-server/parameters/Status.ts create mode 100644 test/runtime/typescript/src/openapi-server/payloads/APet.ts create mode 100644 test/runtime/typescript/src/openapi-server/payloads/AUser.ts create mode 100644 test/runtime/typescript/src/openapi-server/payloads/AnUploadedResponse.ts create mode 100644 test/runtime/typescript/src/openapi-server/payloads/FindPetsByStatusAndCategoryResponse_200.ts create mode 100644 test/runtime/typescript/src/openapi-server/payloads/ItemStatus.ts create mode 100644 test/runtime/typescript/src/openapi-server/payloads/PetCategory.ts create mode 100644 test/runtime/typescript/src/openapi-server/payloads/PetOrder.ts create mode 100644 test/runtime/typescript/src/openapi-server/payloads/PetTag.ts create mode 100644 test/runtime/typescript/src/openapi-server/payloads/Status.ts create mode 100644 test/runtime/typescript/test/channels/http_server/basics.spec.ts diff --git a/.github/workflows/runtime-testing.yml b/.github/workflows/runtime-testing.yml index ced73574..383de12e 100644 --- a/.github/workflows/runtime-testing.yml +++ b/.github/workflows/runtime-testing.yml @@ -67,6 +67,10 @@ jobs: service: '' test-command: 'test:http' needs-service: false + - test-type: 'HTTP Server' + service: '' + test-command: 'test:http-server' + needs-service: false - test-type: 'Regular Tests' service: '' test-command: 'test:regular' diff --git a/src/codegen/generators/typescript/headers.ts b/src/codegen/generators/typescript/headers.ts index 461f6898..4cae01fa 100644 --- a/src/codegen/generators/typescript/headers.ts +++ b/src/codegen/generators/typescript/headers.ts @@ -139,7 +139,17 @@ function createAsyncAPIHeadersGenerator( }); } -function appendOpenAPISerializerFunctions( +/** + * Appends the inbound/outbound header functions to each generated OpenAPI + * header model file and registers their names. + * + * Both names land in `headerFunctions`, which drives the import list in + * `addHeadersToDependencies`, so every protocol file imports both. The + * `deserialize*` import is unused in `http_client.ts` — harmless, since nothing + * in the generated-output tsconfigs sets `noUnusedLocals`, and keeping one + * import list per header model is simpler than making it protocol-aware. + */ +function appendOpenAPIHeaderFunctions( result: {models: OutputModel[]; files: GeneratedFile[]}, headerFunctions: Record ): void { @@ -160,7 +170,10 @@ function appendOpenAPISerializerFunctions( }; } const modelName = constrainedModel.name; - headerFunctions[modelName] = [`serialize${modelName}Headers`]; + headerFunctions[modelName] = [ + `serialize${modelName}Headers`, + `deserialize${modelName}Headers` + ]; } } @@ -217,7 +230,7 @@ export async function generateTypescriptHeadersCore({ importExtension }); if (isOpenAPI) { - appendOpenAPISerializerFunctions(result, headerFunctions); + appendOpenAPIHeaderFunctions(result, headerFunctions); } channelModels[channelId] = result.models.length > 0 ? result.models[0] : undefined; diff --git a/src/codegen/inputs/openapi/generators/headers.ts b/src/codegen/inputs/openapi/generators/headers.ts index 9f71ae12..e99f45bc 100644 --- a/src/codegen/inputs/openapi/generators/headers.ts +++ b/src/codegen/inputs/openapi/generators/headers.ts @@ -11,7 +11,13 @@ import { isHttpMethod } from '../utils'; import { + ConstrainedArrayModel, + ConstrainedBooleanModel, + ConstrainedFloatModel, + ConstrainedIntegerModel, + ConstrainedMetaModel, ConstrainedObjectModel, + ConstrainedReferenceModel, TS_DESCRIPTION_PRESET, TypeScriptFileGenerator } from '@asyncapi/modelina'; @@ -26,11 +32,36 @@ export function createOpenAPIHeadersGenerator() { }); } -export function generateOpenAPIHeaderFunctions( - model: ConstrainedObjectModel -): string { - const modelName = model.name; +/** + * A header is always text on the wire, so the deserializer has to invert the + * serializer's `String(...)` per property type. The decision is made on the + * constrained model itself (never a string match on `type`); a reference model + * — an enum, for instance — is unwrapped so the model it points at decides. + */ +type HeaderWireKind = 'number' | 'boolean' | 'array' | 'text'; + +function resolveHeaderWireKind(property: ConstrainedMetaModel): HeaderWireKind { + const model = + property instanceof ConstrainedReferenceModel ? property.ref : property; + if ( + model instanceof ConstrainedIntegerModel || + model instanceof ConstrainedFloatModel + ) { + return 'number'; + } + if (model instanceof ConstrainedBooleanModel) { + return 'boolean'; + } + if (model instanceof ConstrainedArrayModel) { + return 'array'; + } + // Strings, enums and unions all arrive as text and are cast to the declared + // property type. + return 'text'; +} +function renderHeaderSerializer(model: ConstrainedObjectModel): string { + const modelName = model.name; const headerMappings = Object.values(model.properties) .map((prop) => { const wireName = prop.unconstrainedPropertyName; @@ -46,6 +77,105 @@ ${headerMappings} }`; } +function renderHeaderPropertyDeserialization({ + modelName, + tsName, + lookupName, + kind +}: { + modelName: string; + tsName: string; + lookupName: string; + kind: HeaderWireKind; +}): string { + const valueVariable = `${tsName}Value`; + if (kind === 'array') { + return ` const ${valueVariable} = readHeaderList('${lookupName}'); + if (${valueVariable} !== undefined) { result.${tsName} = ${valueVariable} as ${modelName}['${tsName}']; }`; + } + if (kind === 'number') { + return ` const ${valueVariable} = readHeader('${lookupName}'); + if (${valueVariable} !== undefined) { + const ${tsName}Number = Number(${valueVariable}); + if (!Number.isNaN(${tsName}Number)) { result.${tsName} = ${tsName}Number; } + }`; + } + if (kind === 'boolean') { + return ` const ${valueVariable} = readHeader('${lookupName}'); + if (${valueVariable} !== undefined) { result.${tsName} = ${valueVariable} === 'true'; }`; + } + return ` const ${valueVariable} = readHeader('${lookupName}'); + if (${valueVariable} !== undefined) { result.${tsName} = ${valueVariable} as ${modelName}['${tsName}']; }`; +} + +function renderHeaderDeserializer(model: ConstrainedObjectModel): string { + const modelName = model.name; + const signature = `export function deserialize${modelName}Headers(headers: Record): ${modelName}`; + const properties = Object.values(model.properties).map((prop) => ({ + tsName: prop.propertyName, + // Header names are case-insensitive on the wire and Node lower-cases the + // inbound ones, so everything is matched against a lower-cased view. + lookupName: prop.unconstrainedPropertyName.toLowerCase(), + kind: resolveHeaderWireKind(prop.property) + })); + + if (properties.length === 0) { + return `${signature} { + return {} as ${modelName}; +}`; + } + + const readHeader = properties.some((prop) => prop.kind !== 'array') + ? ` const readHeader = (name: string): string | undefined => { + const value = normalized[name]; + if (value === undefined) { return undefined; } + return Array.isArray(value) ? value[0] : value; + }; +` + : ''; + const readHeaderList = properties.some((prop) => prop.kind === 'array') + ? ` const readHeaderList = (name: string): string[] | undefined => { + const value = normalized[name]; + if (value === undefined) { return undefined; } + // \`serialize${modelName}Headers\` comma-joins arrays through String(...), + // so one value can carry several entries. + return Array.isArray(value) ? value : String(value).split(','); + }; +` + : ''; + const assignments = properties + .map((prop) => renderHeaderPropertyDeserialization({modelName, ...prop})) + .join('\n'); + + return `${signature} { + // Header names are case-insensitive on the wire, so match on a lower-cased + // view of whatever arrived (Express hands over lower-cased names already). + const normalized: Record = {}; + for (const [name, value] of Object.entries(headers)) { + normalized[name.toLowerCase()] = value; + } +${readHeader}${readHeaderList} // Built up property by property; an absent header leaves its property absent + // rather than assigning undefined, so required properties stay required. + const result = {} as ${modelName}; +${assignments} + return result; +}`; +} + +/** + * Emits both directions for an OpenAPI header model: `serializeHeaders` + * for outbound requests (the HTTP client) and `deserializeHeaders` for + * inbound ones (the HTTP server). OpenAPI header models are interfaces, so both + * are free functions rather than methods. + */ +export function generateOpenAPIHeaderFunctions( + model: ConstrainedObjectModel +): string { + return `${renderHeaderSerializer(model)} + +${renderHeaderDeserializer(model)}`; +} + // Helper function to convert OpenAPI header schema to JSON Schema function convertHeaderSchemaToJsonSchema(header: any): any { let schema: any; diff --git a/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap b/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap index ec179b25..2f9b1b9a 100644 --- a/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap +++ b/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap @@ -11,7 +11,7 @@ exports[`channels typescript OpenAPI input should generate HTTP client protocol "import {Pet, PetInterface} from './../../../../../payloads/Pet'; import * as FindPetsByStatusAndCategoryResponseModule from './../../../../../payloads/FindPetsByStatusAndCategoryResponse'; import {FindPetsByStatusAndCategoryParameters, FindPetsByStatusAndCategoryParametersInterface} from './../../../../../parameters/FindPetsByStatusAndCategoryParameters'; -import {FindPetsByStatusAndCategoryHeaders, serializeFindPetsByStatusAndCategoryHeadersHeaders} from './../../../../../headers/FindPetsByStatusAndCategoryHeaders'; +import {FindPetsByStatusAndCategoryHeaders, serializeFindPetsByStatusAndCategoryHeadersHeaders, deserializeFindPetsByStatusAndCategoryHeadersHeaders} from './../../../../../headers/FindPetsByStatusAndCategoryHeaders'; // ============================================================================ // Common Types - Shared across all HTTP client functions diff --git a/test/codegen/generators/typescript/channels.spec.ts b/test/codegen/generators/typescript/channels.spec.ts index ec6a8889..389ebbe8 100644 --- a/test/codegen/generators/typescript/channels.spec.ts +++ b/test/codegen/generators/typescript/channels.spec.ts @@ -761,7 +761,10 @@ describe('channels', () => { generator: {outputPath: './headers'} as any, files: [], headerFunctions: { - FindPetsByStatusAndCategoryHeaders: ['serializeFindPetsByStatusAndCategoryHeadersHeaders'] + FindPetsByStatusAndCategoryHeaders: [ + 'serializeFindPetsByStatusAndCategoryHeadersHeaders', + 'deserializeFindPetsByStatusAndCategoryHeadersHeaders' + ] } }; diff --git a/test/codegen/generators/typescript/headers.spec.ts b/test/codegen/generators/typescript/headers.spec.ts index 5412d398..3df1c1cc 100644 --- a/test/codegen/generators/typescript/headers.spec.ts +++ b/test/codegen/generators/typescript/headers.spec.ts @@ -81,5 +81,49 @@ describe('headers', () => { }); expect(renderedContent.channelModels['deletePet']!.result).toMatchSnapshot(); }); + it('should register both header functions for OpenAPI inputs', async () => { + const parsedOpenAPIDocument = await loadOpenapiDocument({documentPath: path.resolve(__dirname, '../../../configs/openapi-3.json')}); + + const renderedContent = await generateTypescriptHeaders({ + generator: { + outputPath: path.resolve(__dirname, './output'), + preset: 'headers', + language: 'typescript', + dependencies: [], + serializationType: 'json', + includeValidation: true, + id: 'test' + }, + inputType: 'openapi', + openapiDocument: parsedOpenAPIDocument, + dependencyOutputs: { } + }); + const headerFunctions = renderedContent.headerFunctions ?? {}; + for (const functionNames of Object.values(headerFunctions)) { + expect(functionNames).toHaveLength(2); + expect(functionNames[0]).toMatch(/^serialize.*Headers$/); + expect(functionNames[1]).toMatch(/^deserialize.*Headers$/); + } + expect(Object.keys(headerFunctions).length).toBeGreaterThan(0); + }); + it('should register no header functions for AsyncAPI inputs', async () => { + const parsedAsyncAPIDocument = await loadAsyncapiDocument({documentPath: path.resolve(__dirname, '../../../configs/headers.yaml')}); + + const renderedContent = await generateTypescriptHeaders({ + generator: { + outputPath: path.resolve(__dirname, './output'), + preset: 'headers', + language: 'typescript', + dependencies: [], + serializationType: 'json', + id: 'test', + includeValidation: true + }, + inputType: 'asyncapi', + asyncapiDocument: parsedAsyncAPIDocument, + dependencyOutputs: { } + }); + expect(renderedContent.headerFunctions).toEqual({}); + }); }); }); diff --git a/test/codegen/inputs/openapi/__snapshots__/header-functions.spec.ts.snap b/test/codegen/inputs/openapi/__snapshots__/header-functions.spec.ts.snap new file mode 100644 index 00000000..c4af5381 --- /dev/null +++ b/test/codegen/inputs/openapi/__snapshots__/header-functions.spec.ts.snap @@ -0,0 +1,57 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`OpenAPI header functions should match the snapshot of the generated header functions 1`] = ` +"export function serializeListThingsHeadersHeaders(headers: ListThingsHeaders): Record { + const result: Record = {}; + if (headers.xMinusRequestMinusId !== undefined) { result['X-Request-ID'] = String(headers.xMinusRequestMinusId); } + if (headers.xMinusRetryMinusCount !== undefined) { result['X-Retry-Count'] = String(headers.xMinusRetryMinusCount); } + if (headers.xMinusRatio !== undefined) { result['X-Ratio'] = String(headers.xMinusRatio); } + if (headers.xMinusDebug !== undefined) { result['X-Debug'] = String(headers.xMinusDebug); } + if (headers.xMinusTags !== undefined) { result['X-Tags'] = String(headers.xMinusTags); } + if (headers.xMinusMode !== undefined) { result['X-Mode'] = String(headers.xMinusMode); } + return result; +} + +export function deserializeListThingsHeadersHeaders(headers: Record): ListThingsHeaders { + // Header names are case-insensitive on the wire, so match on a lower-cased + // view of whatever arrived (Express hands over lower-cased names already). + const normalized: Record = {}; + for (const [name, value] of Object.entries(headers)) { + normalized[name.toLowerCase()] = value; + } + const readHeader = (name: string): string | undefined => { + const value = normalized[name]; + if (value === undefined) { return undefined; } + return Array.isArray(value) ? value[0] : value; + }; + const readHeaderList = (name: string): string[] | undefined => { + const value = normalized[name]; + if (value === undefined) { return undefined; } + // \`serializeListThingsHeadersHeaders\` comma-joins arrays through String(...), + // so one value can carry several entries. + return Array.isArray(value) ? value : String(value).split(','); + }; + // Built up property by property; an absent header leaves its property absent + // rather than assigning undefined, so required properties stay required. + const result = {} as ListThingsHeaders; + const xMinusRequestMinusIdValue = readHeader('x-request-id'); + if (xMinusRequestMinusIdValue !== undefined) { result.xMinusRequestMinusId = xMinusRequestMinusIdValue as ListThingsHeaders['xMinusRequestMinusId']; } + const xMinusRetryMinusCountValue = readHeader('x-retry-count'); + if (xMinusRetryMinusCountValue !== undefined) { + const xMinusRetryMinusCountNumber = Number(xMinusRetryMinusCountValue); + if (!Number.isNaN(xMinusRetryMinusCountNumber)) { result.xMinusRetryMinusCount = xMinusRetryMinusCountNumber; } + } + const xMinusRatioValue = readHeader('x-ratio'); + if (xMinusRatioValue !== undefined) { + const xMinusRatioNumber = Number(xMinusRatioValue); + if (!Number.isNaN(xMinusRatioNumber)) { result.xMinusRatio = xMinusRatioNumber; } + } + const xMinusDebugValue = readHeader('x-debug'); + if (xMinusDebugValue !== undefined) { result.xMinusDebug = xMinusDebugValue === 'true'; } + const xMinusTagsValue = readHeaderList('x-tags'); + if (xMinusTagsValue !== undefined) { result.xMinusTags = xMinusTagsValue as ListThingsHeaders['xMinusTags']; } + const xMinusModeValue = readHeader('x-mode'); + if (xMinusModeValue !== undefined) { result.xMinusMode = xMinusModeValue as ListThingsHeaders['xMinusMode']; } + return result; +}" +`; diff --git a/test/codegen/inputs/openapi/header-functions.spec.ts b/test/codegen/inputs/openapi/header-functions.spec.ts new file mode 100644 index 00000000..0ab29b2e --- /dev/null +++ b/test/codegen/inputs/openapi/header-functions.spec.ts @@ -0,0 +1,123 @@ +import path from 'node:path'; +import {loadOpenapiDocument} from '../../../../src/codegen/inputs/openapi'; +import {generateTypescriptHeaders} from '../../../../src/codegen/generators/typescript/headers'; + +/** + * Drives the whole headers generator with a purpose-built OpenAPI document + * rather than hand-building `ConstrainedObjectModel`s — the constrained naming + * (`X-Request-ID` -> `xMinusRequestMinusId`) is exactly what the deserializer + * has to invert, so exercising the real pipeline is far less brittle. + */ +async function generateHeaderFile(): Promise<{ + content: string; + headerFunctions: Record; +}> { + const openapiDocument = await loadOpenapiDocument({ + documentPath: path.resolve( + __dirname, + '../../../configs/openapi-header-types.json' + ) + }); + + const rendered = await generateTypescriptHeaders({ + generator: { + outputPath: path.resolve(__dirname, './output'), + preset: 'headers', + language: 'typescript', + dependencies: [], + serializationType: 'json', + includeValidation: true, + id: 'test' + }, + inputType: 'openapi', + openapiDocument, + dependencyOutputs: {} + }); + + const file = rendered.files.find((candidate) => + candidate.path.endsWith('ListThingsHeaders.ts') + ); + if (!file) { + throw new Error('Expected a ListThingsHeaders.ts file to be generated'); + } + return { + content: file.content, + headerFunctions: rendered.headerFunctions ?? {} + }; +} + +describe('OpenAPI header functions', () => { + it('should emit both a serializer and a deserializer', async () => { + const {content} = await generateHeaderFile(); + + expect(content).toContain( + 'export function serializeListThingsHeadersHeaders' + ); + expect(content).toContain( + 'export function deserializeListThingsHeadersHeaders' + ); + }); + + it('should map wire names back to their TypeScript property names', async () => { + const {content} = await generateHeaderFile(); + const deserializer = content.slice( + content.indexOf('export function deserializeListThingsHeadersHeaders') + ); + + // The serializer maps TS name -> wire name... + expect(content).toContain( + "result['X-Request-ID'] = String(headers.xMinusRequestMinusId)" + ); + // ...and the deserializer is its exact inverse. It matches on the + // lower-cased wire name so Express' `x-request-id` is found too. + expect(deserializer).toContain('name.toLowerCase()'); + expect(deserializer).toContain("readHeader('x-request-id')"); + expect(deserializer).toContain('result.xMinusRequestMinusId ='); + }); + + it('should coerce each header type back from its string wire form', async () => { + const {content} = await generateHeaderFile(); + const deserializer = content.slice( + content.indexOf('export function deserializeListThingsHeadersHeaders') + ); + + // number/integer + expect(deserializer).toContain('Number('); + expect(deserializer).toContain('Number.isNaN'); + // boolean + expect(deserializer).toContain("=== 'true'"); + // array + expect(deserializer).toContain(".split(',')"); + }); + + it('should leave an absent header absent rather than assigning undefined', async () => { + const {content} = await generateHeaderFile(); + const deserializer = content.slice( + content.indexOf('export function deserializeListThingsHeadersHeaders') + ); + + // Every assignment must sit behind a presence check. + const assignments = deserializer.match(/result\.[a-zA-Z]+ = /g) ?? []; + expect(assignments.length).toBeGreaterThan(0); + expect(deserializer).toContain('!== undefined'); + }); + + it('should register both function names for the model', async () => { + const {headerFunctions} = await generateHeaderFile(); + + expect(headerFunctions['ListThingsHeaders']).toEqual([ + 'serializeListThingsHeadersHeaders', + 'deserializeListThingsHeadersHeaders' + ]); + }); + + it('should match the snapshot of the generated header functions', async () => { + const {content} = await generateHeaderFile(); + + expect( + content.slice( + content.indexOf('export function serializeListThingsHeadersHeaders') + ) + ).toMatchSnapshot(); + }); +}); diff --git a/test/configs/openapi-header-types.json b/test/configs/openapi-header-types.json new file mode 100644 index 00000000..6dea7b79 --- /dev/null +++ b/test/configs/openapi-header-types.json @@ -0,0 +1,58 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Header type coverage", + "version": "1.0.0" + }, + "paths": { + "/things": { + "get": { + "operationId": "listThings", + "parameters": [ + { + "name": "X-Request-ID", + "in": "header", + "description": "Unique request identifier for tracing", + "schema": {"type": "string"} + }, + { + "name": "X-Retry-Count", + "in": "header", + "schema": {"type": "integer"} + }, + { + "name": "X-Ratio", + "in": "header", + "schema": {"type": "number"} + }, + { + "name": "X-Debug", + "in": "header", + "schema": {"type": "boolean"} + }, + { + "name": "X-Tags", + "in": "header", + "schema": {"type": "array", "items": {"type": "string"}} + }, + { + "name": "X-Mode", + "in": "header", + "required": true, + "schema": {"type": "string", "enum": ["fast", "slow"]} + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": {"type": "object", "properties": {"id": {"type": "string"}}} + } + } + } + } + } + } + } +} diff --git a/test/runtime/typescript/codegen-openapi-server.mjs b/test/runtime/typescript/codegen-openapi-server.mjs new file mode 100644 index 00000000..72c3d28c --- /dev/null +++ b/test/runtime/typescript/codegen-openapi-server.mjs @@ -0,0 +1,37 @@ +/** + * Generates BOTH HTTP protocols from the same OpenAPI document. + * + * Generating them together is deliberate: it proves the two protocol files + * coexist, that the shared `HttpError` name does not collide across files, and + * that the `deserialize*Headers` import added to `http_client.ts` type-checks. + * + * @type {import("../../../dist").TheCodegenConfiguration} + **/ +export default { + inputType: 'openapi', + inputPath: '../openapi-3.json', + language: 'typescript', + generators: [ + { + preset: 'payloads', + outputPath: './src/openapi-server/payloads', + serializationType: 'json', + // Explicit (matches the default) so the request-validation path is + // always exercised by the runtime spec. + includeValidation: true, + }, + { + preset: 'parameters', + outputPath: './src/openapi-server/parameters', + }, + { + preset: 'headers', + outputPath: './src/openapi-server/headers', + }, + { + preset: 'channels', + outputPath: './src/openapi-server/channels', + protocols: ['http_client', 'http_server'] + } + ] +}; diff --git a/test/runtime/typescript/package.json b/test/runtime/typescript/package.json index 7d933a18..68155342 100644 --- a/test/runtime/typescript/package.json +++ b/test/runtime/typescript/package.json @@ -1,6 +1,6 @@ { "scripts": { - "test": "npm run test:kafka && npm run test:nats && npm run test:mqtt && npm run test:amqp && npm run test:eventsource && npm run test:websocket && npm run test:http && npm run test:organization && npm run test:regular && npm run test:payload-types && npm run test:filter", + "test": "npm run test:kafka && npm run test:nats && npm run test:mqtt && npm run test:amqp && npm run test:eventsource && npm run test:websocket && npm run test:http && npm run test:http-server && npm run test:organization && npm run test:regular && npm run test:payload-types && npm run test:filter", "test:organization": "jest -- ./test/channels/organization/", "test:regular": "jest -- ./test/headers.spec.ts ./test/parameters.spec.ts ./test/payloads.spec.ts ./test/types.spec.ts ./test/jsdoc.spec.ts", "test:kafka": "jest -- ./test/channels/regular/kafka.spec.ts", @@ -12,9 +12,10 @@ "test:eventsource": "jest -- ./test/channels/regular/eventsource.spec.ts", "test:websocket": "jest -- ./test/channels/regular/websocket.spec.ts", "test:http": "jest -- ./test/channels/request_reply/http_client/", + "test:http-server": "jest -- ./test/channels/http_server/", "test:payload-types": "jest -- ./test/payload-types/", "test:filter": "jest -- ./test/filter.spec.ts", - "generate": "npm run generate:regular && npm run generate:request:reply && npm run generate:openapi && npm run generate:openapi-primitive && npm run generate:payload-types && npm run generate:organization && npm run generate:node16 && npm run generate:filter", + "generate": "npm run generate:regular && npm run generate:request:reply && npm run generate:openapi && npm run generate:openapi-server && npm run generate:openapi-primitive && npm run generate:payload-types && npm run generate:organization && npm run generate:node16 && npm run generate:filter", "generate:filter": "npm run generate:filter:asyncapi && npm run generate:filter:openapi", "generate:filter:asyncapi": "cross-env CODEGEN_TELEMETRY_DISABLED=1 node ../../../bin/run.mjs generate ./codegen-asyncapi-filter.mjs", "generate:filter:openapi": "cross-env CODEGEN_TELEMETRY_DISABLED=1 node ../../../bin/run.mjs generate ./codegen-openapi-filter.mjs", @@ -26,6 +27,7 @@ "generate:request:reply": "cross-env CODEGEN_TELEMETRY_DISABLED=1 node ../../../bin/run.mjs generate ./codegen-request-reply.mjs", "generate:regular": "cross-env CODEGEN_TELEMETRY_DISABLED=1 node ../../../bin/run.mjs generate ./codegen-regular.mjs", "generate:openapi": "cross-env CODEGEN_TELEMETRY_DISABLED=1 node ../../../bin/run.mjs generate ./codegen-openapi.mjs", + "generate:openapi-server": "cross-env CODEGEN_TELEMETRY_DISABLED=1 node ../../../bin/run.mjs generate ./codegen-openapi-server.mjs", "generate:openapi-primitive": "cross-env CODEGEN_TELEMETRY_DISABLED=1 node ../../../bin/run.mjs generate ./codegen-openapi-primitive.mjs", "generate:payload-types": "cross-env CODEGEN_TELEMETRY_DISABLED=1 node ../../../bin/run.mjs generate ./codegen-payload-types.mjs", "generate:node16": "cross-env CODEGEN_TELEMETRY_DISABLED=1 node ../../../bin/run.mjs generate ./codegen-node16.mjs", diff --git a/test/runtime/typescript/src/openapi-server/channels/http_client.ts b/test/runtime/typescript/src/openapi-server/channels/http_client.ts new file mode 100644 index 00000000..269cd211 --- /dev/null +++ b/test/runtime/typescript/src/openapi-server/channels/http_client.ts @@ -0,0 +1,1009 @@ +import {APet, APetInterface} from './../payloads/APet'; +import * as FindPetsByStatusAndCategoryResponse_200Module from './../payloads/FindPetsByStatusAndCategoryResponse_200'; +import {PetCategory, PetCategoryInterface} from './../payloads/PetCategory'; +import {PetTag, PetTagInterface} from './../payloads/PetTag'; +import {Status} from './../payloads/Status'; +import {ItemStatus} from './../payloads/ItemStatus'; +import {PetOrder, PetOrderInterface} from './../payloads/PetOrder'; +import {AUser, AUserInterface} from './../payloads/AUser'; +import {AnUploadedResponse, AnUploadedResponseInterface} from './../payloads/AnUploadedResponse'; +import {FindPetsByStatusAndCategoryParameters, FindPetsByStatusAndCategoryParametersInterface} from './../parameters/FindPetsByStatusAndCategoryParameters'; +import {FindPetsByStatusAndCategoryHeaders, serializeFindPetsByStatusAndCategoryHeadersHeaders, deserializeFindPetsByStatusAndCategoryHeadersHeaders} from './../headers/FindPetsByStatusAndCategoryHeaders'; + +// ============================================================================ +// 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 + */ +export interface HttpResponse { + ok: boolean; + status: number; + statusText: string; + headers?: Headers | Record; + json: () => Record | Promise>; +} + +/** + * Rich response wrapper returned by HTTP client functions + */ +export interface HttpClientResponse { + /** The deserialized response payload */ + data: T; + /** HTTP status code */ + status: number; + /** HTTP status text */ + statusText: string; + /** Response headers */ + headers: Record; + /** Raw JSON response before deserialization */ + rawData: Record; +} + +/** + * Error thrown for non-OK HTTP responses. + * + * Carries the HTTP `status`, `statusText`, and the parsed response `body` + * (when the error response had a JSON body). Thrown by `handleHttpError` and + * routed through the `onError` hook / retry logic unchanged. + */ +export class HttpError extends HttpGlobalError { + status: number; + statusText: string; + body?: unknown; + + constructor(message: string, status: number, statusText: string, body?: unknown) { + super(message); + this.name = 'HttpError'; + this.status = status; + this.statusText = statusText; + this.body = body; + } +} + +/** + * HTTP request parameters passed to the request hook + */ +export interface HttpRequestParams { + url: string; + headers?: Record; + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD'; + credentials?: 'omit' | 'include' | 'same-origin'; + body?: any; +} + +/** + * Token response structure for OAuth2 flows + */ +export interface TokenResponse { + accessToken: string; + refreshToken?: string; + expiresIn?: number; +} + +// ============================================================================ +// Security Configuration Types - Grouped for better autocomplete +// ============================================================================ + +/** + * API key authentication configuration + */ +export interface ApiKeyAuth { + type: 'apiKey'; + key: string; + name?: string; // Name of the API key parameter (default: 'api_key') + in?: 'header' | 'query'; // Where to place the API key (default: 'header') +} + +/** + * OAuth2 authentication configuration + * + * Supports server-side flows only: + * - client_credentials: Server-to-server authentication + * - password: Resource owner password credentials (legacy, not recommended) + * - Pre-obtained accessToken: For tokens obtained via browser-based flows + * + * For browser-based flows (implicit, authorization_code), obtain the token + * separately and pass it as accessToken. + * Authorization URL: 'http://petstore.swagger.io/api/oauth/dialog' + */ +export interface OAuth2Auth { + type: 'oauth2'; + /** Pre-obtained access token (required if not using a server-side flow) */ + accessToken?: string; + /** Refresh token for automatic token renewal on 401 */ + refreshToken?: string; + /** Token endpoint URL (required for client_credentials/password flows and token refresh) */ + tokenUrl?: string; + /** Client ID (required for flows and token refresh) */ + clientId?: string; + /** Client secret (optional, depends on OAuth provider) */ + clientSecret?: string; + /** Requested scopes Available: write:pets, read:pets */ + scopes?: string[]; + /** Server-side flow type */ + flow?: 'password' | 'client_credentials'; + /** Username for password flow */ + username?: string; + /** Password for password flow */ + password?: string; + /** Callback when tokens are refreshed (for caching/persistence) */ + onTokenRefresh?: (newTokens: TokenResponse) => void; +} + +/** + * Union type for all authentication methods - provides autocomplete support + */ +export type AuthConfig = ApiKeyAuth | OAuth2Auth; + +/** + * Feature flags indicating which auth types are available. + * Used internally to conditionally call auth-specific helpers. + */ +const AUTH_FEATURES = { + oauth2: true +} as const; + +/** + * Default values for API key authentication derived from the spec. + * These match the defaults documented in the ApiKeyAuth interface. + */ +const API_KEY_DEFAULTS = { + name: 'api_key', + in: 'header' as 'header' | 'query' | 'cookie' +} as const; + +// ============================================================================ +// Retry Configuration +// ============================================================================ + +/** + * Retry policy configuration for failed requests + */ +export interface RetryConfig { + maxRetries?: number; // Maximum number of retry attempts (default: 3) + initialDelayMs?: number; // Initial delay before first retry (default: 1000) + maxDelayMs?: number; // Maximum delay between retries (default: 30000) + 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: HttpGlobalError) => void; // Callback on each retry +} + +// ============================================================================ +// Hooks Configuration - Extensible callback system +// ============================================================================ + +/** + * Hooks for customizing HTTP client behavior + */ +export interface HttpHooks { + /** + * Called before each request to transform/modify the request parameters + * Return modified params or undefined to use original + */ + beforeRequest?: (params: HttpRequestParams) => HttpRequestParams | Promise; + + /** + * The actual request implementation - allows swapping fetch for axios, etc. + * Default: uses the global fetch (Node.js 18+) + */ + makeRequest?: (params: HttpRequestParams) => Promise; + + /** + * Called after each response for logging, metrics, etc. + * Can transform the response before it's processed + */ + afterResponse?: (response: HttpResponse, params: HttpRequestParams) => HttpResponse | Promise; + + /** + * Called on request error for logging, error transformation, etc. + */ + onError?: (error: HttpGlobalError, params: HttpRequestParams) => HttpGlobalError | Promise; +} + +// ============================================================================ +// Common Request Context +// ============================================================================ + +/** + * Base context shared by all HTTP client functions + */ +export interface HttpClientContext { + baseUrl?: string; + + // Authentication - grouped for better autocomplete + auth?: AuthConfig; + + // Retry configuration + retry?: RetryConfig; + + // Hooks for extensibility + hooks?: HttpHooks; + + // Additional options + additionalHeaders?: Record; + + // Extra query parameters not covered by the typed parameters interface + additionalQueryParams?: Record; +} + +// ============================================================================ +// Helper Functions - Shared logic extracted for reuse +// ============================================================================ + +/** + * Default retry configuration + */ +const DEFAULT_RETRY_CONFIG: Required = { + maxRetries: 3, + initialDelayMs: 1000, + maxDelayMs: 30000, + backoffMultiplier: 2, + retryableStatusCodes: [408, 429, 500, 502, 503, 504], + retryOnNetworkError: true, + onRetry: () => {}, +}; + +/** + * Default request hook implementation using the global fetch (Node.js 18+) + */ +const defaultMakeRequest = async (params: HttpRequestParams): Promise => { + // Build a Headers object so multi-value headers (string[]) are preserved - + // the global fetch's HeadersInit only accepts string values in a plain object. + const headers = new Headers(); + for (const [name, value] of Object.entries(params.headers ?? {})) { + if (Array.isArray(value)) { + for (const entry of value) { + headers.append(name, entry); + } + } else { + headers.set(name, value); + } + } + return fetch(params.url, { + body: params.body, + method: params.method, + headers + }) as unknown as HttpResponse; +}; + +/** + * Apply authentication to headers and URL based on auth config + */ +function applyAuth( + auth: AuthConfig | undefined, + headers: Record, + url: string +): { headers: Record; url: string } { + if (!auth) return { headers, url }; + + switch (auth.type) { + case 'apiKey': { + const keyName = auth.name ?? API_KEY_DEFAULTS.name; + const keyIn = auth.in ?? API_KEY_DEFAULTS.in; + + if (keyIn === 'header') { + headers[keyName] = auth.key; + } else if (keyIn === 'query') { + const separator = url.includes('?') ? '&' : '?'; + url = `${url}${separator}${keyName}=${encodeURIComponent(auth.key)}`; + } else if (keyIn === 'cookie') { + headers['Cookie'] = `${keyName}=${auth.key}`; + } + break; + } + + case 'oauth2': { + // If we have an access token, use it directly + // Token flows (client_credentials, password) are handled separately + if (auth.accessToken) { + headers['Authorization'] = `Bearer ${auth.accessToken}`; + } + break; + } + } + + return { headers, url }; +} + +/** + * Apply query parameters to URL + */ +function applyQueryParams(queryParams: Record | undefined, url: string): string { + if (!queryParams) return url; + + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined) { + params.append(key, String(value)); + } + } + + const paramString = params.toString(); + if (!paramString) return url; + + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}${paramString}`; +} + +/** + * Sleep for a specified number of milliseconds + */ +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Calculate delay for exponential backoff + */ +function calculateBackoffDelay( + attempt: number, + config: Required +): number { + const delay = config.initialDelayMs * Math.pow(config.backoffMultiplier, attempt - 1); + return Math.min(delay, config.maxDelayMs); +} + +/** + * Determine if a request should be retried based on error/response + */ +function shouldRetry( + error: HttpGlobalError | null, + response: HttpResponse | null, + config: Required, + attempt: number +): boolean { + if (attempt >= config.maxRetries) return false; + + if (error && config.retryOnNetworkError) return true; + + if (response && config.retryableStatusCodes.includes(response.status)) return true; + + return false; +} + +/** + * Execute request with retry logic + */ +async function executeWithRetry( + params: HttpRequestParams, + makeRequest: (params: HttpRequestParams) => Promise, + retryConfig?: RetryConfig +): Promise { + const config = { ...DEFAULT_RETRY_CONFIG, ...retryConfig }; + 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 HttpGlobalError('Retry attempt')); + await sleep(delay); + } + + const response = await makeRequest(params); + + // Check if we should retry this response + if (!shouldRetry(null, response, config, attempt + 1)) { + return response; + } + + lastResponse = response; + lastError = new HttpGlobalError(`HTTP Error: ${response.status} ${response.statusText}`); + } catch (error) { + lastError = error instanceof HttpGlobalError ? error : new HttpGlobalError(String(error)); + + if (!shouldRetry(lastError, null, config, attempt + 1)) { + throw lastError; + } + } + } + + // All retries exhausted + if (lastResponse) { + return lastResponse; + } + throw lastError ?? new HttpGlobalError('Request failed after retries'); +} + +/** + * Handle HTTP error status codes by throwing a typed HttpError. + * Explicit cases are generated from the error status codes declared by the + * input document; undeclared codes fall through to the default handler. + */ +function handleHttpError(status: number, statusText: string, body?: unknown): never { + switch (status) { + case 400: + throw new HttpError("Bad Request", status, statusText, body); + case 404: + throw new HttpError("Not Found", status, statusText, body); + case 405: + throw new HttpError("Method Not Allowed", status, statusText, body); + default: + 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 + */ +function extractHeaders(response: HttpResponse): Record { + const headers: Record = {}; + + if (response.headers) { + if (typeof (response.headers as any).forEach === 'function') { + // Headers object (fetch API) + (response.headers as Headers).forEach((value, key) => { + headers[key.toLowerCase()] = value; + }); + } else { + // Plain object + for (const [key, value] of Object.entries(response.headers)) { + headers[key.toLowerCase()] = value; + } + } + } + + return headers; +} + +/** + * Builds a URL with path parameters replaced + * @param server - Base server URL + * @param pathTemplate - Path template with {param} placeholders + * @param parameters - Parameter object with getChannelWithParameters method + */ +function buildUrlWithParameters string }>( + server: string, + pathTemplate: string, + parameters: T +): string { + const path = parameters.getChannelWithParameters(pathTemplate); + return `${server}${path}`; +} + +/** + * Extracts headers from a typed headers object and merges with additional headers + */ +function applyTypedHeaders( + typedHeaders: { marshal: () => string } | undefined, + additionalHeaders: Record | undefined +): Record { + const headers: Record = { + 'Content-Type': 'application/json', + ...additionalHeaders + }; + + if (typedHeaders) { + // Parse the marshalled headers and merge them + const marshalledHeaders = JSON.parse(typedHeaders.marshal()); + for (const [key, value] of Object.entries(marshalledHeaders)) { + headers[key] = value as string; + } + } + + return headers; +} + +/** + * Validate OAuth2 configuration based on flow type + */ +function validateOAuth2Config(auth: OAuth2Auth): void { + // If using a flow, validate required fields + switch (auth.flow) { + case 'client_credentials': + 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 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: + // No flow specified - must have accessToken for OAuth2 to work + if (!auth.accessToken && !auth.flow) { + // This is fine - token refresh can still work if refreshToken is provided + // Or the request will just be made without auth + } + break; + } +} + +/** + * Handle OAuth2 token flows (client_credentials, password) + */ +async function handleOAuth2TokenFlow( + auth: OAuth2Auth, + originalParams: HttpRequestParams, + makeRequest: (params: HttpRequestParams) => Promise, + retryConfig?: RetryConfig +): Promise { + if (!auth.flow || !auth.tokenUrl) return null; + + const params = new URLSearchParams(); + + if (auth.flow === 'client_credentials') { + params.append('grant_type', 'client_credentials'); + params.append('client_id', auth.clientId!); + } else if (auth.flow === 'password') { + params.append('grant_type', 'password'); + params.append('username', auth.username || ''); + params.append('password', auth.password || ''); + params.append('client_id', auth.clientId!); + } else { + return null; + } + + if (auth.clientSecret) { + params.append('client_secret', auth.clientSecret); + } + if (auth.scopes && auth.scopes.length > 0) { + params.append('scope', auth.scopes.join(' ')); + } + + const authHeaders: Record = { + 'Content-Type': 'application/x-www-form-urlencoded' + }; + + // Use basic auth for client credentials if both client ID and secret are provided + if (auth.flow === 'client_credentials' && auth.clientId && auth.clientSecret) { + const credentials = Buffer.from(`${auth.clientId}:${auth.clientSecret}`).toString('base64'); + authHeaders['Authorization'] = `Basic ${credentials}`; + params.delete('client_id'); + params.delete('client_secret'); + } + + const tokenResponse = await fetch(auth.tokenUrl, { + method: 'POST', + headers: authHeaders, + body: params.toString() + }); + + if (!tokenResponse.ok) { + throw new HttpGlobalError(`OAuth2 token request failed: ${tokenResponse.statusText}`); + } + + const tokenData = await tokenResponse.json(); + const tokens: TokenResponse = { + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token, + expiresIn: tokenData.expires_in + }; + + // Notify the client about the tokens + if (auth.onTokenRefresh) { + auth.onTokenRefresh(tokens); + } + + // Retry the original request with the new token + const updatedHeaders = { ...originalParams.headers }; + updatedHeaders['Authorization'] = `Bearer ${tokens.accessToken}`; + + return executeWithRetry({ ...originalParams, headers: updatedHeaders }, makeRequest, retryConfig); +} + +/** + * Handle OAuth2 token refresh on 401 response + */ +async function handleTokenRefresh( + auth: OAuth2Auth, + originalParams: HttpRequestParams, + makeRequest: (params: HttpRequestParams) => Promise, + retryConfig?: RetryConfig +): Promise { + if (!auth.refreshToken || !auth.tokenUrl || !auth.clientId) return null; + + const refreshResponse = await fetch(auth.tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: auth.refreshToken, + client_id: auth.clientId, + ...(auth.clientSecret ? { client_secret: auth.clientSecret } : {}) + }).toString() + }); + + if (!refreshResponse.ok) { + throw new HttpGlobalError('Unauthorized'); + } + + const tokenData = await refreshResponse.json(); + const newTokens: TokenResponse = { + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token || auth.refreshToken, + expiresIn: tokenData.expires_in + }; + + // Notify the client about the refreshed tokens + if (auth.onTokenRefresh) { + auth.onTokenRefresh(newTokens); + } + + // Retry the original request with the new token + const updatedHeaders = { ...originalParams.headers }; + updatedHeaders['Authorization'] = `Bearer ${newTokens.accessToken}`; + + return executeWithRetry({ ...originalParams, headers: updatedHeaders }, makeRequest, retryConfig); +} +// ============================================================================ +// Generated HTTP Client Functions +// ============================================================================ + +export interface AddPetContext extends HttpClientContext { + payload: APetInterface | APet; +} + +/** + * HTTP POST request to /pet + */ +async function addPet(context: AddPetContext): Promise> { + // Apply defaults + const config = { + baseUrl: 'http://petstore.swagger.io/v2', + ...context, + }; + + // Validate OAuth2 config if present + if (config.auth?.type === 'oauth2' && AUTH_FEATURES.oauth2) { + validateOAuth2Config(config.auth); + } + + // Build headers + let headers = { 'Content-Type': 'application/json', ...config.additionalHeaders } as Record; + + // Build URL + let url = `${config.baseUrl}/pet`; + url = applyQueryParams(config.additionalQueryParams, url); + + // Apply authentication + const authResult = applyAuth(config.auth, headers, url); + headers = authResult.headers; + url = authResult.url; + + // Prepare body + const payload = context.payload instanceof APet ? context.payload : new APet(context.payload); + const body = payload?.marshal(); + + // Determine request function + const makeRequest = config.hooks?.makeRequest ?? defaultMakeRequest; + + // Build request params + let requestParams: HttpRequestParams = { + url, + method: 'POST', + headers, + body + }; + + // Apply beforeRequest hook + if (config.hooks?.beforeRequest) { + requestParams = await config.hooks.beforeRequest(requestParams); + } + + try { + // Execute request with retry logic + let response = await executeWithRetry(requestParams, makeRequest, config.retry); + + // Apply afterResponse hook + if (config.hooks?.afterResponse) { + response = await config.hooks.afterResponse(response, requestParams); + } + + // Handle OAuth2 token flows that require getting a token first + if (config.auth?.type === 'oauth2' && !config.auth.accessToken && AUTH_FEATURES.oauth2) { + const tokenFlowResponse = await handleOAuth2TokenFlow(config.auth, requestParams, makeRequest, config.retry); + if (tokenFlowResponse) { + response = tokenFlowResponse; + } + } + + // Handle 401 with token refresh + if (response.status === 401 && config.auth?.type === 'oauth2' && AUTH_FEATURES.oauth2) { + try { + const refreshResponse = await handleTokenRefresh(config.auth, requestParams, makeRequest, config.retry); + if (refreshResponse) { + response = refreshResponse; + } + } catch { + throw new HttpGlobalError('Unauthorized'); + } + } + + // Handle error responses + if (!response.ok) { + const errorBody = await response.json().catch(() => undefined); + handleHttpError(response.status, response.statusText, errorBody); + } + + // Parse response + const rawData = await response.json(); + const responseData = APet.unmarshal(JSON.stringify(rawData)); + + // Extract response metadata + const responseHeaders = extractHeaders(response); + + const result: HttpClientResponse = { + data: responseData, + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + rawData: rawData ?? {}, + }; + + return result; + + } catch (error) { + // Apply onError hook if present + if (config.hooks?.onError && error instanceof HttpGlobalError) { + throw await config.hooks.onError(error, requestParams); + } + throw error; + } +} + +export interface UpdatePetContext extends HttpClientContext { + payload: APetInterface | APet; +} + +/** + * HTTP PUT request to /pet + */ +async function updatePet(context: UpdatePetContext): Promise> { + // Apply defaults + const config = { + baseUrl: 'http://petstore.swagger.io/v2', + ...context, + }; + + // Validate OAuth2 config if present + if (config.auth?.type === 'oauth2' && AUTH_FEATURES.oauth2) { + validateOAuth2Config(config.auth); + } + + // Build headers + let headers = { 'Content-Type': 'application/json', ...config.additionalHeaders } as Record; + + // Build URL + let url = `${config.baseUrl}/pet`; + url = applyQueryParams(config.additionalQueryParams, url); + + // Apply authentication + const authResult = applyAuth(config.auth, headers, url); + headers = authResult.headers; + url = authResult.url; + + // Prepare body + const payload = context.payload instanceof APet ? context.payload : new APet(context.payload); + const body = payload?.marshal(); + + // Determine request function + const makeRequest = config.hooks?.makeRequest ?? defaultMakeRequest; + + // Build request params + let requestParams: HttpRequestParams = { + url, + method: 'PUT', + headers, + body + }; + + // Apply beforeRequest hook + if (config.hooks?.beforeRequest) { + requestParams = await config.hooks.beforeRequest(requestParams); + } + + try { + // Execute request with retry logic + let response = await executeWithRetry(requestParams, makeRequest, config.retry); + + // Apply afterResponse hook + if (config.hooks?.afterResponse) { + response = await config.hooks.afterResponse(response, requestParams); + } + + // Handle OAuth2 token flows that require getting a token first + if (config.auth?.type === 'oauth2' && !config.auth.accessToken && AUTH_FEATURES.oauth2) { + const tokenFlowResponse = await handleOAuth2TokenFlow(config.auth, requestParams, makeRequest, config.retry); + if (tokenFlowResponse) { + response = tokenFlowResponse; + } + } + + // Handle 401 with token refresh + if (response.status === 401 && config.auth?.type === 'oauth2' && AUTH_FEATURES.oauth2) { + try { + const refreshResponse = await handleTokenRefresh(config.auth, requestParams, makeRequest, config.retry); + if (refreshResponse) { + response = refreshResponse; + } + } catch { + throw new HttpGlobalError('Unauthorized'); + } + } + + // Handle error responses + if (!response.ok) { + const errorBody = await response.json().catch(() => undefined); + handleHttpError(response.status, response.statusText, errorBody); + } + + // Parse response + const rawData = await response.json(); + const responseData = APet.unmarshal(JSON.stringify(rawData)); + + // Extract response metadata + const responseHeaders = extractHeaders(response); + + const result: HttpClientResponse = { + data: responseData, + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + rawData: rawData ?? {}, + }; + + return result; + + } catch (error) { + // Apply onError hook if present + if (config.hooks?.onError && error instanceof HttpGlobalError) { + throw await config.hooks.onError(error, requestParams); + } + throw error; + } +} + +export interface FindPetsByStatusAndCategoryContext extends HttpClientContext { + parameters: FindPetsByStatusAndCategoryParametersInterface | FindPetsByStatusAndCategoryParameters; + requestHeaders?: FindPetsByStatusAndCategoryHeaders; +} + +/** + * Find pets by status and category with additional filtering options + */ +async function findPetsByStatusAndCategory(context: FindPetsByStatusAndCategoryContext): Promise> { + // Apply defaults + const config = { + baseUrl: 'http://petstore.swagger.io/v2', + ...context, + }; + + const parameters = context.parameters instanceof FindPetsByStatusAndCategoryParameters ? context.parameters : new FindPetsByStatusAndCategoryParameters(context.parameters); + + // Validate OAuth2 config if present + if (config.auth?.type === 'oauth2' && AUTH_FEATURES.oauth2) { + validateOAuth2Config(config.auth); + } + + // Build headers + let headers = { 'Content-Type': 'application/json', ...config.additionalHeaders, ...(context.requestHeaders ? serializeFindPetsByStatusAndCategoryHeadersHeaders(context.requestHeaders) : {}) } as Record; + + // Build URL + let url = buildUrlWithParameters(config.baseUrl, '/pet/findByStatus/{status}/{categoryId}', parameters); + url = applyQueryParams(config.additionalQueryParams, url); + + // Apply authentication + const authResult = applyAuth(config.auth, headers, url); + headers = authResult.headers; + url = authResult.url; + + // Prepare body + const body = undefined; + + // Determine request function + const makeRequest = config.hooks?.makeRequest ?? defaultMakeRequest; + + // Build request params + let requestParams: HttpRequestParams = { + url, + method: 'GET', + headers, + body + }; + + // Apply beforeRequest hook + if (config.hooks?.beforeRequest) { + requestParams = await config.hooks.beforeRequest(requestParams); + } + + try { + // Execute request with retry logic + let response = await executeWithRetry(requestParams, makeRequest, config.retry); + + // Apply afterResponse hook + if (config.hooks?.afterResponse) { + response = await config.hooks.afterResponse(response, requestParams); + } + + // Handle OAuth2 token flows that require getting a token first + if (config.auth?.type === 'oauth2' && !config.auth.accessToken && AUTH_FEATURES.oauth2) { + const tokenFlowResponse = await handleOAuth2TokenFlow(config.auth, requestParams, makeRequest, config.retry); + if (tokenFlowResponse) { + response = tokenFlowResponse; + } + } + + // Handle 401 with token refresh + if (response.status === 401 && config.auth?.type === 'oauth2' && AUTH_FEATURES.oauth2) { + try { + const refreshResponse = await handleTokenRefresh(config.auth, requestParams, makeRequest, config.retry); + if (refreshResponse) { + response = refreshResponse; + } + } catch { + throw new HttpGlobalError('Unauthorized'); + } + } + + // Handle error responses + if (!response.ok) { + const errorBody = await response.json().catch(() => undefined); + handleHttpError(response.status, response.statusText, errorBody); + } + + // Parse response + const rawData = await response.json(); + const responseData = FindPetsByStatusAndCategoryResponse_200Module.unmarshal(JSON.stringify(rawData)); + + // Extract response metadata + const responseHeaders = extractHeaders(response); + + const result: HttpClientResponse = { + data: responseData, + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + rawData: rawData ?? {}, + }; + + return result; + + } catch (error) { + // Apply onError hook if present + if (config.hooks?.onError && error instanceof HttpGlobalError) { + throw await config.hooks.onError(error, requestParams); + } + throw error; + } +} + +export { addPet, updatePet, findPetsByStatusAndCategory }; diff --git a/test/runtime/typescript/src/openapi-server/channels/index.ts b/test/runtime/typescript/src/openapi-server/channels/index.ts new file mode 100644 index 00000000..5727531d --- /dev/null +++ b/test/runtime/typescript/src/openapi-server/channels/index.ts @@ -0,0 +1,3 @@ +import * as http_client from './http_client'; + +export {http_client}; diff --git a/test/runtime/typescript/src/openapi-server/headers/FindPetsByStatusAndCategoryHeaders.ts b/test/runtime/typescript/src/openapi-server/headers/FindPetsByStatusAndCategoryHeaders.ts new file mode 100644 index 00000000..85cd984e --- /dev/null +++ b/test/runtime/typescript/src/openapi-server/headers/FindPetsByStatusAndCategoryHeaders.ts @@ -0,0 +1,41 @@ + +interface FindPetsByStatusAndCategoryHeaders { + /** + * Unique request identifier for tracing + */ + xMinusRequestMinusId?: string; + /** + * Preferred language for response messages + */ + acceptMinusLanguage?: string; +} +export { FindPetsByStatusAndCategoryHeaders }; + +export function serializeFindPetsByStatusAndCategoryHeadersHeaders(headers: FindPetsByStatusAndCategoryHeaders): Record { + const result: Record = {}; + if (headers.xMinusRequestMinusId !== undefined) { result['X-Request-ID'] = String(headers.xMinusRequestMinusId); } + if (headers.acceptMinusLanguage !== undefined) { result['Accept-Language'] = String(headers.acceptMinusLanguage); } + return result; +} + +export function deserializeFindPetsByStatusAndCategoryHeadersHeaders(headers: Record): FindPetsByStatusAndCategoryHeaders { + // Header names are case-insensitive on the wire, so match on a lower-cased + // view of whatever arrived (Express hands over lower-cased names already). + const normalized: Record = {}; + for (const [name, value] of Object.entries(headers)) { + normalized[name.toLowerCase()] = value; + } + const readHeader = (name: string): string | undefined => { + const value = normalized[name]; + if (value === undefined) { return undefined; } + return Array.isArray(value) ? value[0] : value; + }; + // Built up property by property; an absent header leaves its property absent + // rather than assigning undefined, so required properties stay required. + const result = {} as FindPetsByStatusAndCategoryHeaders; + const xMinusRequestMinusIdValue = readHeader('x-request-id'); + if (xMinusRequestMinusIdValue !== undefined) { result.xMinusRequestMinusId = xMinusRequestMinusIdValue as FindPetsByStatusAndCategoryHeaders['xMinusRequestMinusId']; } + const acceptMinusLanguageValue = readHeader('accept-language'); + if (acceptMinusLanguageValue !== undefined) { result.acceptMinusLanguage = acceptMinusLanguageValue as FindPetsByStatusAndCategoryHeaders['acceptMinusLanguage']; } + return result; +} \ No newline at end of file diff --git a/test/runtime/typescript/src/openapi-server/parameters/FindPetsByStatusAndCategoryParameters.ts b/test/runtime/typescript/src/openapi-server/parameters/FindPetsByStatusAndCategoryParameters.ts new file mode 100644 index 00000000..60c3951e --- /dev/null +++ b/test/runtime/typescript/src/openapi-server/parameters/FindPetsByStatusAndCategoryParameters.ts @@ -0,0 +1,393 @@ +import {Status} from './Status'; +import {SortBy} from './SortBy'; +import {SortOrder} from './SortOrder'; +import {Format} from './Format'; +interface FindPetsByStatusAndCategoryParametersInterface { + status: Status + categoryId: number + limit?: number + offset?: number + sortBy?: SortBy + sortOrder?: SortOrder + tags?: string[] + includePetDetails?: boolean + format?: Format +} +class FindPetsByStatusAndCategoryParameters { + private _status: Status; + private _categoryId: number; + private _limit?: number; + private _offset?: number; + private _sortBy?: SortBy; + private _sortOrder?: SortOrder; + private _tags?: string[]; + private _includePetDetails?: boolean; + private _format?: Format; + + constructor(input: FindPetsByStatusAndCategoryParametersInterface) { + this._status = input.status; + this._categoryId = input.categoryId; + this._limit = input.limit; + this._offset = input.offset; + this._sortBy = input.sortBy; + this._sortOrder = input.sortOrder; + this._tags = input.tags; + this._includePetDetails = input.includePetDetails; + this._format = input.format; + } + + /** + * Status value that needs to be considered for filter + */ + get status(): Status { return this._status; } + set status(status: Status) { this._status = status; } + + /** + * Category ID to filter pets by + */ + get categoryId(): number { return this._categoryId; } + set categoryId(categoryId: number) { this._categoryId = categoryId; } + + /** + * Maximum number of pets to return + */ + get limit(): number | undefined { return this._limit; } + set limit(limit: number | undefined) { this._limit = limit; } + + /** + * Number of pets to skip before returning results + */ + get offset(): number | undefined { return this._offset; } + set offset(offset: number | undefined) { this._offset = offset; } + + /** + * Sort pets by specified field + */ + get sortBy(): SortBy | undefined { return this._sortBy; } + set sortBy(sortBy: SortBy | undefined) { this._sortBy = sortBy; } + + /** + * Sort order for results + */ + get sortOrder(): SortOrder | undefined { return this._sortOrder; } + set sortOrder(sortOrder: SortOrder | undefined) { this._sortOrder = sortOrder; } + + /** + * Filter pets by tags (comma-separated) + */ + get tags(): string[] | undefined { return this._tags; } + set tags(tags: string[] | undefined) { this._tags = tags; } + + /** + * Include detailed pet information in response + */ + get includePetDetails(): boolean | undefined { return this._includePetDetails; } + set includePetDetails(includePetDetails: boolean | undefined) { this._includePetDetails = includePetDetails; } + + /** + * Response format preference + */ + get format(): Format | undefined { return this._format; } + set format(format: Format | undefined) { this._format = format; } + + + + /** + * Serialize path parameters according to OpenAPI 2.0/3.x specification + * @returns Record of parameter names to their serialized values for path substitution + */ + serializePathParameters(): Record { + const result: Record = {}; + + // Serialize path parameter: status (style: simple, explode: false) + if (this.status !== undefined && this.status !== null) { + const value = this.status; + if (Array.isArray(value)) { + result['status'] = value.map(val => encodeURIComponent(String(val))).join(','); + } else if (typeof value === 'object' && value !== null) { + result['status'] = Object.entries(value).map(([key, val]) => `${encodeURIComponent(key)},${encodeURIComponent(String(val))}`).join(','); + } else { + result['status'] = encodeURIComponent(String(value)); + } + } + // Serialize path parameter: categoryId (style: simple, explode: false) + if (this.categoryId !== undefined && this.categoryId !== null) { + const value = this.categoryId; + if (Array.isArray(value)) { + result['categoryId'] = value.map(val => encodeURIComponent(String(val))).join(','); + } else if (typeof value === 'object' && value !== null) { + result['categoryId'] = Object.entries(value).map(([key, val]) => `${encodeURIComponent(key)},${encodeURIComponent(String(val))}`).join(','); + } else { + result['categoryId'] = encodeURIComponent(String(value)); + } + } + + return result; + } + /** + * Serialize query parameters according to OpenAPI 2.0/3.x specification + * @returns URLSearchParams object with serialized query parameters + */ + serializeQueryParameters(): URLSearchParams { + const params = new URLSearchParams(); + + // Serialize query parameter: limit (style: form, explode: true) + if (this.limit !== undefined && this.limit !== null) { + const value = this.limit; + if (Array.isArray(value)) { + value.forEach(val => params.append('limit', encodeURIComponent(String(val)))); + } else if (typeof value === 'object' && value !== null) { + Object.entries(value).forEach(([key, val]) => params.append(encodeURIComponent(key), encodeURIComponent(String(val)))); + } else { + params.append('limit', encodeURIComponent(String(value))); + } + } + // Serialize query parameter: offset (style: form, explode: true) + if (this.offset !== undefined && this.offset !== null) { + const value = this.offset; + if (Array.isArray(value)) { + value.forEach(val => params.append('offset', encodeURIComponent(String(val)))); + } else if (typeof value === 'object' && value !== null) { + Object.entries(value).forEach(([key, val]) => params.append(encodeURIComponent(key), encodeURIComponent(String(val)))); + } else { + params.append('offset', encodeURIComponent(String(value))); + } + } + // Serialize query parameter: sortBy (style: form, explode: true) + if (this.sortBy !== undefined && this.sortBy !== null) { + const value = this.sortBy; + if (Array.isArray(value)) { + value.forEach(val => params.append('sortBy', encodeURIComponent(String(val)))); + } else if (typeof value === 'object' && value !== null) { + Object.entries(value).forEach(([key, val]) => params.append(encodeURIComponent(key), encodeURIComponent(String(val)))); + } else { + params.append('sortBy', encodeURIComponent(String(value))); + } + } + // Serialize query parameter: sortOrder (style: form, explode: true) + if (this.sortOrder !== undefined && this.sortOrder !== null) { + const value = this.sortOrder; + if (Array.isArray(value)) { + value.forEach(val => params.append('sortOrder', encodeURIComponent(String(val)))); + } else if (typeof value === 'object' && value !== null) { + Object.entries(value).forEach(([key, val]) => params.append(encodeURIComponent(key), encodeURIComponent(String(val)))); + } else { + params.append('sortOrder', encodeURIComponent(String(value))); + } + } + // Serialize query parameter: tags (style: form, explode: false) + if (this.tags !== undefined && this.tags !== null) { + const value = this.tags; + if (Array.isArray(value)) { + params.append('tags', value.map(val => encodeURIComponent(String(val))).join(',')); + } else if (typeof value === 'object' && value !== null) { + params.append('tags', Object.entries(value).map(([key, val]) => `${encodeURIComponent(key)},${encodeURIComponent(String(val))}`).join(',')); + } else { + params.append('tags', encodeURIComponent(String(value))); + } + } + // Serialize query parameter: includePetDetails (style: form, explode: true) + if (this.includePetDetails !== undefined && this.includePetDetails !== null) { + const value = this.includePetDetails; + if (Array.isArray(value)) { + value.forEach(val => params.append('includePetDetails', encodeURIComponent(String(val)))); + } else if (typeof value === 'object' && value !== null) { + Object.entries(value).forEach(([key, val]) => params.append(encodeURIComponent(key), encodeURIComponent(String(val)))); + } else { + params.append('includePetDetails', encodeURIComponent(String(value))); + } + } + // Serialize query parameter: format (style: form, explode: true) + if (this.format !== undefined && this.format !== null) { + const value = this.format; + if (Array.isArray(value)) { + value.forEach(val => params.append('format', String(val))); + } else if (typeof value === 'object' && value !== null) { + Object.entries(value).forEach(([key, val]) => params.append(key, String(val))); + } else { + params.append('format', String(value)); + } + } + + return params; + } + /** + * Get the complete serialized URL with path and query parameters + * @param basePath The base path template (e.g., '/users/{id}') + * @returns The complete URL with serialized parameters + */ + serializeUrl(basePath: string): string { + let url = basePath; + + // Replace path parameters + + const pathParams = this.serializePathParameters(); + for (const [name, value] of Object.entries(pathParams)) { + url = url.replace(new RegExp(`{${name}}`, 'g'), value); + } + + // Add query parameters + + const queryParams = this.serializeQueryParameters(); + const queryString = queryParams.toString(); + if (queryString) { + url += (url.includes('?') ? '&' : '?') + queryString; + } + + return url; + } + + /** + * Get the channel path with parameters substituted (compatible with AsyncAPI channel interface) + * @param basePath The base path template (e.g., '/pet/findByStatus/{status}/{categoryId}') + * @returns The path with parameters replaced + */ + getChannelWithParameters(basePath: string): string { + return this.serializeUrl(basePath); + } + /** + * Deserialize URL and populate instance properties from query parameters + * @param url The URL to parse (can be full URL or just query string) + */ + deserializeUrl(url: string): void { + // Extract query string from URL + let queryString = ''; + if (url.includes('?')) { + queryString = url.split('?')[1]; + } else if (url.includes('=')) { + // Assume it's already a query string + queryString = url; + } + + if (!queryString) { + return; + } + + const params = new URLSearchParams(queryString); + + // Deserialize query parameter: limit (style: form, explode: true) + if (params.has('limit')) { + const value = params.get('limit'); + if (value) { + const decodedValue = decodeURIComponent(value); + const numValue = Number(decodedValue); + if (!isNaN(numValue)) { + this.limit = numValue; + } + } + } + // Deserialize query parameter: offset (style: form, explode: true) + if (params.has('offset')) { + const value = params.get('offset'); + if (value) { + const decodedValue = decodeURIComponent(value); + const numValue = Number(decodedValue); + if (!isNaN(numValue)) { + this.offset = numValue; + } + } + } + // Deserialize query parameter: sortBy (style: form, explode: true) + if (params.has('sortBy')) { + const value = params.get('sortBy'); + if (value) { + const decodedValue = decodeURIComponent(value); + this.sortBy = decodedValue as "name" | "id" | "category" | "status"; + } + } + // Deserialize query parameter: sortOrder (style: form, explode: true) + if (params.has('sortOrder')) { + const value = params.get('sortOrder'); + if (value) { + const decodedValue = decodeURIComponent(value); + this.sortOrder = decodedValue as "asc" | "desc"; + } + } + // Deserialize query parameter: tags (style: form, explode: false) + if (params.has('tags')) { + const value = params.get('tags'); + if (value === '') { + this.tags = []; + } else if (value) { + // Split by comma and decode + const decodedValues = value.split(',').map(val => decodeURIComponent(val.trim())); + this.tags = decodedValues as string[]; + } + } + // Deserialize query parameter: includePetDetails (style: form, explode: true) + if (params.has('includePetDetails')) { + const value = params.get('includePetDetails'); + if (value) { + const decodedValue = decodeURIComponent(value); + this.includePetDetails = decodedValue.toLowerCase() === 'true'; + } + } + // Deserialize query parameter: format (style: form, explode: true) + if (params.has('format')) { + const value = params.get('format'); + if (value) { + const decodedValue = decodeURIComponent(value); + this.format = decodedValue as "json" | "xml" | "csv"; + } + } + } + + /** + * Static method to create a new instance from a URL + * @param url The URL to parse + * @param basePath The base path template (e.g., '/pet/findByStatus/{status}/{categoryId}') + + * @returns A new FindPetsByStatusAndCategoryParameters instance + */ + static fromUrl(url: string, basePath: string): FindPetsByStatusAndCategoryParameters { + // Extract path parameters from URL + const pathParams = this.extractPathParameters(url, basePath); + const instance = new FindPetsByStatusAndCategoryParameters({ status: pathParams.status, categoryId: pathParams.categoryId }); + instance.deserializeUrl(url); + return instance; + } + + /** + * Extract path parameters from a URL using a base path template + * @param url The URL to extract parameters from + * @param basePath The base path template (e.g., '/pet/findByStatus/{status}/{categoryId}') + * @returns Object containing extracted path parameter values + */ + private static extractPathParameters(url: string, basePath: string): { status: "available" | "pending" | "sold", categoryId: number } { + // Remove query string from URL for path matching + const urlPath = url.split('?')[0]; + + // Create regex pattern from base path template + const regexPattern = basePath.replace(/\{([^}]+)\}/g, '([^/]+)'); + const regex = new RegExp('^' + regexPattern + '$'); + + const match = urlPath.match(regex); + if (!match) { + throw new Error(`URL path '${urlPath}' does not match base path template '${basePath}'`); + } + + // Extract parameter names from base path template + const paramNames = basePath.match(/\{([^}]+)\}/g)?.map(p => p.slice(1, -1)) || []; + + // Map matched values to parameter names + const result: any = {}; + paramNames.forEach((paramName, index) => { + const rawValue = match[index + 1]; + const decodeValue = decodeURIComponent(rawValue); + switch (paramName) { + case 'status': + result.status = decodeValue as "available" | "pending" | "sold"; + break; + case 'categoryId': + result.categoryId = Number(decodeValue) as number; + break; + default: + result[paramName] = decodeValue; + } + }); + + return result; + } +} +export { FindPetsByStatusAndCategoryParameters }; +export type { FindPetsByStatusAndCategoryParametersInterface }; \ No newline at end of file diff --git a/test/runtime/typescript/src/openapi-server/parameters/Format.ts b/test/runtime/typescript/src/openapi-server/parameters/Format.ts new file mode 100644 index 00000000..53f45e9c --- /dev/null +++ b/test/runtime/typescript/src/openapi-server/parameters/Format.ts @@ -0,0 +1,6 @@ + +/** + * Response format preference + */ +type Format = "json" | "xml" | "csv"; +export { Format }; \ No newline at end of file diff --git a/test/runtime/typescript/src/openapi-server/parameters/SortBy.ts b/test/runtime/typescript/src/openapi-server/parameters/SortBy.ts new file mode 100644 index 00000000..b267a9d1 --- /dev/null +++ b/test/runtime/typescript/src/openapi-server/parameters/SortBy.ts @@ -0,0 +1,6 @@ + +/** + * Sort pets by specified field + */ +type SortBy = "name" | "id" | "category" | "status"; +export { SortBy }; \ No newline at end of file diff --git a/test/runtime/typescript/src/openapi-server/parameters/SortOrder.ts b/test/runtime/typescript/src/openapi-server/parameters/SortOrder.ts new file mode 100644 index 00000000..efb5a9d9 --- /dev/null +++ b/test/runtime/typescript/src/openapi-server/parameters/SortOrder.ts @@ -0,0 +1,6 @@ + +/** + * Sort order for results + */ +type SortOrder = "asc" | "desc"; +export { SortOrder }; \ No newline at end of file diff --git a/test/runtime/typescript/src/openapi-server/parameters/Status.ts b/test/runtime/typescript/src/openapi-server/parameters/Status.ts new file mode 100644 index 00000000..c82a0529 --- /dev/null +++ b/test/runtime/typescript/src/openapi-server/parameters/Status.ts @@ -0,0 +1,6 @@ + +/** + * Status value that needs to be considered for filter + */ +type Status = "available" | "pending" | "sold"; +export { Status }; \ No newline at end of file diff --git a/test/runtime/typescript/src/openapi-server/payloads/APet.ts b/test/runtime/typescript/src/openapi-server/payloads/APet.ts new file mode 100644 index 00000000..cf97761e --- /dev/null +++ b/test/runtime/typescript/src/openapi-server/payloads/APet.ts @@ -0,0 +1,164 @@ +import {PetCategory} from './PetCategory'; +import {PetTag} from './PetTag'; +import {Status} from './Status'; +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import addFormatsModule from 'ajv-formats'; +interface APetInterface { + id?: number + category?: PetCategory + name: string + photoUrls: string[] + tags?: PetTag[] + status?: Status + additionalProperties?: Record +} +/** + * A pet for sale in the pet store + */ +class APet { + private _id?: number; + private _category?: PetCategory; + private _name: string; + private _photoUrls: string[]; + private _tags?: PetTag[]; + private _status?: Status; + private _additionalProperties?: Record; + + constructor(input: APetInterface) { + this._id = input.id; + this._category = input.category; + this._name = input.name; + this._photoUrls = input.photoUrls; + this._tags = input.tags; + this._status = input.status; + this._additionalProperties = input.additionalProperties; + } + + get id(): number | undefined { return this._id; } + set id(id: number | undefined) { this._id = id; } + + /** + * A category for a pet + */ + get category(): PetCategory | undefined { return this._category; } + set category(category: PetCategory | undefined) { this._category = category; } + + get name(): string { return this._name; } + set name(name: string) { this._name = name; } + + get photoUrls(): string[] { return this._photoUrls; } + set photoUrls(photoUrls: string[]) { this._photoUrls = photoUrls; } + + get tags(): PetTag[] | undefined { return this._tags; } + set tags(tags: PetTag[] | undefined) { this._tags = tags; } + + /** + * pet status in the store + */ + get status(): Status | undefined { return this._status; } + set status(status: Status | undefined) { this._status = status; } + + get additionalProperties(): Record | undefined { return this._additionalProperties; } + set additionalProperties(additionalProperties: Record | undefined) { this._additionalProperties = additionalProperties; } + + public toJson(): Record { + const json: Record = {}; + if(this.id !== undefined) { + json["id"] = this.id; + } + if(this.category !== undefined) { + json["category"] = this.category && typeof this.category === 'object' && 'toJson' in this.category && typeof this.category.toJson === 'function' ? this.category.toJson() : this.category; + } + if(this.name !== undefined) { + json["name"] = this.name; + } + if(this.photoUrls !== undefined) { + json["photoUrls"] = this.photoUrls; + } + if(this.tags !== undefined) { + json["tags"] = this.tags.map((item: any) => + item && typeof item === 'object' && 'toJson' in item && typeof item.toJson === 'function' + ? item.toJson() + : item + ); + } + if(this.status !== undefined) { + json["status"] = this.status; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of Object.entries(this.additionalProperties)) { + //Only unwrap those that are not already a property in the JSON object + if(["id","category","name","photoUrls","tags","status","additionalProperties"].includes(String(key))) continue; + json[key] = value; + } + } + return json; + } + + public marshal(): string { + return JSON.stringify(this.toJson()); + } + + public static fromJson(obj: Record): APet { + const instance = new APet({} as any); + + if (obj["id"] !== undefined) { + instance.id = obj["id"] as number; + } + if (obj["category"] !== undefined) { + instance.category = PetCategory.fromJson(obj["category"] as Record); + } + if (obj["name"] !== undefined) { + instance.name = obj["name"] as string; + } + if (obj["photoUrls"] !== undefined) { + instance.photoUrls = obj["photoUrls"] as string[]; + } + if (obj["tags"] !== undefined) { + instance.tags = obj["tags"] == null + ? undefined + : (obj["tags"] as Record[]).map((item: Record) => PetTag.fromJson(item)); + } + if (obj["status"] !== undefined) { + instance.status = obj["status"] as Status; + } + + instance.additionalProperties = {}; + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["id","category","name","photoUrls","tags","status","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties[key] = value as any; + } + return instance; + } + + public static unmarshal(json: string | object): APet { + const obj = typeof json === "object" ? json : JSON.parse(json); + return APet.fromJson(obj as Record); + } + public static theCodeGenSchema = {"title":"a Pet","description":"A pet for sale in the pet store","type":"object","required":["name","photoUrls"],"properties":{"id":{"type":"integer","format":"int64"},"category":{"title":"Pet category","description":"A category for a pet","type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string","pattern":"^[a-zA-Z0-9]+[a-zA-Z0-9\\.\\-_]*[a-zA-Z0-9]+$"}},"xml":{"name":"Category"}},"name":{"type":"string","example":"doggie"},"photoUrls":{"type":"array","xml":{"name":"photoUrl","wrapped":true},"items":{"type":"string"}},"tags":{"type":"array","xml":{"name":"tag","wrapped":true},"items":{"title":"Pet Tag","description":"A tag for a pet","type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"Tag"}}},"status":{"type":"string","description":"pet status in the store","deprecated":true,"enum":["available","pending","sold"]}},"xml":{"name":"Pet"},"$id":"AddPetRequest","$schema":"http://json-schema.org/draft-07/schema"}; + public static validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } { + const {data, ajvValidatorFunction} = context ?? {}; + // Intentionally parse JSON strings to support validation of marshalled output. + // Example: validate({data: marshal(obj)}) works because marshal returns JSON string. + // Note: String 'true' will be coerced to boolean true due to JSON.parse. + const parsedData = typeof data === 'string' ? JSON.parse(data) : data; + const validate = ajvValidatorFunction ?? this.createValidator(context) + return { + valid: validate(parsedData), + errors: validate.errors ?? undefined, + }; + } + public static createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction { + const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})}; + // `ajv-formats` is CommonJS; its default import is the module namespace under + // `moduleResolution: node16`/`nodenext`, so unwrap `.default` when present. + const addFormats = ((addFormatsModule as unknown as {default?: unknown}).default ?? addFormatsModule) as (ajv: Ajv) => Ajv; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { APet }; +export type { APetInterface }; \ No newline at end of file diff --git a/test/runtime/typescript/src/openapi-server/payloads/AUser.ts b/test/runtime/typescript/src/openapi-server/payloads/AUser.ts new file mode 100644 index 00000000..2e2bd007 --- /dev/null +++ b/test/runtime/typescript/src/openapi-server/payloads/AUser.ts @@ -0,0 +1,176 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import addFormatsModule from 'ajv-formats'; +interface AUserInterface { + id?: number + username?: string + firstName?: string + lastName?: string + email?: string + password?: string + phone?: string + userStatus?: number + additionalProperties?: Record +} +/** + * A User who is purchasing from the pet store + */ +class AUser { + private _id?: number; + private _username?: string; + private _firstName?: string; + private _lastName?: string; + private _email?: string; + private _password?: string; + private _phone?: string; + private _userStatus?: number; + private _additionalProperties?: Record; + + constructor(input: AUserInterface) { + this._id = input.id; + this._username = input.username; + this._firstName = input.firstName; + this._lastName = input.lastName; + this._email = input.email; + this._password = input.password; + this._phone = input.phone; + this._userStatus = input.userStatus; + this._additionalProperties = input.additionalProperties; + } + + get id(): number | undefined { return this._id; } + set id(id: number | undefined) { this._id = id; } + + get username(): string | undefined { return this._username; } + set username(username: string | undefined) { this._username = username; } + + get firstName(): string | undefined { return this._firstName; } + set firstName(firstName: string | undefined) { this._firstName = firstName; } + + get lastName(): string | undefined { return this._lastName; } + set lastName(lastName: string | undefined) { this._lastName = lastName; } + + get email(): string | undefined { return this._email; } + set email(email: string | undefined) { this._email = email; } + + get password(): string | undefined { return this._password; } + set password(password: string | undefined) { this._password = password; } + + get phone(): string | undefined { return this._phone; } + set phone(phone: string | undefined) { this._phone = phone; } + + /** + * User Status + */ + get userStatus(): number | undefined { return this._userStatus; } + set userStatus(userStatus: number | undefined) { this._userStatus = userStatus; } + + get additionalProperties(): Record | undefined { return this._additionalProperties; } + set additionalProperties(additionalProperties: Record | undefined) { this._additionalProperties = additionalProperties; } + + public toJson(): Record { + const json: Record = {}; + if(this.id !== undefined) { + json["id"] = this.id; + } + if(this.username !== undefined) { + json["username"] = this.username; + } + if(this.firstName !== undefined) { + json["firstName"] = this.firstName; + } + if(this.lastName !== undefined) { + json["lastName"] = this.lastName; + } + if(this.email !== undefined) { + json["email"] = this.email; + } + if(this.password !== undefined) { + json["password"] = this.password; + } + if(this.phone !== undefined) { + json["phone"] = this.phone; + } + if(this.userStatus !== undefined) { + json["userStatus"] = this.userStatus; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of Object.entries(this.additionalProperties)) { + //Only unwrap those that are not already a property in the JSON object + if(["id","username","firstName","lastName","email","password","phone","userStatus","additionalProperties"].includes(String(key))) continue; + json[key] = value; + } + } + return json; + } + + public marshal(): string { + return JSON.stringify(this.toJson()); + } + + public static fromJson(obj: Record): AUser { + const instance = new AUser({} as any); + + if (obj["id"] !== undefined) { + instance.id = obj["id"] as number; + } + if (obj["username"] !== undefined) { + instance.username = obj["username"] as string; + } + if (obj["firstName"] !== undefined) { + instance.firstName = obj["firstName"] as string; + } + if (obj["lastName"] !== undefined) { + instance.lastName = obj["lastName"] as string; + } + if (obj["email"] !== undefined) { + instance.email = obj["email"] as string; + } + if (obj["password"] !== undefined) { + instance.password = obj["password"] as string; + } + if (obj["phone"] !== undefined) { + instance.phone = obj["phone"] as string; + } + if (obj["userStatus"] !== undefined) { + instance.userStatus = obj["userStatus"] as number; + } + + instance.additionalProperties = {}; + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["id","username","firstName","lastName","email","password","phone","userStatus","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties[key] = value as any; + } + return instance; + } + + public static unmarshal(json: string | object): AUser { + const obj = typeof json === "object" ? json : JSON.parse(json); + return AUser.fromJson(obj as Record); + } + public static theCodeGenSchema = {"title":"a User","description":"A User who is purchasing from the pet store","type":"object","properties":{"id":{"type":"integer","format":"int64"},"username":{"type":"string"},"firstName":{"type":"string"},"lastName":{"type":"string"},"email":{"type":"string"},"password":{"type":"string"},"phone":{"type":"string"},"userStatus":{"type":"integer","format":"int32","description":"User Status"}},"xml":{"name":"User"},"$id":"User","$schema":"http://json-schema.org/draft-07/schema"}; + public static validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } { + const {data, ajvValidatorFunction} = context ?? {}; + // Intentionally parse JSON strings to support validation of marshalled output. + // Example: validate({data: marshal(obj)}) works because marshal returns JSON string. + // Note: String 'true' will be coerced to boolean true due to JSON.parse. + const parsedData = typeof data === 'string' ? JSON.parse(data) : data; + const validate = ajvValidatorFunction ?? this.createValidator(context) + return { + valid: validate(parsedData), + errors: validate.errors ?? undefined, + }; + } + public static createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction { + const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})}; + // `ajv-formats` is CommonJS; its default import is the module namespace under + // `moduleResolution: node16`/`nodenext`, so unwrap `.default` when present. + const addFormats = ((addFormatsModule as unknown as {default?: unknown}).default ?? addFormatsModule) as (ajv: Ajv) => Ajv; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { AUser }; +export type { AUserInterface }; \ No newline at end of file diff --git a/test/runtime/typescript/src/openapi-server/payloads/AnUploadedResponse.ts b/test/runtime/typescript/src/openapi-server/payloads/AnUploadedResponse.ts new file mode 100644 index 00000000..52e26695 --- /dev/null +++ b/test/runtime/typescript/src/openapi-server/payloads/AnUploadedResponse.ts @@ -0,0 +1,113 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import addFormatsModule from 'ajv-formats'; +interface AnUploadedResponseInterface { + code?: number + type?: string + message?: string + additionalProperties?: Record +} +/** + * Describes the result of uploading an image resource + */ +class AnUploadedResponse { + private _code?: number; + private _type?: string; + private _message?: string; + private _additionalProperties?: Record; + + constructor(input: AnUploadedResponseInterface) { + this._code = input.code; + this._type = input.type; + this._message = input.message; + this._additionalProperties = input.additionalProperties; + } + + get code(): number | undefined { return this._code; } + set code(code: number | undefined) { this._code = code; } + + get type(): string | undefined { return this._type; } + set type(type: string | undefined) { this._type = type; } + + get message(): string | undefined { return this._message; } + set message(message: string | undefined) { this._message = message; } + + get additionalProperties(): Record | undefined { return this._additionalProperties; } + set additionalProperties(additionalProperties: Record | undefined) { this._additionalProperties = additionalProperties; } + + public toJson(): Record { + const json: Record = {}; + if(this.code !== undefined) { + json["code"] = this.code; + } + if(this.type !== undefined) { + json["type"] = this.type; + } + if(this.message !== undefined) { + json["message"] = this.message; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of Object.entries(this.additionalProperties)) { + //Only unwrap those that are not already a property in the JSON object + if(["code","type","message","additionalProperties"].includes(String(key))) continue; + json[key] = value; + } + } + return json; + } + + public marshal(): string { + return JSON.stringify(this.toJson()); + } + + public static fromJson(obj: Record): AnUploadedResponse { + const instance = new AnUploadedResponse({} as any); + + if (obj["code"] !== undefined) { + instance.code = obj["code"] as number; + } + if (obj["type"] !== undefined) { + instance.type = obj["type"] as string; + } + if (obj["message"] !== undefined) { + instance.message = obj["message"] as string; + } + + instance.additionalProperties = {}; + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["code","type","message","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties[key] = value as any; + } + return instance; + } + + public static unmarshal(json: string | object): AnUploadedResponse { + const obj = typeof json === "object" ? json : JSON.parse(json); + return AnUploadedResponse.fromJson(obj as Record); + } + public static theCodeGenSchema = {"title":"An uploaded response","description":"Describes the result of uploading an image resource","type":"object","properties":{"code":{"type":"integer","format":"int32"},"type":{"type":"string"},"message":{"type":"string"}},"$id":"ApiResponse","$schema":"http://json-schema.org/draft-07/schema"}; + public static validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } { + const {data, ajvValidatorFunction} = context ?? {}; + // Intentionally parse JSON strings to support validation of marshalled output. + // Example: validate({data: marshal(obj)}) works because marshal returns JSON string. + // Note: String 'true' will be coerced to boolean true due to JSON.parse. + const parsedData = typeof data === 'string' ? JSON.parse(data) : data; + const validate = ajvValidatorFunction ?? this.createValidator(context) + return { + valid: validate(parsedData), + errors: validate.errors ?? undefined, + }; + } + public static createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction { + const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})}; + // `ajv-formats` is CommonJS; its default import is the module namespace under + // `moduleResolution: node16`/`nodenext`, so unwrap `.default` when present. + const addFormats = ((addFormatsModule as unknown as {default?: unknown}).default ?? addFormatsModule) as (ajv: Ajv) => Ajv; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { AnUploadedResponse }; +export type { AnUploadedResponseInterface }; \ No newline at end of file diff --git a/test/runtime/typescript/src/openapi-server/payloads/FindPetsByStatusAndCategoryResponse_200.ts b/test/runtime/typescript/src/openapi-server/payloads/FindPetsByStatusAndCategoryResponse_200.ts new file mode 100644 index 00000000..6792b23f --- /dev/null +++ b/test/runtime/typescript/src/openapi-server/payloads/FindPetsByStatusAndCategoryResponse_200.ts @@ -0,0 +1,48 @@ +import {APet} from './APet'; +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import addFormatsModule from 'ajv-formats'; +type FindPetsByStatusAndCategoryResponse_200 = APet[]; + +export function unmarshal(json: string | any[]): FindPetsByStatusAndCategoryResponse_200 { + const arr = typeof json === 'string' ? JSON.parse(json) : json; + return arr.map((item: any) => { + if (item && typeof item === 'object') { + return APet.unmarshal(item); + } + return item; + }) as FindPetsByStatusAndCategoryResponse_200; +} +export function marshal(payload: FindPetsByStatusAndCategoryResponse_200): string { + return JSON.stringify(payload.map((item) => { + if (item && typeof item === 'object' && 'marshal' in item && typeof item.marshal === 'function') { + return JSON.parse(item.marshal()); + } + return item; + })); +} +export const theCodeGenSchema = {"type":"array","items":{"title":"a Pet","description":"A pet for sale in the pet store","type":"object","required":["name","photoUrls"],"properties":{"id":{"type":"integer","format":"int64"},"category":{"title":"Pet category","description":"A category for a pet","type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string","pattern":"^[a-zA-Z0-9]+[a-zA-Z0-9\\.\\-_]*[a-zA-Z0-9]+$"}},"xml":{"name":"Category"}},"name":{"type":"string","example":"doggie"},"photoUrls":{"type":"array","xml":{"name":"photoUrl","wrapped":true},"items":{"type":"string"}},"tags":{"type":"array","xml":{"name":"tag","wrapped":true},"items":{"title":"Pet Tag","description":"A tag for a pet","type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"Tag"}}},"status":{"type":"string","description":"pet status in the store","deprecated":true,"enum":["available","pending","sold"]}},"xml":{"name":"Pet"}},"$id":"findPetsByStatusAndCategory_Response_200","$schema":"http://json-schema.org/draft-07/schema"}; +export function validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } { + const {data, ajvValidatorFunction} = context ?? {}; + // Intentionally parse JSON strings to support validation of marshalled output. + // Example: validate({data: marshal(obj)}) works because marshal returns JSON string. + // Note: String 'true' will be coerced to boolean true due to JSON.parse. + const parsedData = typeof data === 'string' ? JSON.parse(data) : data; + const validate = ajvValidatorFunction ?? createValidator(context) + return { + valid: validate(parsedData), + errors: validate.errors ?? undefined, + }; +} +export function createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction { + const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})}; + // `ajv-formats` is CommonJS; its default import is the module namespace under + // `moduleResolution: node16`/`nodenext`, so unwrap `.default` when present. + const addFormats = ((addFormatsModule as unknown as {default?: unknown}).default ?? addFormatsModule) as (ajv: Ajv) => Ajv; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(theCodeGenSchema); + return validate; +} + + +export { FindPetsByStatusAndCategoryResponse_200 }; \ No newline at end of file diff --git a/test/runtime/typescript/src/openapi-server/payloads/ItemStatus.ts b/test/runtime/typescript/src/openapi-server/payloads/ItemStatus.ts new file mode 100644 index 00000000..706c5beb --- /dev/null +++ b/test/runtime/typescript/src/openapi-server/payloads/ItemStatus.ts @@ -0,0 +1,10 @@ + +/** + * pet status in the store + */ +enum ItemStatus { + AVAILABLE = "available", + PENDING = "pending", + SOLD = "sold", +} +export { ItemStatus }; \ No newline at end of file diff --git a/test/runtime/typescript/src/openapi-server/payloads/PetCategory.ts b/test/runtime/typescript/src/openapi-server/payloads/PetCategory.ts new file mode 100644 index 00000000..4e6363d8 --- /dev/null +++ b/test/runtime/typescript/src/openapi-server/payloads/PetCategory.ts @@ -0,0 +1,101 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import addFormatsModule from 'ajv-formats'; +interface PetCategoryInterface { + id?: number + name?: string + additionalProperties?: Record +} +/** + * A category for a pet + */ +class PetCategory { + private _id?: number; + private _name?: string; + private _additionalProperties?: Record; + + constructor(input: PetCategoryInterface) { + this._id = input.id; + this._name = input.name; + this._additionalProperties = input.additionalProperties; + } + + get id(): number | undefined { return this._id; } + set id(id: number | undefined) { this._id = id; } + + get name(): string | undefined { return this._name; } + set name(name: string | undefined) { this._name = name; } + + get additionalProperties(): Record | undefined { return this._additionalProperties; } + set additionalProperties(additionalProperties: Record | undefined) { this._additionalProperties = additionalProperties; } + + public toJson(): Record { + const json: Record = {}; + if(this.id !== undefined) { + json["id"] = this.id; + } + if(this.name !== undefined) { + json["name"] = this.name; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of Object.entries(this.additionalProperties)) { + //Only unwrap those that are not already a property in the JSON object + if(["id","name","additionalProperties"].includes(String(key))) continue; + json[key] = value; + } + } + return json; + } + + public marshal(): string { + return JSON.stringify(this.toJson()); + } + + public static fromJson(obj: Record): PetCategory { + const instance = new PetCategory({} as any); + + if (obj["id"] !== undefined) { + instance.id = obj["id"] as number; + } + if (obj["name"] !== undefined) { + instance.name = obj["name"] as string; + } + + instance.additionalProperties = {}; + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["id","name","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties[key] = value as any; + } + return instance; + } + + public static unmarshal(json: string | object): PetCategory { + const obj = typeof json === "object" ? json : JSON.parse(json); + return PetCategory.fromJson(obj as Record); + } + public static theCodeGenSchema = {"title":"Pet category","description":"A category for a pet","type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string","pattern":"^[a-zA-Z0-9]+[a-zA-Z0-9\\.\\-_]*[a-zA-Z0-9]+$"}},"xml":{"name":"Category"}}; + public static validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } { + const {data, ajvValidatorFunction} = context ?? {}; + // Intentionally parse JSON strings to support validation of marshalled output. + // Example: validate({data: marshal(obj)}) works because marshal returns JSON string. + // Note: String 'true' will be coerced to boolean true due to JSON.parse. + const parsedData = typeof data === 'string' ? JSON.parse(data) : data; + const validate = ajvValidatorFunction ?? this.createValidator(context) + return { + valid: validate(parsedData), + errors: validate.errors ?? undefined, + }; + } + public static createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction { + const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})}; + // `ajv-formats` is CommonJS; its default import is the module namespace under + // `moduleResolution: node16`/`nodenext`, so unwrap `.default` when present. + const addFormats = ((addFormatsModule as unknown as {default?: unknown}).default ?? addFormatsModule) as (ajv: Ajv) => Ajv; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { PetCategory }; +export type { PetCategoryInterface }; \ No newline at end of file diff --git a/test/runtime/typescript/src/openapi-server/payloads/PetOrder.ts b/test/runtime/typescript/src/openapi-server/payloads/PetOrder.ts new file mode 100644 index 00000000..46f10b36 --- /dev/null +++ b/test/runtime/typescript/src/openapi-server/payloads/PetOrder.ts @@ -0,0 +1,153 @@ +import {Status} from './Status'; +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import addFormatsModule from 'ajv-formats'; +interface PetOrderInterface { + id?: number + petId?: number + quantity?: number + shipDate?: Date + status?: Status + complete?: boolean + additionalProperties?: Record +} +/** + * An order for a pets from the pet store + */ +class PetOrder { + private _id?: number; + private _petId?: number; + private _quantity?: number; + private _shipDate?: Date; + private _status?: Status; + private _complete?: boolean; + private _additionalProperties?: Record; + + constructor(input: PetOrderInterface) { + this._id = input.id; + this._petId = input.petId; + this._quantity = input.quantity; + this._shipDate = input.shipDate; + this._status = input.status; + this._complete = input.complete; + this._additionalProperties = input.additionalProperties; + } + + get id(): number | undefined { return this._id; } + set id(id: number | undefined) { this._id = id; } + + get petId(): number | undefined { return this._petId; } + set petId(petId: number | undefined) { this._petId = petId; } + + get quantity(): number | undefined { return this._quantity; } + set quantity(quantity: number | undefined) { this._quantity = quantity; } + + get shipDate(): Date | undefined { return this._shipDate; } + set shipDate(shipDate: Date | undefined) { this._shipDate = shipDate; } + + /** + * Order Status + */ + get status(): Status | undefined { return this._status; } + set status(status: Status | undefined) { this._status = status; } + + get complete(): boolean | undefined { return this._complete; } + set complete(complete: boolean | undefined) { this._complete = complete; } + + get additionalProperties(): Record | undefined { return this._additionalProperties; } + set additionalProperties(additionalProperties: Record | undefined) { this._additionalProperties = additionalProperties; } + + public toJson(): Record { + const json: Record = {}; + if(this.id !== undefined) { + json["id"] = this.id; + } + if(this.petId !== undefined) { + json["petId"] = this.petId; + } + if(this.quantity !== undefined) { + json["quantity"] = this.quantity; + } + if(this.shipDate !== undefined) { + json["shipDate"] = this.shipDate; + } + if(this.status !== undefined) { + json["status"] = this.status; + } + if(this.complete !== undefined) { + json["complete"] = this.complete; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of Object.entries(this.additionalProperties)) { + //Only unwrap those that are not already a property in the JSON object + if(["id","petId","quantity","shipDate","status","complete","additionalProperties"].includes(String(key))) continue; + json[key] = value; + } + } + return json; + } + + public marshal(): string { + return JSON.stringify(this.toJson()); + } + + public static fromJson(obj: Record): PetOrder { + const instance = new PetOrder({} as any); + + if (obj["id"] !== undefined) { + instance.id = obj["id"] as number; + } + if (obj["petId"] !== undefined) { + instance.petId = obj["petId"] as number; + } + if (obj["quantity"] !== undefined) { + instance.quantity = obj["quantity"] as number; + } + if (obj["shipDate"] !== undefined) { + instance.shipDate = obj["shipDate"] == null ? undefined : new Date(obj["shipDate"] as string); + } + if (obj["status"] !== undefined) { + instance.status = obj["status"] as Status; + } + if (obj["complete"] !== undefined) { + instance.complete = obj["complete"] as boolean; + } + + instance.additionalProperties = {}; + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["id","petId","quantity","shipDate","status","complete","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties[key] = value as any; + } + return instance; + } + + public static unmarshal(json: string | object): PetOrder { + const obj = typeof json === "object" ? json : JSON.parse(json); + return PetOrder.fromJson(obj as Record); + } + public static theCodeGenSchema = {"title":"Pet Order","description":"An order for a pets from the pet store","type":"object","properties":{"id":{"type":"integer","format":"int64"},"petId":{"type":"integer","format":"int64"},"quantity":{"type":"integer","format":"int32"},"shipDate":{"type":"string","format":"date-time"},"status":{"type":"string","description":"Order Status","enum":["placed","approved","delivered"]},"complete":{"type":"boolean","default":false}},"xml":{"name":"Order"},"$id":"Order","$schema":"http://json-schema.org/draft-07/schema"}; + public static validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } { + const {data, ajvValidatorFunction} = context ?? {}; + // Intentionally parse JSON strings to support validation of marshalled output. + // Example: validate({data: marshal(obj)}) works because marshal returns JSON string. + // Note: String 'true' will be coerced to boolean true due to JSON.parse. + const parsedData = typeof data === 'string' ? JSON.parse(data) : data; + const validate = ajvValidatorFunction ?? this.createValidator(context) + return { + valid: validate(parsedData), + errors: validate.errors ?? undefined, + }; + } + public static createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction { + const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})}; + // `ajv-formats` is CommonJS; its default import is the module namespace under + // `moduleResolution: node16`/`nodenext`, so unwrap `.default` when present. + const addFormats = ((addFormatsModule as unknown as {default?: unknown}).default ?? addFormatsModule) as (ajv: Ajv) => Ajv; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { PetOrder }; +export type { PetOrderInterface }; \ No newline at end of file diff --git a/test/runtime/typescript/src/openapi-server/payloads/PetTag.ts b/test/runtime/typescript/src/openapi-server/payloads/PetTag.ts new file mode 100644 index 00000000..2c7e4e42 --- /dev/null +++ b/test/runtime/typescript/src/openapi-server/payloads/PetTag.ts @@ -0,0 +1,101 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import addFormatsModule from 'ajv-formats'; +interface PetTagInterface { + id?: number + name?: string + additionalProperties?: Record +} +/** + * A tag for a pet + */ +class PetTag { + private _id?: number; + private _name?: string; + private _additionalProperties?: Record; + + constructor(input: PetTagInterface) { + this._id = input.id; + this._name = input.name; + this._additionalProperties = input.additionalProperties; + } + + get id(): number | undefined { return this._id; } + set id(id: number | undefined) { this._id = id; } + + get name(): string | undefined { return this._name; } + set name(name: string | undefined) { this._name = name; } + + get additionalProperties(): Record | undefined { return this._additionalProperties; } + set additionalProperties(additionalProperties: Record | undefined) { this._additionalProperties = additionalProperties; } + + public toJson(): Record { + const json: Record = {}; + if(this.id !== undefined) { + json["id"] = this.id; + } + if(this.name !== undefined) { + json["name"] = this.name; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of Object.entries(this.additionalProperties)) { + //Only unwrap those that are not already a property in the JSON object + if(["id","name","additionalProperties"].includes(String(key))) continue; + json[key] = value; + } + } + return json; + } + + public marshal(): string { + return JSON.stringify(this.toJson()); + } + + public static fromJson(obj: Record): PetTag { + const instance = new PetTag({} as any); + + if (obj["id"] !== undefined) { + instance.id = obj["id"] as number; + } + if (obj["name"] !== undefined) { + instance.name = obj["name"] as string; + } + + instance.additionalProperties = {}; + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["id","name","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties[key] = value as any; + } + return instance; + } + + public static unmarshal(json: string | object): PetTag { + const obj = typeof json === "object" ? json : JSON.parse(json); + return PetTag.fromJson(obj as Record); + } + public static theCodeGenSchema = {"title":"Pet Tag","description":"A tag for a pet","type":"object","properties":{"id":{"type":"integer","format":"int64"},"name":{"type":"string"}},"xml":{"name":"Tag"}}; + public static validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } { + const {data, ajvValidatorFunction} = context ?? {}; + // Intentionally parse JSON strings to support validation of marshalled output. + // Example: validate({data: marshal(obj)}) works because marshal returns JSON string. + // Note: String 'true' will be coerced to boolean true due to JSON.parse. + const parsedData = typeof data === 'string' ? JSON.parse(data) : data; + const validate = ajvValidatorFunction ?? this.createValidator(context) + return { + valid: validate(parsedData), + errors: validate.errors ?? undefined, + }; + } + public static createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction { + const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})}; + // `ajv-formats` is CommonJS; its default import is the module namespace under + // `moduleResolution: node16`/`nodenext`, so unwrap `.default` when present. + const addFormats = ((addFormatsModule as unknown as {default?: unknown}).default ?? addFormatsModule) as (ajv: Ajv) => Ajv; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { PetTag }; +export type { PetTagInterface }; \ No newline at end of file diff --git a/test/runtime/typescript/src/openapi-server/payloads/Status.ts b/test/runtime/typescript/src/openapi-server/payloads/Status.ts new file mode 100644 index 00000000..28dc3f54 --- /dev/null +++ b/test/runtime/typescript/src/openapi-server/payloads/Status.ts @@ -0,0 +1,10 @@ + +/** + * pet status in the store + */ +enum Status { + AVAILABLE = "available", + PENDING = "pending", + SOLD = "sold", +} +export { Status }; \ No newline at end of file diff --git a/test/runtime/typescript/test/channels/http_server/basics.spec.ts b/test/runtime/typescript/test/channels/http_server/basics.spec.ts new file mode 100644 index 00000000..caa14f4f --- /dev/null +++ b/test/runtime/typescript/test/channels/http_server/basics.spec.ts @@ -0,0 +1,486 @@ +import express, {Express, Router} from 'express'; +import {AddressInfo, Server} from 'http'; +import {APet} from '../../../src/openapi-server/payloads/APet'; +import {FindPetsByStatusAndCategoryParameters} from '../../../src/openapi-server/parameters/FindPetsByStatusAndCategoryParameters'; +import {FindPetsByStatusAndCategoryHeaders} from '../../../src/openapi-server/headers/FindPetsByStatusAndCategoryHeaders'; +import { + HttpError, + registerAddPet, + registerFindPetsByStatusAndCategory, + registerUpdatePet +} from '../../../src/openapi-server/channels/http_server'; +import { + addPet as addPetClient, + findPetsByStatusAndCategory as findPetsByStatusAndCategoryClient +} from '../../../src/openapi-server/channels/http_client'; + +jest.setTimeout(15000); + +/** + * Boot an app on an OS-assigned port, run the test body against it, then close. + */ +function runWithServer( + app: Express, + testFn: (params: {baseUrl: string; port: number}) => Promise +): Promise { + return new Promise((resolve, reject) => { + const httpServer: Server = app.listen(0); + httpServer.on('error', reject); + httpServer.on('listening', async () => { + const {port} = httpServer.address() as AddressInfo; + try { + await testFn({baseUrl: `http://localhost:${port}`, port}); + resolve(); + } catch (error) { + reject(error); + } finally { + httpServer.close(); + } + }); + }); +} + +/** An app with `express.json()` mounted, the common production setup. */ +function createJsonApp(): {app: Express; router: Router} { + const router = Router(); + const app = express(); + app.use(express.json()); + app.use(router); + return {app, router}; +} + +/** An app with NO body parser — proves `readJsonBody` reads the raw stream. */ +function createBareApp(): {app: Express; router: Router} { + const router = Router(); + const app = express(); + app.use(router); + return {app, router}; +} + +const validPet = { + id: 42, + name: 'doggie', + photoUrls: ['http://example.com/dog.png'] +}; + +describe('HTTP Server - Basics', () => { + describe('request body', () => { + it('should hand the handler a typed body and marshal the returned payload', async () => { + const {app, router} = createJsonApp(); + let receivedBody: APet | undefined; + + registerAddPet({ + router, + callback: ({body}) => { + receivedBody = body; + return {status: 200, body}; + } + }); + + return runWithServer(app, async ({baseUrl}) => { + const response = await fetch(`${baseUrl}/pet`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(validPet) + }); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain( + 'application/json' + ); + const roundTripped = APet.unmarshal(await response.text()); + expect(roundTripped.name).toEqual('doggie'); + expect(roundTripped.id).toEqual(42); + expect(receivedBody).toBeInstanceOf(APet); + expect(receivedBody?.photoUrls).toEqual([ + 'http://example.com/dog.png' + ]); + }); + }); + + it('should read the raw request stream when no body parser is mounted', async () => { + const {app, router} = createBareApp(); + let receivedBody: APet | undefined; + + registerAddPet({ + router, + callback: ({body}) => { + receivedBody = body; + return {status: 200, body}; + } + }); + + return runWithServer(app, async ({baseUrl}) => { + const response = await fetch(`${baseUrl}/pet`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(validPet) + }); + + expect(response.status).toBe(200); + expect(receivedBody?.name).toEqual('doggie'); + }); + }); + + it('should accept a plain object literal as the response body', async () => { + const {app, router} = createJsonApp(); + + registerUpdatePet({ + router, + callback: () => ({ + status: 200, + body: {name: 'plain', photoUrls: ['a']} + }) + }); + + return runWithServer(app, async ({baseUrl}) => { + const response = await fetch(`${baseUrl}/pet`, { + method: 'PUT', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(validPet) + }); + + expect(response.status).toBe(200); + expect(APet.unmarshal(await response.text()).name).toEqual('plain'); + }); + }); + }); + + describe('bodyless declared status codes', () => { + it('should send no body for a declared-but-bodyless status code', async () => { + const {app, router} = createJsonApp(); + + registerAddPet({ + router, + callback: () => ({status: 405}) + }); + + return runWithServer(app, async ({baseUrl}) => { + const response = await fetch(`${baseUrl}/pet`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(validPet) + }); + + expect(response.status).toBe(405); + expect(await response.text()).toEqual(''); + }); + }); + }); + + describe('parameters and headers', () => { + it('should give the handler typed path, query and header values', async () => { + const {app, router} = createJsonApp(); + let receivedParameters: FindPetsByStatusAndCategoryParameters | undefined; + let receivedHeaders: FindPetsByStatusAndCategoryHeaders | undefined; + + registerFindPetsByStatusAndCategory({ + router, + callback: ({parameters, requestHeaders}) => { + receivedParameters = parameters; + receivedHeaders = requestHeaders; + return {status: 200, body: [new APet(validPet)]}; + } + }); + + return runWithServer(app, async ({baseUrl}) => { + const response = await fetch( + `${baseUrl}/pet/findByStatus/available/7?limit=5&tags=cute,small&includePetDetails=true`, + { + headers: { + 'X-Request-ID': 'req-123', + 'Accept-Language': 'da-DK' + } + } + ); + + expect(response.status).toBe(200); + expect(receivedParameters).toBeInstanceOf( + FindPetsByStatusAndCategoryParameters + ); + expect(receivedParameters?.status).toEqual('available'); + expect(receivedParameters?.categoryId).toEqual(7); + expect(receivedParameters?.limit).toEqual(5); + expect(receivedParameters?.tags).toEqual(['cute', 'small']); + expect(receivedParameters?.includePetDetails).toEqual(true); + expect(receivedHeaders?.xMinusRequestMinusId).toEqual('req-123'); + expect(receivedHeaders?.acceptMinusLanguage).toEqual('da-DK'); + }); + }); + + it('should still extract parameters when the router is mounted under a prefix', async () => { + const {app, router} = createJsonApp(); + let receivedParameters: FindPetsByStatusAndCategoryParameters | undefined; + + registerFindPetsByStatusAndCategory({ + router, + callback: ({parameters}) => { + receivedParameters = parameters; + return {status: 200, body: []}; + } + }); + // Re-mount the same router behind a prefix. Express makes `request.url` + // mount-relative, so the generated code must not use `originalUrl`. + app.use('/v2', router); + + return runWithServer(app, async ({baseUrl}) => { + const response = await fetch( + `${baseUrl}/v2/pet/findByStatus/sold/3?limit=2` + ); + + expect(response.status).toBe(200); + expect(receivedParameters?.status).toEqual('sold'); + expect(receivedParameters?.categoryId).toEqual(3); + expect(receivedParameters?.limit).toEqual(2); + }); + }); + }); + + describe('error handling', () => { + it('should map a thrown HttpError onto its status and body', async () => { + const {app, router} = createJsonApp(); + + registerAddPet({ + router, + callback: () => { + throw new HttpError('nope', 404, 'Not Found', {reason: 'gone'}); + } + }); + + return runWithServer(app, async ({baseUrl}) => { + const response = await fetch(`${baseUrl}/pet`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(validPet) + }); + + expect(response.status).toBe(404); + expect(await response.json()).toEqual({reason: 'gone'}); + }); + }); + + it('should map a plain Error onto 500 without leaking its message', async () => { + const {app, router} = createJsonApp(); + + registerAddPet({ + router, + callback: () => { + throw new Error('database password is hunter2'); + } + }); + + return runWithServer(app, async ({baseUrl}) => { + const response = await fetch(`${baseUrl}/pet`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(validPet) + }); + + expect(response.status).toBe(500); + expect(await response.text()).not.toContain('hunter2'); + }); + }); + }); + + describe('hooks', () => { + it('should call beforeHandler and afterHandler', async () => { + const {app, router} = createJsonApp(); + const calls: string[] = []; + let afterStatus: number | undefined; + + registerAddPet({ + router, + hooks: { + beforeHandler: () => { + calls.push('before'); + }, + afterHandler: ({status}) => { + calls.push('after'); + afterStatus = status; + } + }, + callback: ({body}) => { + calls.push('handler'); + return {status: 200, body}; + } + }); + + return runWithServer(app, async ({baseUrl}) => { + await fetch(`${baseUrl}/pet`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(validPet) + }); + + expect(calls).toEqual(['before', 'handler', 'after']); + expect(afterStatus).toEqual(200); + }); + }); + + it('should let onError override the mapped error response', async () => { + const {app, router} = createJsonApp(); + + registerAddPet({ + router, + hooks: { + onError: () => ({status: 418, body: {teapot: true}}) + }, + callback: () => { + throw new Error('boom'); + } + }); + + return runWithServer(app, async ({baseUrl}) => { + const response = await fetch(`${baseUrl}/pet`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(validPet) + }); + + expect(response.status).toBe(418); + expect(await response.json()).toEqual({teapot: true}); + }); + }); + }); + + describe('headers on the response', () => { + it('should send additionalHeaders and let a per-response header win', async () => { + const {app, router} = createJsonApp(); + + registerAddPet({ + router, + additionalHeaders: {'X-Server': 'codegen', 'X-Shared': 'base'}, + callback: ({body}) => ({ + status: 200, + body, + headers: {'X-Shared': 'override'} + }) + }); + + return runWithServer(app, async ({baseUrl}) => { + const response = await fetch(`${baseUrl}/pet`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(validPet) + }); + + expect(response.headers.get('x-server')).toEqual('codegen'); + expect(response.headers.get('x-shared')).toEqual('override'); + }); + }); + }); + + describe('request validation', () => { + it('should reject an invalid request body with 400 and the validation causes', async () => { + const {app, router} = createJsonApp(); + let handlerCalled = false; + + registerAddPet({ + router, + callback: ({body}) => { + handlerCalled = true; + return {status: 200, body}; + } + }); + + return runWithServer(app, async ({baseUrl}) => { + const response = await fetch(`${baseUrl}/pet`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({id: 1}) + }); + + expect(response.status).toBe(400); + expect(await response.text()).toContain('cause'); + expect(handlerCalled).toEqual(false); + }); + }); + + it('should let an invalid body through when skipRequestValidation is set', async () => { + const {app, router} = createJsonApp(); + let handlerCalled = false; + + registerAddPet({ + router, + skipRequestValidation: true, + callback: ({body}) => { + handlerCalled = true; + return {status: 200, body}; + } + }); + + return runWithServer(app, async ({baseUrl}) => { + const response = await fetch(`${baseUrl}/pet`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({id: 1}) + }); + + expect(response.status).toBe(200); + expect(handlerCalled).toEqual(true); + }); + }); + }); + + describe('generated client against generated server', () => { + it('should round trip a POST through the generated HTTP client', async () => { + const {app, router} = createJsonApp(); + + registerAddPet({ + router, + callback: ({body}) => ({status: 200, body}) + }); + + return runWithServer(app, async ({baseUrl}) => { + const response = await addPetClient({ + baseUrl, + payload: new APet(validPet) + }); + + expect(response.status).toBe(200); + expect(response.data.name).toEqual('doggie'); + expect(response.data.id).toEqual(42); + }); + }); + + it('should round trip a GET with parameters and headers through the generated HTTP client', async () => { + const {app, router} = createJsonApp(); + let receivedParameters: FindPetsByStatusAndCategoryParameters | undefined; + let receivedHeaders: FindPetsByStatusAndCategoryHeaders | undefined; + + registerFindPetsByStatusAndCategory({ + router, + callback: ({parameters, requestHeaders}) => { + receivedParameters = parameters; + receivedHeaders = requestHeaders; + return {status: 200, body: [new APet(validPet)]}; + } + }); + + return runWithServer(app, async ({baseUrl}) => { + const response = await findPetsByStatusAndCategoryClient({ + baseUrl, + parameters: new FindPetsByStatusAndCategoryParameters({ + status: 'available', + categoryId: 9, + limit: 3, + tags: ['a', 'b'] + }), + requestHeaders: { + xMinusRequestMinusId: 'round-trip', + acceptMinusLanguage: 'en-GB' + } + }); + + expect(response.status).toBe(200); + expect(response.data).toHaveLength(1); + expect(response.data[0].name).toEqual('doggie'); + expect(receivedParameters?.categoryId).toEqual(9); + expect(receivedParameters?.limit).toEqual(3); + expect(receivedParameters?.tags).toEqual(['a', 'b']); + expect(receivedHeaders?.xMinusRequestMinusId).toEqual('round-trip'); + expect(receivedHeaders?.acceptMinusLanguage).toEqual('en-GB'); + }); + }); + }); +}); From 8599985a45b28fd37cd34f9947cf9c87e525c454 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Sat, 1 Aug 2026 19:47:52 +0200 Subject: [PATCH 03/16] feat: collect per-operation server response variants from OpenAPI responses Co-Authored-By: Claude Opus 5 (1M context) --- .../generators/typescript/channels/openapi.ts | 197 +++++++++++++- ...openapi-http-server-responses.spec.ts.snap | 80 ++++++ .../openapi-http-server-responses.spec.ts | 249 ++++++++++++++++++ 3 files changed, 523 insertions(+), 3 deletions(-) create mode 100644 test/codegen/generators/typescript/channels/__snapshots__/openapi-http-server-responses.spec.ts.snap create mode 100644 test/codegen/generators/typescript/channels/openapi-http-server-responses.spec.ts diff --git a/src/codegen/generators/typescript/channels/openapi.ts b/src/codegen/generators/typescript/channels/openapi.ts index 774d73bb..bdd2ef05 100644 --- a/src/codegen/generators/typescript/channels/openapi.ts +++ b/src/codegen/generators/typescript/channels/openapi.ts @@ -9,10 +9,22 @@ import {TypeScriptHeadersRenderType} from '../headers'; import { TypeScriptChannelRenderedFunctionType, SupportedProtocols, - TypeScriptChannelsContext + TypeScriptChannelsContext, + HttpServerResponseVariant } from './types'; -import {ConstrainedObjectModel} from '@asyncapi/modelina'; -import {collectProtocolDependencies, addRendersToExternal} from './utils'; +import {ChannelPayload} from '../../../types'; +import { + ConstrainedMetaModel, + ConstrainedObjectModel, + ConstrainedReferenceModel, + ConstrainedUnionModel +} from '@asyncapi/modelina'; +import { + collectProtocolDependencies, + addRendersToExternal, + parameterUnionType, + payloadUnionType +} from './utils'; import { renderHttpFetchClient, renderHttpCommonTypes, @@ -451,6 +463,185 @@ function processOperation( return render; } +/** + * Read the status code a response union member was decorated with, exactly as + * `modelina/presets/union.ts` does: the decoration sits on the referenced model + * when the member is a reference to an object, and on the member itself + * otherwise. + * + * `x-modelina-status-codes` is only set for numeric codes, so a `default` + * response member is identified by the `_Response_` `$id` + * every response schema is decorated with instead. + */ +function extractMemberStatusCode( + member: ConstrainedMetaModel +): number | 'default' | undefined { + const originalInput = + member instanceof ConstrainedReferenceModel + ? member.ref.originalInput + : member.originalInput; + const decoration = originalInput?.['x-modelina-status-codes']; + if (typeof decoration === 'number') { + return decoration; + } + if ( + decoration && + typeof decoration === 'object' && + typeof decoration.code === 'number' + ) { + return decoration.code; + } + return extractStatusCodeFromSchemaId(originalInput?.$id); +} + +/** + * Recover the status code from a decorated response schema's `$id` + * (`_Response_`, set in the OpenAPI payload processor). + */ +function extractStatusCodeFromSchemaId( + schemaId: unknown +): number | 'default' | undefined { + if (typeof schemaId !== 'string') { + return undefined; + } + const suffix = schemaId.slice(schemaId.lastIndexOf('_') + 1); + if (suffix === 'default') { + return 'default'; + } + const numericCode = Number(suffix); + return Number.isInteger(numericCode) ? numericCode : undefined; +} + +/** + * Every status code the operation declares, in the order the generated response + * union should list them: ascending numerically, with `default` last. + * + * Wildcard keys (`2XX`) cannot be returned as a concrete status and are + * skipped — an operation left with no variants falls back to the untyped + * `{status: number; body?: unknown}` response in the renderer. + */ +function collectDeclaredStatusCodes( + operation: OpenAPIOperation +): (number | 'default')[] { + const responses = (operation as {responses?: Record}) + .responses; + if (!responses) { + return []; + } + + const numericCodes: number[] = []; + let hasDefault = false; + for (const statusCode of Object.keys(responses)) { + if (statusCode === 'default') { + hasDefault = true; + continue; + } + const numericCode = Number(statusCode); + if (Number.isInteger(numericCode)) { + numericCodes.push(numericCode); + continue; + } + Logger.verbose( + `OpenAPI response key '${statusCode}' is not a concrete status code and was skipped for the HTTP server response union` + ); + } + + numericCodes.sort((left, right) => left - right); + return hasDefault ? [...numericCodes, 'default'] : numericCodes; +} + +/** + * Map each status code that carries a body to the type the generated handler + * returns for it. + * + * Two payload shapes have to be handled, because `createUnionSchema` returns + * the response schema *directly* when an operation declares exactly one + * body-carrying response and only builds a `oneOf` union when there are two or + * more: + * + * - a status-code union -> walk its members and read each member's code + * - anything else -> the single body-carrying response; its code is read from + * the model's `$id` suffix, which the payload processor sets authoritatively + */ +function collectResponseBodyTypes( + responsePayload: ChannelPayload | undefined +): Map { + const bodyTypes = new Map(); + if (!responsePayload) { + return bodyTypes; + } + + const model = responsePayload.messageModel.model; + if ( + model instanceof ConstrainedUnionModel && + model.originalInput?.['x-modelina-has-status-codes'] === true + ) { + for (const member of model.union) { + const statusCode = extractMemberStatusCode(member); + if (statusCode === undefined) { + continue; + } + // A member that references an object model is a class with `marshal()`. + // Anything else (an array, a primitive, an enum) has no importable module + // of its own — `member.type` is a bare type expression like `APet[]` — so + // the renderer falls back to `JSON.stringify` for it. + const isObjectModel = + member instanceof ConstrainedReferenceModel && + member.ref instanceof ConstrainedObjectModel; + bodyTypes.set(statusCode, { + statusCode, + bodyType: member.type, + bodyInputType: isObjectModel + ? parameterUnionType(member.type) + : member.type, + isObjectModel + }); + } + return bodyTypes; + } + + const {messageType, messageModule} = getMessageTypeAndModule(responsePayload); + if (!messageType) { + return bodyTypes; + } + const statusCode = extractStatusCodeFromSchemaId(model.originalInput?.$id); + if (statusCode === undefined) { + return bodyTypes; + } + bodyTypes.set(statusCode, { + statusCode, + bodyType: messageModule ? `${messageModule}.${messageType}` : messageType, + bodyInputType: payloadUnionType({messageType, messageModule}), + bodyModule: messageModule, + isObjectModel: messageModule === undefined + }); + return bodyTypes; +} + +/** + * The ordered set of responses a generated server handler may return for one + * operation. + * + * Declared-but-bodyless codes exist only in the raw document (the petstore's + * `addPet` declares `405` with no content), so the list is assembled from the + * raw `responses` keys unioned with the model-derived body types — neither + * source alone is complete. + */ +export function collectServerResponseVariants({ + operation, + responsePayload +}: { + operation: OpenAPIOperation; + responsePayload: ChannelPayload | undefined; +}): HttpServerResponseVariant[] { + const bodyTypes = collectResponseBodyTypes(responsePayload); + const declaredCodes = collectDeclaredStatusCodes(operation); + + return declaredCodes.map( + (statusCode) => bodyTypes.get(statusCode) ?? {statusCode} + ); +} + /** * 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 diff --git a/test/codegen/generators/typescript/channels/__snapshots__/openapi-http-server-responses.spec.ts.snap b/test/codegen/generators/typescript/channels/__snapshots__/openapi-http-server-responses.spec.ts.snap new file mode 100644 index 00000000..f380f97d --- /dev/null +++ b/test/codegen/generators/typescript/channels/__snapshots__/openapi-http-server-responses.spec.ts.snap @@ -0,0 +1,80 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`OpenAPI HTTP server response variants matches the snapshot of every collected variant list 1`] = ` +{ + "allBodylessResponses": [ + { + "statusCode": 204, + }, + ], + "arrayResponse": [ + { + "bodyInputType": "ArrayResponseResponse_200Module.ArrayResponseResponse_200", + "bodyModule": "ArrayResponseResponse_200Module", + "bodyType": "ArrayResponseResponse_200Module.ArrayResponseResponse_200", + "isObjectModel": false, + "statusCode": 200, + }, + { + "statusCode": 400, + }, + ], + "defaultResponse": [ + { + "bodyInputType": "DefaultResponseResponse_200Interface | DefaultResponseResponse_200", + "bodyType": "DefaultResponseResponse_200", + "isObjectModel": true, + "statusCode": 200, + }, + { + "bodyInputType": "DefaultResponseResponseDefaultInterface | DefaultResponseResponseDefault", + "bodyType": "DefaultResponseResponseDefault", + "isObjectModel": true, + "statusCode": "default", + }, + ], + "errorWithBody": [ + { + "bodyInputType": "ErrorWithBodyResponse_200Interface | ErrorWithBodyResponse_200", + "bodyType": "ErrorWithBodyResponse_200", + "isObjectModel": true, + "statusCode": 200, + }, + { + "bodyInputType": "ErrorWithBodyResponse_409Interface | ErrorWithBodyResponse_409", + "bodyType": "ErrorWithBodyResponse_409", + "isObjectModel": true, + "statusCode": 409, + }, + ], + "multipleBodyResponses": [ + { + "bodyInputType": "MultipleBodyResponsesResponse_200Interface | MultipleBodyResponsesResponse_200", + "bodyType": "MultipleBodyResponsesResponse_200", + "isObjectModel": true, + "statusCode": 200, + }, + { + "bodyInputType": "MultipleBodyResponsesResponse_404Interface | MultipleBodyResponsesResponse_404", + "bodyType": "MultipleBodyResponsesResponse_404", + "isObjectModel": true, + "statusCode": 404, + }, + { + "statusCode": 500, + }, + ], + "singleBodyResponse": [ + { + "bodyInputType": "SingleBodyResponseResponse_200Interface | SingleBodyResponseResponse_200", + "bodyModule": undefined, + "bodyType": "SingleBodyResponseResponse_200", + "isObjectModel": true, + "statusCode": 200, + }, + { + "statusCode": 405, + }, + ], +} +`; diff --git a/test/codegen/generators/typescript/channels/openapi-http-server-responses.spec.ts b/test/codegen/generators/typescript/channels/openapi-http-server-responses.spec.ts new file mode 100644 index 00000000..e6bb49f2 --- /dev/null +++ b/test/codegen/generators/typescript/channels/openapi-http-server-responses.spec.ts @@ -0,0 +1,249 @@ +/** + * Assembling the per-operation response variant list is the subtlest part of + * the HTTP server protocol: it has to merge the status codes declared in the + * raw document with the body types derived from the response payload models, + * across three different payload shapes (no model, a single model, a + * status-code union). + * + * It is tested here in isolation, driving the real payloads generator so the + * models are exactly the ones the channels generator later sees. + */ +import {loadOpenapiFromMemory} from '../../../../../src/codegen/inputs/openapi'; +import { + generateTypescriptPayload, + TypeScriptPayloadRenderType +} from '../../../../../src/codegen/generators/typescript/payloads'; +import {collectServerResponseVariants} from '../../../../../src/codegen/generators/typescript/channels/openapi'; +import {HttpServerResponseVariant} from '../../../../../src/codegen/generators/typescript/channels/types'; + +const petSchema = { + type: 'object', + required: ['name'], + properties: {id: {type: 'integer'}, name: {type: 'string'}} +}; + +const jsonBody = (schema: unknown) => ({ + description: 'OK', + content: {'application/json': {schema}} +}); + +const spec = { + openapi: '3.0.3', + info: {title: 'Server responses', version: '1.0.0'}, + paths: { + // The `addPet` shape: exactly one body-carrying response plus a declared + // but bodyless error code. `createUnionSchema` returns the schema directly + // here, so there are no status codes on the model at all. + '/single': { + post: { + operationId: 'singleBodyResponse', + requestBody: {content: {'application/json': {schema: petSchema}}}, + responses: { + 200: jsonBody(petSchema), + 405: {description: 'Invalid input'} + } + } + }, + // Several body-carrying responses -> a `oneOf` union carrying + // `x-modelina-status-codes` on each member. + '/multiple': { + get: { + operationId: 'multipleBodyResponses', + responses: { + 200: jsonBody(petSchema), + 404: jsonBody({ + type: 'object', + required: ['message'], + properties: {message: {type: 'string'}} + }), + 500: {description: 'Server error'} + } + } + }, + // Every declared response is bodyless. The operation must still be + // registered - the client's "skip when there is nothing to call" rule does + // not apply to a server. + '/bodyless': { + delete: { + operationId: 'allBodylessResponses', + responses: {204: {description: 'No content'}} + } + }, + // No `responses` at all. + '/none': { + get: { + operationId: 'noResponsesDeclared' + } + }, + // A `default` response with a body must sort last. + '/default': { + get: { + operationId: 'defaultResponse', + responses: { + 200: jsonBody(petSchema), + default: jsonBody({ + type: 'object', + properties: {message: {type: 'string'}} + }) + } + } + }, + // A non-object (array) body: module-qualified, no `Interface | Class` + // widening. + '/array': { + get: { + operationId: 'arrayResponse', + responses: { + 200: jsonBody({type: 'array', items: petSchema}), + 400: {description: 'Bad request'} + } + } + }, + // Codes >= 400 that DO carry a body are legitimately returnable. + '/errorbody': { + get: { + operationId: 'errorWithBody', + responses: { + 200: jsonBody(petSchema), + 409: jsonBody({ + type: 'object', + properties: {conflict: {type: 'string'}} + }) + } + } + } + } +}; + +let payloads: TypeScriptPayloadRenderType; +let document: Awaited>; + +async function variantsFor( + operationId: string, + path: string, + method: string +): Promise { + const pathItem = (document.paths as Record)[path]; + return collectServerResponseVariants({ + operation: pathItem[method], + // eslint-disable-next-line security/detect-object-injection + responsePayload: payloads.operationModels[`${operationId}_Response`] + }); +} + +describe('OpenAPI HTTP server response variants', () => { + beforeAll(async () => { + document = await loadOpenapiFromMemory({specString: JSON.stringify(spec)}); + payloads = await generateTypescriptPayload({ + generator: { + id: 'test', + preset: 'payloads', + outputPath: './test/codegen/generators/typescript/channels/output', + language: 'typescript', + dependencies: [], + serializationType: 'json', + includeValidation: true, + useForJavaScript: true, + rawPropertyNames: false, + map: 'map', + enum: 'union' + }, + inputType: 'openapi', + openapiDocument: document, + dependencyOutputs: {} + }); + }); + + it('merges a single body response with declared bodyless codes', async () => { + const variants = await variantsFor('singleBodyResponse', '/single', 'post'); + + expect(variants.map((variant) => variant.statusCode)).toEqual([200, 405]); + expect(variants[0].bodyType).toBeDefined(); + expect(variants[0].isObjectModel).toBe(true); + expect(variants[0].bodyInputType).toContain('|'); + expect(variants[1].bodyType).toBeUndefined(); + }); + + it('derives a body type per union member from the status code metadata', async () => { + const variants = await variantsFor( + 'multipleBodyResponses', + '/multiple', + 'get' + ); + + expect(variants.map((variant) => variant.statusCode)).toEqual([ + 200, 404, 500 + ]); + expect(variants[0].bodyType).toBeDefined(); + expect(variants[1].bodyType).toBeDefined(); + expect(variants[0].bodyType).not.toEqual(variants[1].bodyType); + // 500 is declared without content, so it stays bodyless. + expect(variants[2].bodyType).toBeUndefined(); + }); + + it('keeps an operation whose responses are all bodyless', async () => { + const variants = await variantsFor( + 'allBodylessResponses', + '/bodyless', + 'delete' + ); + + expect(variants).toEqual([{statusCode: 204}]); + }); + + it('returns no variants when the operation declares no responses', async () => { + const variants = await variantsFor('noResponsesDeclared', '/none', 'get'); + + expect(variants).toEqual([]); + }); + + it('sorts the default response last', async () => { + const variants = await variantsFor('defaultResponse', '/default', 'get'); + + expect(variants.map((variant) => variant.statusCode)).toEqual([ + 200, + 'default' + ]); + expect(variants[1].bodyType).toBeDefined(); + }); + + it('module-qualifies a non-object body and does not widen it', async () => { + const variants = await variantsFor('arrayResponse', '/array', 'get'); + + expect(variants.map((variant) => variant.statusCode)).toEqual([200, 400]); + expect(variants[0].isObjectModel).toBe(false); + expect(variants[0].bodyModule).toBeDefined(); + expect(variants[0].bodyType).toContain('.'); + expect(variants[0].bodyInputType).toEqual(variants[0].bodyType); + }); + + it('includes error codes that carry a body as returnable variants', async () => { + const variants = await variantsFor('errorWithBody', '/errorbody', 'get'); + + expect(variants.map((variant) => variant.statusCode)).toEqual([200, 409]); + expect(variants[1].bodyType).toBeDefined(); + }); + + it('matches the snapshot of every collected variant list', async () => { + expect({ + singleBodyResponse: await variantsFor( + 'singleBodyResponse', + '/single', + 'post' + ), + multipleBodyResponses: await variantsFor( + 'multipleBodyResponses', + '/multiple', + 'get' + ), + allBodylessResponses: await variantsFor( + 'allBodylessResponses', + '/bodyless', + 'delete' + ), + defaultResponse: await variantsFor('defaultResponse', '/default', 'get'), + arrayResponse: await variantsFor('arrayResponse', '/array', 'get'), + errorWithBody: await variantsFor('errorWithBody', '/errorbody', 'get') + }).toMatchSnapshot(); + }); +}); From f79b330c7129083fc4c1d7be19329cb9d9e17b28 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Sat, 1 Aug 2026 19:50:50 +0200 Subject: [PATCH 04/16] feat: add HTTP server common types renderer Co-Authored-By: Claude Opus 5 (1M context) --- .../channels/protocols/http/index.ts | 7 +- .../protocols/http/server-common-types.ts | 218 ++++++++++++++++++ .../server-common-types.spec.ts.snap | 204 ++++++++++++++++ .../http/server-common-types.spec.ts | 62 +++++ 4 files changed, 490 insertions(+), 1 deletion(-) create mode 100644 src/codegen/generators/typescript/channels/protocols/http/server-common-types.ts create mode 100644 test/codegen/generators/typescript/channels/protocols/http/__snapshots__/server-common-types.spec.ts.snap create mode 100644 test/codegen/generators/typescript/channels/protocols/http/server-common-types.spec.ts diff --git a/src/codegen/generators/typescript/channels/protocols/http/index.ts b/src/codegen/generators/typescript/channels/protocols/http/index.ts index b4948dbb..2fb2696c 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/index.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/index.ts @@ -24,9 +24,14 @@ import {HttpRenderType} from '../../../../../types'; import {ConstrainedObjectModel} from '@asyncapi/modelina'; import {renderHttpCommonTypes} from './common-types'; import {renderHttpFetchClient} from './client'; +import {renderHttpServerCommonTypes} from './server-common-types'; // Re-export main functions for backward compatibility -export {renderHttpCommonTypes, renderHttpFetchClient}; +export { + renderHttpCommonTypes, + renderHttpFetchClient, + renderHttpServerCommonTypes +}; // Re-export security utilities for external use export { diff --git a/src/codegen/generators/typescript/channels/protocols/http/server-common-types.ts b/src/codegen/generators/typescript/channels/protocols/http/server-common-types.ts new file mode 100644 index 00000000..39299a97 --- /dev/null +++ b/src/codegen/generators/typescript/channels/protocols/http/server-common-types.ts @@ -0,0 +1,218 @@ +/** + * Generates the common types and helper functions shared across all generated + * HTTP server functions. Rendered once per `http_server.ts`, mirroring + * `renderHttpCommonTypes` on the client side. + * + * The two sides deliberately emit their own copy of `HttpError` and the + * `HttpGlobalError` alias: `http_client.ts` and `http_server.ts` are + * independent modules, so neither may import from the other. + */ + +/** + * Renders the shared infrastructure block for the HTTP server protocol. + * + * Stateless — the same input always produces the same output, so it can be + * emitted once per file without any bookkeeping. + */ +export function renderHttpServerCommonTypes(): string { + return `// ============================================================================ +// Common Types - Shared across all HTTP server 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; + +/** + * Error a handler throws to answer with a specific HTTP status. + * + * Shape-compatible with the \`HttpError\` the generated HTTP client throws, so + * the same class reads the same on both sides of the wire. When \`body\` is set + * it is sent as-is; otherwise a \`{message, status, statusText}\` object is sent. + */ +export class HttpError extends HttpGlobalError { + status: number; + statusText: string; + body?: unknown; + + constructor(message: string, status: number, statusText: string, body?: unknown) { + super(message); + this.name = 'HttpError'; + this.status = status; + this.statusText = statusText; + this.body = body; + } +} + +/** + * Hooks for observing and customizing the generated request handlers. + */ +export interface HttpServerHooks { + /** + * Called before the request is read, for logging, metrics or access control. + * Throw an \`HttpError\` here to reject the request before it reaches the handler. + */ + beforeHandler?: (params: {request: Request}) => void | Promise; + + /** + * Called after a response has been sent. \`body\` is the JSON text that went + * out, or undefined when the response had no body. + */ + afterHandler?: (params: {request: Request; status: number; body?: string}) => void | Promise; + + /** + * Called when a handler throws. Return a replacement response to override the + * mapped one, or undefined to keep it. + */ + onError?: (params: {error: HttpGlobalError; request: Request}) => {status: number; body?: unknown} | undefined | Promise<{status: number; body?: unknown} | undefined>; +} + +/** + * Base context shared by every generated register function. + */ +export interface HttpServerContext { + /** Headers added to every response the registered route sends. */ + additionalHeaders?: Record; + + /** Hooks for extensibility */ + hooks?: HttpServerHooks; + + /** Skip validating the incoming request payload against its JSON Schema. */ + skipRequestValidation?: boolean; +} + +// ============================================================================ +// Helper Functions - Shared logic extracted for reuse +// ============================================================================ + +/** + * Read the request body as JSON. + * + * Works whether or not a body parser such as \`express.json()\` is mounted: an + * already-parsed body is handed back untouched, otherwise the raw stream is + * read. An empty body yields \`undefined\` instead of throwing, mirroring the + * client's defensive \`readOptionalJsonBody\`. + */ +function readJsonBody(request: Request): Promise { + const parsed = (request as {body?: unknown}).body; + if (typeof parsed === 'string') { + return Promise.resolve(parsed.length === 0 ? undefined : JSON.parse(parsed)); + } + // A body parser that saw no body leaves an empty object behind, so only a + // non-empty parsed body short-circuits the raw read. + if (parsed !== undefined && parsed !== null && (typeof parsed !== 'object' || Object.keys(parsed).length > 0)) { + return Promise.resolve(parsed); + } + return new Promise((resolve, reject) => { + // A parser upstream may already have drained the stream; waiting for an + // 'end' that will never fire again would hang the request forever. + if (request.readableEnded || request.complete) { + resolve(undefined); + return; + } + let raw = ''; + request.setEncoding('utf8'); + request.on('data', (chunk: string) => { raw += chunk; }); + request.on('end', () => { + if (raw.length === 0) { + resolve(undefined); + return; + } + try { + resolve(JSON.parse(raw)); + } catch (parseError) { + reject(parseError); + } + }); + request.on('error', reject); + }); +} + +/** + * Map a thrown error onto a response. + * + * An \`HttpError\` maps to its own status and body. Anything else is an internal + * failure and maps to a generic 500 — an internal error message must never + * become client-visible. + */ +function resolveErrorResponse(error: unknown): {status: number; body?: unknown} { + if (error instanceof HttpError) { + return { + status: error.status, + body: error.body ?? {message: error.message, status: error.status, statusText: error.statusText} + }; + } + return {status: 500, body: {message: 'Internal Server Error', status: 500, statusText: 'Internal Server Error'}}; +} + +/** + * Send a response. + * + * \`body\` is already-marshalled JSON text — a model's \`marshal()\` returns a + * string and applies the wire-name mapping, so \`send\` is correct here and + * \`json\` would double-encode it. \`undefined\` sends no body at all. + */ +function sendResponse({response, status, body, headers, additionalHeaders}: { + response: Response; + status: number; + body?: string; + headers?: Record; + additionalHeaders?: Record; +}): void { + // Per-response headers win over the context-wide ones. + for (const [name, value] of Object.entries({...additionalHeaders, ...headers})) { + response.setHeader(name, value); + } + response.status(status); + if (body === undefined) { + response.end(); + return; + } + response.setHeader('Content-Type', 'application/json'); + response.send(body); +} + +/** + * Turn an error thrown by a handler into a response. + * + * A half-written response cannot be recovered, so once headers are on the wire + * the error goes to Express' error middleware instead. + */ +async function handleHandlerError({error, request, response, next, hooks, additionalHeaders}: { + error: unknown; + request: Request; + response: Response; + next: NextFunction; + hooks?: HttpServerHooks; + additionalHeaders?: Record; +}): Promise { + if (response.headersSent) { + next(error); + return; + } + let resolved = resolveErrorResponse(error); + if (hooks?.onError && error instanceof HttpGlobalError) { + const override = await hooks.onError({error, request}); + if (override !== undefined) { + resolved = override; + } + } + sendResponse({ + response, + status: resolved.status, + body: resolved.body === undefined ? undefined : JSON.stringify(resolved.body), + additionalHeaders + }); +} + +// ============================================================================ +// Generated HTTP Server Functions +// ============================================================================`; +} diff --git a/test/codegen/generators/typescript/channels/protocols/http/__snapshots__/server-common-types.spec.ts.snap b/test/codegen/generators/typescript/channels/protocols/http/__snapshots__/server-common-types.spec.ts.snap new file mode 100644 index 00000000..9822901c --- /dev/null +++ b/test/codegen/generators/typescript/channels/protocols/http/__snapshots__/server-common-types.spec.ts.snap @@ -0,0 +1,204 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renderHttpServerCommonTypes matches the snapshot of the whole block 1`] = ` +"// ============================================================================ +// Common Types - Shared across all HTTP server 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; + +/** + * Error a handler throws to answer with a specific HTTP status. + * + * Shape-compatible with the \`HttpError\` the generated HTTP client throws, so + * the same class reads the same on both sides of the wire. When \`body\` is set + * it is sent as-is; otherwise a \`{message, status, statusText}\` object is sent. + */ +export class HttpError extends HttpGlobalError { + status: number; + statusText: string; + body?: unknown; + + constructor(message: string, status: number, statusText: string, body?: unknown) { + super(message); + this.name = 'HttpError'; + this.status = status; + this.statusText = statusText; + this.body = body; + } +} + +/** + * Hooks for observing and customizing the generated request handlers. + */ +export interface HttpServerHooks { + /** + * Called before the request is read, for logging, metrics or access control. + * Throw an \`HttpError\` here to reject the request before it reaches the handler. + */ + beforeHandler?: (params: {request: Request}) => void | Promise; + + /** + * Called after a response has been sent. \`body\` is the JSON text that went + * out, or undefined when the response had no body. + */ + afterHandler?: (params: {request: Request; status: number; body?: string}) => void | Promise; + + /** + * Called when a handler throws. Return a replacement response to override the + * mapped one, or undefined to keep it. + */ + onError?: (params: {error: HttpGlobalError; request: Request}) => {status: number; body?: unknown} | undefined | Promise<{status: number; body?: unknown} | undefined>; +} + +/** + * Base context shared by every generated register function. + */ +export interface HttpServerContext { + /** Headers added to every response the registered route sends. */ + additionalHeaders?: Record; + + /** Hooks for extensibility */ + hooks?: HttpServerHooks; + + /** Skip validating the incoming request payload against its JSON Schema. */ + skipRequestValidation?: boolean; +} + +// ============================================================================ +// Helper Functions - Shared logic extracted for reuse +// ============================================================================ + +/** + * Read the request body as JSON. + * + * Works whether or not a body parser such as \`express.json()\` is mounted: an + * already-parsed body is handed back untouched, otherwise the raw stream is + * read. An empty body yields \`undefined\` instead of throwing, mirroring the + * client's defensive \`readOptionalJsonBody\`. + */ +function readJsonBody(request: Request): Promise { + const parsed = (request as {body?: unknown}).body; + if (typeof parsed === 'string') { + return Promise.resolve(parsed.length === 0 ? undefined : JSON.parse(parsed)); + } + // A body parser that saw no body leaves an empty object behind, so only a + // non-empty parsed body short-circuits the raw read. + if (parsed !== undefined && parsed !== null && (typeof parsed !== 'object' || Object.keys(parsed).length > 0)) { + return Promise.resolve(parsed); + } + return new Promise((resolve, reject) => { + // A parser upstream may already have drained the stream; waiting for an + // 'end' that will never fire again would hang the request forever. + if (request.readableEnded || request.complete) { + resolve(undefined); + return; + } + let raw = ''; + request.setEncoding('utf8'); + request.on('data', (chunk: string) => { raw += chunk; }); + request.on('end', () => { + if (raw.length === 0) { + resolve(undefined); + return; + } + try { + resolve(JSON.parse(raw)); + } catch (parseError) { + reject(parseError); + } + }); + request.on('error', reject); + }); +} + +/** + * Map a thrown error onto a response. + * + * An \`HttpError\` maps to its own status and body. Anything else is an internal + * failure and maps to a generic 500 — an internal error message must never + * become client-visible. + */ +function resolveErrorResponse(error: unknown): {status: number; body?: unknown} { + if (error instanceof HttpError) { + return { + status: error.status, + body: error.body ?? {message: error.message, status: error.status, statusText: error.statusText} + }; + } + return {status: 500, body: {message: 'Internal Server Error', status: 500, statusText: 'Internal Server Error'}}; +} + +/** + * Send a response. + * + * \`body\` is already-marshalled JSON text — a model's \`marshal()\` returns a + * string and applies the wire-name mapping, so \`send\` is correct here and + * \`json\` would double-encode it. \`undefined\` sends no body at all. + */ +function sendResponse({response, status, body, headers, additionalHeaders}: { + response: Response; + status: number; + body?: string; + headers?: Record; + additionalHeaders?: Record; +}): void { + // Per-response headers win over the context-wide ones. + for (const [name, value] of Object.entries({...additionalHeaders, ...headers})) { + response.setHeader(name, value); + } + response.status(status); + if (body === undefined) { + response.end(); + return; + } + response.setHeader('Content-Type', 'application/json'); + response.send(body); +} + +/** + * Turn an error thrown by a handler into a response. + * + * A half-written response cannot be recovered, so once headers are on the wire + * the error goes to Express' error middleware instead. + */ +async function handleHandlerError({error, request, response, next, hooks, additionalHeaders}: { + error: unknown; + request: Request; + response: Response; + next: NextFunction; + hooks?: HttpServerHooks; + additionalHeaders?: Record; +}): Promise { + if (response.headersSent) { + next(error); + return; + } + let resolved = resolveErrorResponse(error); + if (hooks?.onError && error instanceof HttpGlobalError) { + const override = await hooks.onError({error, request}); + if (override !== undefined) { + resolved = override; + } + } + sendResponse({ + response, + status: resolved.status, + body: resolved.body === undefined ? undefined : JSON.stringify(resolved.body), + additionalHeaders + }); +} + +// ============================================================================ +// Generated HTTP Server Functions +// ============================================================================" +`; diff --git a/test/codegen/generators/typescript/channels/protocols/http/server-common-types.spec.ts b/test/codegen/generators/typescript/channels/protocols/http/server-common-types.spec.ts new file mode 100644 index 00000000..d52f4c36 --- /dev/null +++ b/test/codegen/generators/typescript/channels/protocols/http/server-common-types.spec.ts @@ -0,0 +1,62 @@ +/** + * The shared block emitted once per generated `http_server.ts` — the mirror of + * `renderHttpCommonTypes` for the client. + */ +import {renderHttpServerCommonTypes} from '../../../../../../../src/codegen/generators/typescript/channels/protocols/http/server-common-types'; + +describe('renderHttpServerCommonTypes', () => { + it('declares the shared server infrastructure', () => { + const code = renderHttpServerCommonTypes(); + + expect(code).toContain('export class HttpError extends HttpGlobalError'); + expect(code).toContain('export interface HttpServerHooks'); + expect(code).toContain('export interface HttpServerContext'); + expect(code).toContain('function readJsonBody'); + expect(code).toContain('function resolveErrorResponse'); + expect(code).toContain('function sendResponse'); + expect(code).toContain('function handleHandlerError'); + }); + + it('captures the global Error under an alias a payload model cannot shadow', () => { + const code = renderHttpServerCommonTypes(); + + // A document is free to declare a schema called `Error`, and its generated + // model is imported into this very file. + expect(code).toContain('const HttpGlobalError = globalThis.Error;'); + expect(code).toContain( + 'type HttpGlobalError = InstanceType;' + ); + expect(code).not.toMatch(/extends Error\b/); + expect(code).not.toMatch(/new Error\(/); + expect(code).not.toMatch(/instanceof Error\b/); + }); + + it('emits no client-only auth, retry or OAuth2 machinery', () => { + const code = renderHttpServerCommonTypes(); + + expect(code).not.toContain('OAuth2'); + expect(code).not.toContain('RetryConfig'); + expect(code).not.toContain('AuthConfig'); + expect(code).not.toContain('ApiKeyAuth'); + expect(code).not.toContain('makeRequest'); + }); + + it('never leaks a non-HttpError message into the response body', () => { + const code = renderHttpServerCommonTypes(); + const resolver = code.slice(code.indexOf('function resolveErrorResponse')); + + expect(resolver).toContain('Internal Server Error'); + // The only message that may reach the body is the one an HttpError carries. + expect(resolver).not.toMatch(/body:\s*\{message:\s*error\.message/); + }); + + it('is stateless — two calls produce identical output', () => { + expect(renderHttpServerCommonTypes()).toEqual( + renderHttpServerCommonTypes() + ); + }); + + it('matches the snapshot of the whole block', () => { + expect(renderHttpServerCommonTypes()).toMatchSnapshot(); + }); +}); From 481843ffa3b983838f457e6e7e42c2591e306d74 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Sat, 1 Aug 2026 19:55:51 +0200 Subject: [PATCH 05/16] feat: add per-operation HTTP server register renderer Co-Authored-By: Claude Opus 5 (1M context) --- .../channels/protocols/http/index.ts | 4 +- .../channels/protocols/http/server.ts | 387 ++++++++++++++++++ .../generators/typescript/channels/utils.ts | 11 +- .../http/__snapshots__/server.spec.ts.snap | 116 ++++++ .../channels/protocols/http/server.spec.ts | 379 +++++++++++++++++ 5 files changed, 894 insertions(+), 3 deletions(-) create mode 100644 src/codegen/generators/typescript/channels/protocols/http/server.ts create mode 100644 test/codegen/generators/typescript/channels/protocols/http/__snapshots__/server.spec.ts.snap create mode 100644 test/codegen/generators/typescript/channels/protocols/http/server.spec.ts diff --git a/src/codegen/generators/typescript/channels/protocols/http/index.ts b/src/codegen/generators/typescript/channels/protocols/http/index.ts index 2fb2696c..f2b74cf9 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/index.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/index.ts @@ -25,12 +25,14 @@ import {ConstrainedObjectModel} from '@asyncapi/modelina'; import {renderHttpCommonTypes} from './common-types'; import {renderHttpFetchClient} from './client'; import {renderHttpServerCommonTypes} from './server-common-types'; +import {renderHttpServerRegister} from './server'; // Re-export main functions for backward compatibility export { renderHttpCommonTypes, renderHttpFetchClient, - renderHttpServerCommonTypes + renderHttpServerCommonTypes, + renderHttpServerRegister }; // Re-export security utilities for external use diff --git a/src/codegen/generators/typescript/channels/protocols/http/server.ts b/src/codegen/generators/typescript/channels/protocols/http/server.ts new file mode 100644 index 00000000..688b3faf --- /dev/null +++ b/src/codegen/generators/typescript/channels/protocols/http/server.ts @@ -0,0 +1,387 @@ +/** + * Generates HTTP server stubs for individual API operations. + * + * Each operation gets a `register(context)` function that mounts a typed + * handler on a caller-supplied Express `Router`. It is the structural inverse + * of `renderHttpFetchClient`: where the client builds a URL, serializes + * headers and unmarshals a response, the server extracts parameters from the + * URL, deserializes headers and marshals the handler's return value. + */ +import {HttpRenderType} from '../../../../../types'; +import {pascalCase} from '../../../utils'; +import { + ChannelFunctionTypes, + HttpServerResponseVariant, + RenderHttpServerParameters +} from '../../types'; +import { + getHeaderTypeAndModule, + getValidationFunctions, + parameterInstanceExpression, + renderChannelJSDoc +} from '../../utils'; + +const METHODS_WITH_BODY = ['POST', 'PUT', 'PATCH']; +const RESPONSE_HEADERS_FIELD = + 'headers?: Record'; + +/** + * Renders the register function and its supporting types for one operation. + */ +export function renderHttpServerRegister({ + requestTopic, + method, + requestMessageType, + requestMessageModule, + channelParameters, + channelHeaders, + subName = pascalCase(requestTopic), + functionName = `register${subName}`, + description, + deprecated, + payloadGenerator, + hasDeserializeHeaders = false, + responses +}: RenderHttpServerParameters): HttpRenderType { + const contextInterfaceName = `${pascalCase(functionName)}Context`; + const responseTypeName = buildResponseTypeName(functionName); + const {headerType} = getHeaderTypeAndModule(channelHeaders); + // OpenAPI header models are always object interfaces, so a union header model + // (which would come back with a module) cannot occur here; without a + // `deserialize*` function there is nothing inbound to call either way. + const usesHeaders = hasDeserializeHeaders && headerType !== undefined; + const hasBody = + METHODS_WITH_BODY.includes(method.toUpperCase()) && + requestMessageType !== undefined; + const bodyType = requestMessageModule + ? `${requestMessageModule}.${requestMessageType}` + : requestMessageType; + + const responseUnion = generateResponseUnion({responseTypeName, responses}); + const contextInterface = generateContextInterface({ + contextInterfaceName, + responseTypeName, + bodyType: hasBody ? bodyType : undefined, + parametersType: channelParameters?.type, + headersType: usesHeaders ? headerType : undefined + }); + const jsDoc = renderChannelJSDoc({ + description, + deprecated, + fallbackDescription: `Registers an HTTP ${method.toUpperCase()} handler for ${requestTopic}` + }); + const implementation = generateRegisterImplementation({ + functionName, + contextInterfaceName, + requestTopic, + method, + responses, + hasBody, + requestMessageType, + requestMessageModule, + includeValidation: payloadGenerator.generator.includeValidation === true, + parameterModelName: channelParameters?.name, + headersType: usesHeaders ? headerType : undefined, + jsDoc + }); + + return { + messageType: hasBody ? bodyType : undefined, + replyType: responseTypeName, + code: `${responseUnion} + +${contextInterface} + +${implementation}`, + functionName, + dependencies: [ + "import { NextFunction, Request, Response, Router } from 'express';" + ], + functionType: ChannelFunctionTypes.HTTP_SERVER, + parameterType: channelParameters?.name, + headerType: usesHeaders ? headerType : undefined + }; +} + +/** + * `registerAddPet` -> `AddPetServerResponse`. The `Register` prefix is dropped + * so the response type reads as the operation's, not the registration's. + */ +function buildResponseTypeName(functionName: string): string { + const pascalName = pascalCase(functionName); + const withoutPrefix = pascalName.replace(/^Register/, ''); + return `${withoutPrefix === '' ? pascalName : withoutPrefix}ServerResponse`; +} + +/** + * The discriminated union of everything the handler may return. + * + * `default` has no concrete status, so it becomes a `{status: number}` member + * placed last. An operation that declared no usable response falls back to an + * untyped member so the stub still compiles. + */ +function generateResponseUnion({ + responseTypeName, + responses +}: { + responseTypeName: string; + responses: HttpServerResponseVariant[]; +}): string { + if (responses.length === 0) { + return `export type ${responseTypeName} = {status: number; body?: unknown; ${RESPONSE_HEADERS_FIELD}};`; + } + + const members = responses.map((variant) => { + const status = variant.statusCode === 'default' ? 'number' : variant.statusCode; + const body = variant.bodyInputType + ? `body: ${variant.bodyInputType}; ` + : ''; + return ` | {status: ${status}; ${body}${RESPONSE_HEADERS_FIELD}}`; + }); + + return `export type ${responseTypeName} = +${members.join('\n')};`; +} + +/** + * The context every register function takes: the router to mount on, the + * handler, and the shared server options. + */ +function generateContextInterface({ + contextInterfaceName, + responseTypeName, + bodyType, + parametersType, + headersType +}: { + contextInterfaceName: string; + responseTypeName: string; + bodyType?: string; + parametersType?: string; + headersType?: string; +}): string { + const callbackParameters: string[] = []; + if (bodyType) { + callbackParameters.push(` body: ${bodyType};`); + } + if (parametersType) { + callbackParameters.push(` parameters: ${parametersType};`); + } + if (headersType) { + callbackParameters.push(` requestHeaders: ${headersType};`); + } + // `request` is always handed over for read-only access to anything not + // modelled (cookies, auth headers, the raw socket). `response` and `next` are + // deliberately withheld: the typed return value owns the response, and + // handing them over would make that contract ambiguous. + callbackParameters.push(' request: Request;'); + + return `export interface ${contextInterfaceName} extends HttpServerContext { + router: Router; + callback: (params: { +${callbackParameters.join('\n')} + }) => ${responseTypeName} | Promise<${responseTypeName}>; +}`; +} + +/** + * The marshalling expression for one response variant. + * + * An object model is normalized to a class instance first, so a handler may + * return a plain object literal and still get the model's wire-name mapping. A + * module-qualified body marshals through its module. Anything else — a non-object + * union *member*, whose `bodyType` is a bare type expression like `APet[]` with + * no importable module of its own — falls back to `JSON.stringify`. + */ +function renderVariantMarshal({ + variant, + source +}: { + variant: HttpServerResponseVariant; + source: string; +}): string { + if (variant.isObjectModel && variant.bodyType) { + return `${parameterInstanceExpression({ + modelName: variant.bodyType, + source + })}.marshal()`; + } + if (variant.bodyModule) { + return `${variant.bodyModule}.marshal(${source})`; + } + return `JSON.stringify(${source})`; +} + +/** + * The statements that turn the handler's return value into response text. + * + * When the operation declares a `default` response, the union carries a + * `{status: number}` member that no literal `case` can narrow away, so the + * concrete cases reach their body through a cast. Without one — the common + * shape — the narrowing is exact and the generated code stays cast-free. + */ +function generateResponseMarshalling( + responses: HttpServerResponseVariant[] +): string { + if (responses.length === 0) { + return ` const responseBody = result.body === undefined ? undefined : JSON.stringify(result.body);`; + } + + const bodyCarrying = responses.filter((variant) => variant.bodyType); + if (bodyCarrying.length === 0) { + return ' const responseBody = undefined;'; + } + + const hasDefaultVariant = responses.some( + (variant) => variant.statusCode === 'default' + ); + const cases = bodyCarrying.map((variant) => { + // In the `default:` clause TypeScript has already removed every + // literal-status member, so the body is reachable without a cast. + const needsCast = hasDefaultVariant && variant.statusCode !== 'default'; + const bodySource = needsCast + ? `(result as {body: ${variant.bodyInputType}}).body` + : 'result.body'; + const clause = + variant.statusCode === 'default' + ? 'default' + : `case ${variant.statusCode}`; + return ` ${clause}: { + const responsePayload = ${bodySource}; + responseBody = ${renderVariantMarshal({variant, source: 'responsePayload'})}; + break; + }`; + }); + const hasDefaultCase = bodyCarrying.some( + (variant) => variant.statusCode === 'default' + ); + const fallthrough = hasDefaultCase + ? '' + : ` + default: + break;`; + + return ` let responseBody: string | undefined = undefined; + switch (result.status) { +${cases.join('\n')}${fallthrough} + }`; +} + +/** + * The register function itself. + */ +function generateRegisterImplementation({ + functionName, + contextInterfaceName, + requestTopic, + method, + responses, + hasBody, + requestMessageType, + requestMessageModule, + includeValidation, + parameterModelName, + headersType, + jsDoc +}: { + functionName: string; + contextInterfaceName: string; + requestTopic: string; + method: string; + responses: HttpServerResponseVariant[]; + hasBody: boolean; + requestMessageType?: string; + requestMessageModule?: string; + includeValidation: boolean; + parameterModelName?: string; + headersType?: string; + jsDoc: string; +}): string { + // `{param}` -> `:param`, with a leading slash guaranteed, exactly as the + // EventSource Express registration does. + const routePath = ensureLeadingSlash(requestTopic.replace(/{([^}]+)}/g, ':$1')); + + const callbackArguments: string[] = []; + const statements: string[] = []; + + if (hasBody && requestMessageType) { + const {potentialValidationFunction} = getValidationFunctions({ + includeValidation, + messageModule: requestMessageModule, + messageType: requestMessageType, + // The validation causes travel in the message; `resolveErrorResponse` + // turns an HttpError without an explicit body into `{message, ...}`. + onValidationFail: `throw new HttpError(\`Invalid request payload received; $\{JSON.stringify({cause: errors})}\`, 400, 'Bad Request');`, + skipValidationFlag: 'context.skipRequestValidation' + }); + const unmarshalTarget = requestMessageModule ?? requestMessageType; + statements.push(' const receivedData = await readJsonBody(request);'); + if (potentialValidationFunction) { + statements.push(` ${potentialValidationFunction}`); + } + // The JSON *text* is passed, not the parsed object: a primitive payload's + // `unmarshal` JSON-parses its argument, so an already-parsed value would + // both fail to type-check and throw at runtime. + statements.push( + ` const body = ${unmarshalTarget}.unmarshal(JSON.stringify(receivedData));` + ); + callbackArguments.push('body'); + } + + if (parameterModelName) { + // `request.url` is mount-relative — Express strips the mount prefix from it + // but not from `originalUrl` — and `extractPathParameters` anchors its + // regex against the whole path, so only the mount-relative form matches the + // template. Using `originalUrl` here silently breaks `app.use('/v2', router)`. + statements.push( + ` const parameters = ${parameterModelName}.fromUrl(request.url, '${requestTopic}');` + ); + callbackArguments.push('parameters'); + } + + if (headersType) { + statements.push( + ` const requestHeaders = deserialize${headersType}Headers(request.headers as Record);` + ); + callbackArguments.push('requestHeaders'); + } + + callbackArguments.push('request'); + + const marshalling = generateResponseMarshalling(responses); + const validatorCreation = + hasBody && requestMessageType && includeValidation + ? // Created once, outside the route handler: compiling an Ajv validator + // per request would be a real performance problem. + ` ${ + getValidationFunctions({ + includeValidation, + messageModule: requestMessageModule, + messageType: requestMessageType, + onValidationFail: '' + }).potentialValidatorCreation + }\n` + : ''; + + const statementsBlock = + statements.length > 0 ? `${statements.join('\n')}\n` : ''; + + return `${jsDoc} +function ${functionName}(context: ${contextInterfaceName}): void { +${validatorCreation} context.router.${method.toLowerCase()}('${routePath}', async (request: Request, response: Response, next: NextFunction) => { + try { + await context.hooks?.beforeHandler?.({request}); +${statementsBlock} const result = await context.callback({${callbackArguments.join(', ')}}); +${marshalling} + sendResponse({response, status: result.status, body: responseBody, headers: result.headers, additionalHeaders: context.additionalHeaders}); + await context.hooks?.afterHandler?.({request, status: result.status, body: responseBody}); + } catch (error) { + await handleHandlerError({error, request, response, next, hooks: context.hooks, additionalHeaders: context.additionalHeaders}); + } + }); +}`; +} + +function ensureLeadingSlash(path: string): string { + return path.startsWith('/') ? path : `/${path}`; +} diff --git a/src/codegen/generators/typescript/channels/utils.ts b/src/codegen/generators/typescript/channels/utils.ts index d35445d9..bd7c63f5 100644 --- a/src/codegen/generators/typescript/channels/utils.ts +++ b/src/codegen/generators/typescript/channels/utils.ts @@ -316,18 +316,25 @@ export function getValidationFunctions({ includeValidation, messageModule, messageType, - onValidationFail + onValidationFail, + skipValidationFlag = 'skipMessageValidation' }: { includeValidation: boolean; messageModule?: string; messageType: string; onValidationFail: string; + /** + * The expression guarding the validation block. Defaults to the + * `skipMessageValidation` argument every subscribe-family protocol declares; + * the HTTP server reads its flag off the context object instead. + */ + skipValidationFlag?: string; }) { let validatorCreation = ''; let validationFunction = ''; if (includeValidation) { validatorCreation = `const validator = ${messageModule ?? messageType}.createValidator();`; - validationFunction = `if(!skipMessageValidation) { + validationFunction = `if(!${skipValidationFlag}) { const {valid, errors} = ${messageModule ?? messageType}.validate({data: receivedData, ajvValidatorFunction: validator}); if(!valid) { ${onValidationFail} diff --git a/test/codegen/generators/typescript/channels/protocols/http/__snapshots__/server.spec.ts.snap b/test/codegen/generators/typescript/channels/protocols/http/__snapshots__/server.spec.ts.snap new file mode 100644 index 00000000..3487cb92 --- /dev/null +++ b/test/codegen/generators/typescript/channels/protocols/http/__snapshots__/server.spec.ts.snap @@ -0,0 +1,116 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renderHttpServerRegister matches the snapshot for the representative operation shapes: bare 1`] = ` +"export type HealthServerResponse = + | {status: 204; headers?: Record}; + +export interface RegisterHealthContext extends HttpServerContext { + router: Router; + callback: (params: { + request: Request; + }) => HealthServerResponse | Promise; +} + +/** + * Registers an HTTP GET handler for /health + */ +function registerHealth(context: RegisterHealthContext): void { + context.router.get('/health', async (request: Request, response: Response, next: NextFunction) => { + try { + await context.hooks?.beforeHandler?.({request}); + const result = await context.callback({request}); + const responseBody = undefined; + sendResponse({response, status: result.status, body: responseBody, headers: result.headers, additionalHeaders: context.additionalHeaders}); + await context.hooks?.afterHandler?.({request, status: result.status, body: responseBody}); + } catch (error) { + await handleHandlerError({error, request, response, next, hooks: context.hooks, additionalHeaders: context.additionalHeaders}); + } + }); +}" +`; + +exports[`renderHttpServerRegister matches the snapshot for the representative operation shapes: body-and-responses 1`] = ` +"export type AddPetServerResponse = + | {status: 200; body: APetInterface | APet; headers?: Record} + | {status: 405; headers?: Record}; + +export interface RegisterAddPetContext extends HttpServerContext { + router: Router; + callback: (params: { + body: APet; + request: Request; + }) => AddPetServerResponse | Promise; +} + +/** + * Add a new pet to the store + */ +function registerAddPet(context: RegisterAddPetContext): void { + context.router.post('/pet', async (request: Request, response: Response, next: NextFunction) => { + try { + await context.hooks?.beforeHandler?.({request}); + const receivedData = await readJsonBody(request); + const body = APet.unmarshal(JSON.stringify(receivedData)); + const result = await context.callback({body, request}); + let responseBody: string | undefined = undefined; + switch (result.status) { + case 200: { + const responsePayload = result.body; + responseBody = (responsePayload instanceof APet ? responsePayload : new APet(responsePayload)).marshal(); + break; + } + default: + break; + } + sendResponse({response, status: result.status, body: responseBody, headers: result.headers, additionalHeaders: context.additionalHeaders}); + await context.hooks?.afterHandler?.({request, status: result.status, body: responseBody}); + } catch (error) { + await handleHandlerError({error, request, response, next, hooks: context.hooks, additionalHeaders: context.additionalHeaders}); + } + }); +}" +`; + +exports[`renderHttpServerRegister matches the snapshot for the representative operation shapes: parameters-and-headers 1`] = ` +"export type FindPetsByStatusAndCategoryServerResponse = + | {status: 200; body: ListModule.List; headers?: Record} + | {status: 400; headers?: Record} + | {status: 404; headers?: Record}; + +export interface RegisterFindPetsByStatusAndCategoryContext extends HttpServerContext { + router: Router; + callback: (params: { + parameters: FindPetsByStatusAndCategoryParameters; + requestHeaders: FindPetsByStatusAndCategoryHeaders; + request: Request; + }) => FindPetsByStatusAndCategoryServerResponse | Promise; +} + +/** + * Registers an HTTP GET handler for /pet/findByStatus/{status}/{categoryId} + */ +function registerFindPetsByStatusAndCategory(context: RegisterFindPetsByStatusAndCategoryContext): void { + context.router.get('/pet/findByStatus/:status/:categoryId', async (request: Request, response: Response, next: NextFunction) => { + try { + await context.hooks?.beforeHandler?.({request}); + const parameters = FindPetsByStatusAndCategoryParameters.fromUrl(request.url, '/pet/findByStatus/{status}/{categoryId}'); + const requestHeaders = deserializeFindPetsByStatusAndCategoryHeadersHeaders(request.headers as Record); + const result = await context.callback({parameters, requestHeaders, request}); + let responseBody: string | undefined = undefined; + switch (result.status) { + case 200: { + const responsePayload = result.body; + responseBody = ListModule.marshal(responsePayload); + break; + } + default: + break; + } + sendResponse({response, status: result.status, body: responseBody, headers: result.headers, additionalHeaders: context.additionalHeaders}); + await context.hooks?.afterHandler?.({request, status: result.status, body: responseBody}); + } catch (error) { + await handleHandlerError({error, request, response, next, hooks: context.hooks, additionalHeaders: context.additionalHeaders}); + } + }); +}" +`; diff --git a/test/codegen/generators/typescript/channels/protocols/http/server.spec.ts b/test/codegen/generators/typescript/channels/protocols/http/server.spec.ts new file mode 100644 index 00000000..30dc557f --- /dev/null +++ b/test/codegen/generators/typescript/channels/protocols/http/server.spec.ts @@ -0,0 +1,379 @@ +/** + * Per-operation rendering for the `http_server` protocol — the mirror of + * `renderHttpFetchClient`. + */ +import { + ConstrainedObjectModel, + ConstrainedObjectPropertyModel, + ConstrainedStringModel +} from '@asyncapi/modelina'; +import {renderHttpServerRegister} from '../../../../../../../src/codegen/generators/typescript/channels/protocols/http/server'; +import { + ChannelFunctionTypes, + HttpServerResponseVariant, + RenderHttpServerParameters +} from '../../../../../../../src/codegen/generators/typescript/channels/types'; +import {TypeScriptPayloadRenderType} from '../../../../../../../src/codegen/generators/typescript/payloads'; + +function objectModel(name: string, propertyNames: string[]) { + const properties: Record = {}; + for (const propertyName of propertyNames) { + properties[propertyName] = new ConstrainedObjectPropertyModel( + propertyName, + propertyName, + true, + new ConstrainedStringModel(propertyName, undefined, {}, 'string') + ); + } + return new ConstrainedObjectModel( + name, + undefined, + {}, + name, + properties as any + ); +} + +const payloadGenerator = (includeValidation: boolean) => + ({ + generator: {includeValidation} + }) as unknown as TypeScriptPayloadRenderType; + +const petVariants: HttpServerResponseVariant[] = [ + { + statusCode: 200, + bodyType: 'APet', + bodyInputType: 'APetInterface | APet', + isObjectModel: true + }, + {statusCode: 405} +]; + +function render( + overrides: Partial = {} +): ReturnType { + return renderHttpServerRegister({ + requestTopic: '/pet', + method: 'POST', + requestMessageType: 'APet', + requestMessageModule: undefined, + channelParameters: undefined, + channelHeaders: undefined, + functionName: 'registerAddPet', + payloadGenerator: payloadGenerator(false), + responses: petVariants, + ...overrides + }); +} + +describe('renderHttpServerRegister', () => { + describe('the register contract', () => { + it('takes a single context object and returns void', () => { + const {code, functionName, functionType} = render(); + + expect(functionName).toEqual('registerAddPet'); + expect(functionType).toEqual(ChannelFunctionTypes.HTTP_SERVER); + expect(code).toContain( + 'export interface RegisterAddPetContext extends HttpServerContext' + ); + expect(code).toContain(' router: Router;'); + expect(code).toContain( + 'function registerAddPet(context: RegisterAddPetContext): void' + ); + }); + + it('defaults the function name from the request topic', () => { + const {functionName} = render({functionName: undefined}); + + expect(functionName).toEqual('registerPet'); + }); + + it('declares the Express import as a dependency', () => { + const {dependencies} = render(); + + expect( + dependencies.some((line: string) => line.includes("from 'express'")) + ).toBe(true); + }); + + it('passes the handler a single destructured params object', () => { + const {code} = render(); + + expect(code).toContain('callback: (params: {'); + // The typed return value owns the response — handing over `response` or + // `next` would make the contract ambiguous. + expect(code).not.toContain('response: Response;\n }'); + expect(code).toContain('request: Request;'); + }); + }); + + describe('route registration', () => { + it('converts a path template into an Express route', () => { + const {code} = render({ + requestTopic: '/pet/findByStatus/{status}/{categoryId}', + method: 'GET', + requestMessageType: undefined, + channelParameters: objectModel('FindPetsByStatusAndCategoryParameters', [ + 'status', + 'categoryId' + ]) + }); + + expect(code).toContain( + "router.get('/pet/findByStatus/:status/:categoryId'" + ); + }); + + it('adds a leading slash when the template lacks one', () => { + const {code} = render({requestTopic: 'pet', method: 'POST'}); + + expect(code).toContain("router.post('/pet'"); + }); + }); + + describe('parameters', () => { + it('extracts parameters from the mount-relative url with the path template', () => { + const {code, parameterType} = render({ + requestTopic: '/pet/findByStatus/{status}/{categoryId}', + method: 'GET', + requestMessageType: undefined, + channelParameters: objectModel('FindPetsByStatusAndCategoryParameters', [ + 'status' + ]) + }); + + expect(code).toContain( + "FindPetsByStatusAndCategoryParameters.fromUrl(request.url, '/pet/findByStatus/{status}/{categoryId}')" + ); + // Express strips the mount prefix from `url` but not from `originalUrl`, + // and `extractPathParameters` anchors its regex against the whole path. + expect(code).not.toContain('originalUrl'); + expect(code).toContain('parameters,'); + expect(parameterType).toEqual('FindPetsByStatusAndCategoryParameters'); + }); + + it('emits no parameter extraction when the operation has none', () => { + const {code} = render(); + + expect(code).not.toContain('fromUrl'); + expect(code).not.toContain('parameters:'); + }); + }); + + describe('headers', () => { + it('deserializes request headers when the model exposes a deserializer', () => { + const {code, headerType} = render({ + channelHeaders: objectModel('AddPetHeaders', ['xRequestId']), + hasDeserializeHeaders: true + }); + + expect(code).toContain( + 'deserializeAddPetHeadersHeaders(request.headers' + ); + expect(code).toContain('requestHeaders: AddPetHeaders;'); + expect(headerType).toEqual('AddPetHeaders'); + }); + + it('emits no header handling when there is no deserializer', () => { + const {code} = render({ + channelHeaders: objectModel('AddPetHeaders', ['xRequestId']), + hasDeserializeHeaders: false + }); + + expect(code).not.toContain('deserializeAddPetHeadersHeaders'); + expect(code).not.toContain('requestHeaders'); + }); + }); + + describe('request body', () => { + it('unmarshals the body for body-carrying methods', () => { + const {code} = render({method: 'PUT'}); + + expect(code).toContain('readJsonBody(request)'); + // The JSON text is passed, not the parsed object: a primitive payload's + // `unmarshal` JSON-parses its argument. + expect(code).toContain('APet.unmarshal(JSON.stringify('); + expect(code).toContain('body: APet;'); + }); + + it('emits no body handling for GET and DELETE', () => { + for (const method of ['GET', 'DELETE'] as const) { + const {code} = render({method, requestMessageType: undefined}); + + expect(code).not.toContain('readJsonBody'); + expect(code).not.toContain('body: APet;'); + } + }); + }); + + describe('validation', () => { + it('creates the validator once outside the route handler', () => { + const {code} = render({payloadGenerator: payloadGenerator(true)}); + + expect(code).toContain('APet.createValidator()'); + expect(code).toContain('skipRequestValidation'); + // Compiling an Ajv validator per request would be a real performance bug. + const validatorIndex = code.indexOf('createValidator()'); + const routeIndex = code.indexOf('router.post('); + expect(validatorIndex).toBeGreaterThan(-1); + expect(validatorIndex).toBeLessThan(routeIndex); + }); + + it('emits no validation code when includeValidation is off', () => { + const {code} = render({payloadGenerator: payloadGenerator(false)}); + + expect(code).not.toContain('createValidator'); + expect(code).not.toContain('skipRequestValidation'); + }); + }); + + describe('the response union', () => { + it('renders one member per declared variant', () => { + const {code, replyType} = render(); + + expect(replyType).toEqual('AddPetServerResponse'); + expect(code).toContain('export type AddPetServerResponse ='); + expect(code).toContain( + '| {status: 200; body: APetInterface | APet; headers?: Record}' + ); + expect(code).toContain( + '| {status: 405; headers?: Record}' + ); + }); + + it('falls back to an untyped response when no variants were collected', () => { + const {code} = render({responses: []}); + + expect(code).toContain( + 'export type AddPetServerResponse = {status: number; body?: unknown; headers?: Record};' + ); + }); + + it('marshals a module-qualified body through its module', () => { + const {code} = render({ + method: 'GET', + requestMessageType: undefined, + responses: [ + { + statusCode: 200, + bodyType: 'ListModule.List', + bodyInputType: 'ListModule.List', + bodyModule: 'ListModule', + isObjectModel: false + } + ] + }); + + expect(code).toContain('ListModule.marshal('); + }); + + it('falls back to JSON.stringify for a non-object union member', () => { + const {code} = render({ + method: 'GET', + requestMessageType: undefined, + responses: [ + { + statusCode: 200, + bodyType: 'APet[]', + bodyInputType: 'APet[]', + isObjectModel: false + } + ] + }); + + expect(code).toContain('JSON.stringify('); + }); + + it('renders the default response as an open status member placed last', () => { + const {code} = render({ + responses: [ + {statusCode: 200, bodyType: 'APet', bodyInputType: 'APet', isObjectModel: true}, + {statusCode: 'default'} + ] + }); + + const union = code.slice(0, code.indexOf('export interface')); + expect(union).toContain( + '| {status: number; headers?: Record}' + ); + expect(union.indexOf('status: 200')).toBeLessThan( + union.indexOf('status: number') + ); + // The open `{status: number}` member cannot be narrowed away by a literal + // `case`, so the concrete cases reach their body through a cast. + expect(code).toContain('(result as {body: APet}).body'); + }); + + it('emits a default clause carrying the default response body', () => { + const {code} = render({ + responses: [ + {statusCode: 200, bodyType: 'APet', bodyInputType: 'APet', isObjectModel: true}, + { + statusCode: 'default', + bodyType: 'AnError', + bodyInputType: 'AnErrorInterface | AnError', + isObjectModel: true + } + ] + }); + + expect(code).toContain('default: {'); + expect(code).toContain('new AnError(responsePayload)'); + }); + }); + + describe('JSDoc', () => { + it('propagates the operation description and deprecation', () => { + const {code} = render({ + description: 'Add a new pet to the store', + deprecated: true + }); + + expect(code).toContain('Add a new pet to the store'); + expect(code).toContain('@deprecated'); + }); + }); + + it('matches the snapshot for the representative operation shapes', () => { + expect(render({description: 'Add a new pet to the store'}).code).toMatchSnapshot( + 'body-and-responses' + ); + expect( + render({ + requestTopic: '/pet/findByStatus/{status}/{categoryId}', + method: 'GET', + functionName: 'registerFindPetsByStatusAndCategory', + requestMessageType: undefined, + channelParameters: objectModel( + 'FindPetsByStatusAndCategoryParameters', + ['status'] + ), + channelHeaders: objectModel('FindPetsByStatusAndCategoryHeaders', [ + 'xRequestId' + ]), + hasDeserializeHeaders: true, + payloadGenerator: payloadGenerator(true), + responses: [ + { + statusCode: 200, + bodyType: 'ListModule.List', + bodyInputType: 'ListModule.List', + bodyModule: 'ListModule', + isObjectModel: false + }, + {statusCode: 400}, + {statusCode: 404} + ] + }).code + ).toMatchSnapshot('parameters-and-headers'); + expect( + render({ + requestTopic: '/health', + method: 'GET', + functionName: 'registerHealth', + requestMessageType: undefined, + responses: [{statusCode: 204}] + }).code + ).toMatchSnapshot('bare'); + }); +}); From d401ecf902d59d3d05090a41407493cf8b5a141f Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Sat, 1 Aug 2026 20:00:05 +0200 Subject: [PATCH 06/16] feat: dispatch OpenAPI channels per protocol and add http_server Co-Authored-By: Claude Opus 5 (1M context) --- .../generators/typescript/channels/index.ts | 2 +- .../generators/typescript/channels/openapi.ts | 319 +++++++++++++++-- .../channels/protocols/http/server.ts | 9 +- .../openapi-http-server.spec.ts.snap | 326 ++++++++++++++++++ .../channels/openapi-http-server.spec.ts | 313 +++++++++++++++++ 5 files changed, 942 insertions(+), 27 deletions(-) create mode 100644 test/codegen/generators/typescript/channels/__snapshots__/openapi-http-server.spec.ts.snap create mode 100644 test/codegen/generators/typescript/channels/openapi-http-server.spec.ts diff --git a/src/codegen/generators/typescript/channels/index.ts b/src/codegen/generators/typescript/channels/index.ts index 8cad104a..f17e52e3 100644 --- a/src/codegen/generators/typescript/channels/index.ts +++ b/src/codegen/generators/typescript/channels/index.ts @@ -164,7 +164,7 @@ async function finalizeGeneration( // was generated and how to fix it. if (generatedProtocols.length === 0) { Logger.warn( - `Channels generator '${context.generator.id}' produced no protocol functions. Set 'protocols' (e.g. nats, kafka, mqtt, amqp, http_client, websocket, event_source) in the generator configuration to generate channel functions.` + `Channels generator '${context.generator.id}' produced no protocol functions. Set 'protocols' (e.g. nats, kafka, mqtt, amqp, http_client, http_server, websocket, event_source) in the generator configuration to generate channel functions.` ); } diff --git a/src/codegen/generators/typescript/channels/openapi.ts b/src/codegen/generators/typescript/channels/openapi.ts index bdd2ef05..55fee781 100644 --- a/src/codegen/generators/typescript/channels/openapi.ts +++ b/src/codegen/generators/typescript/channels/openapi.ts @@ -28,10 +28,12 @@ import { import { renderHttpFetchClient, renderHttpCommonTypes, + renderHttpServerCommonTypes, + renderHttpServerRegister, analyzeSecuritySchemes } from './protocols/http'; import {getMessageTypeAndModule, splitAddressSegments} from './utils'; -import {camelCase} from '../utils'; +import {camelCase, pascalCase} from '../utils'; import {createMissingInputDocumentError} from '../../../errors'; import {resolveImportExtension} from '../../../utils'; import {Logger} from '../../../../LoggingInterface'; @@ -68,8 +70,30 @@ const HTTP_METHODS: HttpMethod[] = [ const METHODS_WITH_BODY: HttpMethod[] = ['post', 'put', 'patch']; /** - * Generates TypeScript HTTP client channels from an OpenAPI document. - * Only supports http_client protocol - other protocols are ignored for OpenAPI input. + * The pieces every OpenAPI protocol path needs. Grouped so the dispatcher can + * hand the same bundle to each branch. + */ +interface OpenAPIProtocolGenerationContext { + context: TypeScriptChannelsContext; + openapiDocument: OpenAPIDocument; + parameters: TypeScriptParameterRenderType; + payloads: TypeScriptPayloadRenderType; + headers: TypeScriptHeadersRenderType; + protocolCodeFunctions: Record; + externalProtocolFunctionInformation: Record< + string, + TypeScriptChannelRenderedFunctionType[] + >; + protocolDependencies: Record; +} + +/** + * Generates TypeScript channels from an OpenAPI document. + * + * OpenAPI input supports the two HTTP protocols: `http_client` (outbound + * request functions) and `http_server` (inbound Express handler stubs). Every + * other protocol is silently skipped — `finalizeGeneration` already warns when + * a generation produced nothing at all. */ export async function generateTypeScriptChannelsForOpenAPI( context: TypeScriptChannelsContext, @@ -84,18 +108,54 @@ export async function generateTypeScriptChannelsForOpenAPI( >, protocolDependencies: Record ): Promise { - // Only http_client is supported for OpenAPI - if (!protocolsToUse.includes('http_client')) { + const supportedProtocols = protocolsToUse.filter( + (protocol) => protocol === 'http_client' || protocol === 'http_server' + ); + if (supportedProtocols.length === 0) { return; } const {openapiDocument} = validateOpenAPIContext(context); + const generationContext: OpenAPIProtocolGenerationContext = { + context, + openapiDocument, + parameters, + payloads, + headers, + protocolCodeFunctions, + externalProtocolFunctionInformation, + protocolDependencies + }; - // Extract security schemes from the OpenAPI document - const securitySchemes = extractSecuritySchemes(openapiDocument); + for (const protocol of supportedProtocols) { + switch (protocol) { + case 'http_client': + generateOpenAPIHttpClientChannels(generationContext); + break; + case 'http_server': + generateOpenAPIHttpServerChannels(generationContext); + break; + default: + // Unreachable: `supportedProtocols` only ever holds the two above. + break; + } + } +} - // Collect dependencies - const deps = protocolDependencies['http_client']; +/** + * Collect the payload/parameter/header imports for one protocol file. + */ +function collectDependenciesForProtocol({ + protocol, + generationContext +}: { + protocol: 'http_client' | 'http_server'; + generationContext: OpenAPIProtocolGenerationContext; +}): string[] { + const {context, parameters, payloads, headers, protocolDependencies} = + generationContext; + // eslint-disable-next-line security/detect-object-injection + const deps = protocolDependencies[protocol]; const importExtension = resolveImportExtension( context.generator, context.config @@ -108,6 +168,27 @@ export async function generateTypeScriptChannelsForOpenAPI( deps, importExtension ); + return deps; +} + +function generateOpenAPIHttpClientChannels( + generationContext: OpenAPIProtocolGenerationContext +): void { + const { + openapiDocument, + parameters, + payloads, + headers, + protocolCodeFunctions, + externalProtocolFunctionInformation + } = generationContext; + + // Extract security schemes from the OpenAPI document + const securitySchemes = extractSecuritySchemes(openapiDocument); + const deps = collectDependenciesForProtocol({ + protocol: 'http_client', + generationContext + }); // OAuth2 request handling is only generated when the spec defines an OAuth2 // scheme; otherwise the narrowed AuthConfig union would make that code fail to @@ -147,6 +228,43 @@ export async function generateTypeScriptChannelsForOpenAPI( }); } +function generateOpenAPIHttpServerChannels( + generationContext: OpenAPIProtocolGenerationContext +): void { + const { + openapiDocument, + parameters, + payloads, + headers, + protocolCodeFunctions, + externalProtocolFunctionInformation + } = generationContext; + + const deps = collectDependenciesForProtocol({ + protocol: 'http_server', + generationContext + }); + + const renders = processOpenAPIServerOperations({ + openapiDocument, + payloads, + parameters, + headers + }); + + if (protocolCodeFunctions['http_server'].length === 0 && renders.length > 0) { + protocolCodeFunctions['http_server'].unshift(renderHttpServerCommonTypes()); + } + + addRendersToExternal({ + protocol: 'http_server', + renders, + protocolCodeFunctions, + externalProtocolFunctionInformation, + dependencies: deps + }); +} + /** * Collect the error status codes declared across every operation in the * document. Numeric response keys >= 400 and the literal 'default' are gathered @@ -336,6 +454,164 @@ function processOpenAPIOperations( return renders; } +/** + * The models the payload/parameter/header generators produced for one + * operation. + * + * The correlation key is `deriveOperationId`, and it has to stay identical + * across the client and server paths — otherwise a generated server stub and + * its generated client would disagree about which model belongs to which + * operation. Doing the lookups in one place is what guarantees that. + */ +function correlateOperationModels({ + operationId, + hasBody, + payloads, + parameters, + headers +}: { + operationId: string; + hasBody: boolean; + payloads: TypeScriptPayloadRenderType; + parameters: TypeScriptParameterRenderType; + headers: TypeScriptHeadersRenderType; +}) { + /* eslint-disable security/detect-object-injection */ + return { + requestPayload: hasBody ? payloads.operationModels[operationId] : undefined, + responsePayload: payloads.operationModels[`${operationId}_Response`], + parameterModel: parameters.channelModels[operationId], + headersModel: headers.channelModels[operationId] + }; + /* eslint-enable security/detect-object-injection */ +} + +/** + * Iterate every operation in the document and render its server stub. + */ +function processOpenAPIServerOperations({ + openapiDocument, + payloads, + parameters, + headers +}: { + openapiDocument: OpenAPIDocument; + payloads: TypeScriptPayloadRenderType; + parameters: TypeScriptParameterRenderType; + headers: TypeScriptHeadersRenderType; +}): ReturnType[] { + const renders: ReturnType[] = []; + + for (const [path, pathItem] of Object.entries(openapiDocument.paths ?? {})) { + if (!pathItem) { + continue; + } + + for (const method of HTTP_METHODS) { + const render = processServerOperation({ + pathItem, + method, + path, + payloads, + parameters, + headers + }); + if (render) { + renders.push(render); + } + } + } + + return renders; +} + +/** + * Render a single operation's server stub. + * + * Unlike the client, an operation is never skipped for lacking a response + * body: a server has to register a route for every operation the document + * declares, or requests to it would 404. + */ +function processServerOperation({ + pathItem, + method, + path, + payloads, + parameters, + headers +}: { + pathItem: OpenAPIV3.PathItemObject | OpenAPIV2.PathsObject; + method: HttpMethod; + path: string; + payloads: TypeScriptPayloadRenderType; + parameters: TypeScriptParameterRenderType; + headers: TypeScriptHeadersRenderType; +}): ReturnType | undefined { + // eslint-disable-next-line security/detect-object-injection + const operation = (pathItem as Record)[method] as + | OpenAPIOperation + | undefined; + if (!operation) { + return undefined; + } + + const operationId = deriveOperationId({ + operationId: operation.operationId, + method, + path + }); + const hasBody = METHODS_WITH_BODY.includes(method); + const {requestPayload, responsePayload, parameterModel, headersModel} = + correlateOperationModels({ + operationId, + hasBody, + payloads, + parameters, + headers + }); + + const {messageModule: requestMessageModule, messageType: requestMessageType} = + requestPayload + ? getMessageTypeAndModule(requestPayload) + : {messageModule: undefined, messageType: undefined}; + + const render = renderHttpServerRegister({ + subName: pascalCase(operationId), + // An operation-id-derived name is stable and shares its source with the + // client's `camelCase(operationId)`, so the two sides stay recognisably + // paired. + functionName: `register${pascalCase(operationId)}`, + requestTopic: path, + method: method.toUpperCase() as + | 'GET' + | 'POST' + | 'PUT' + | 'PATCH' + | 'DELETE' + | 'OPTIONS' + | 'HEAD', + requestMessageType, + requestMessageModule, + channelParameters: parameterModel?.model as + | ConstrainedObjectModel + | undefined, + channelHeaders: headersModel?.model as ConstrainedObjectModel | undefined, + description: operation.description ?? operation.summary, + deprecated: operation.deprecated === true, + payloadGenerator: payloads, + hasDeserializeHeaders: headersModel !== undefined, + responses: collectServerResponseVariants({operation, responsePayload}) + }); + + // Grouping metadata for the `organization` option, set exactly as the client + // path sets it. + render.tags = operation.tags ?? []; + render.pathSegments = splitAddressSegments(path); + render.method = method.toLowerCase(); + + return render; +} + /** * Process a single OpenAPI operation and generate an HTTP client function. */ @@ -364,22 +640,15 @@ function processOperation( }); const hasBody = METHODS_WITH_BODY.includes(method); - // Look up payloads - const requestPayload = hasBody - ? // eslint-disable-next-line security/detect-object-injection - payloads.operationModels[operationId] - : undefined; - const responsePayloadKey = `${operationId}_Response`; - // eslint-disable-next-line security/detect-object-injection - const responsePayload = payloads.operationModels[responsePayloadKey]; - - // Look up parameters - // eslint-disable-next-line security/detect-object-injection - const parameterModel = parameters.channelModels[operationId]; - - // Look up headers - // eslint-disable-next-line security/detect-object-injection - const headersModel = headers.channelModels[operationId]; + // Look up the payload/parameter/header models for this operation + const {requestPayload, responsePayload, parameterModel, headersModel} = + correlateOperationModels({ + operationId, + hasBody, + payloads, + parameters, + headers + }); // Get message types - handle undefined payloads const requestMessageInfo = requestPayload diff --git a/src/codegen/generators/typescript/channels/protocols/http/server.ts b/src/codegen/generators/typescript/channels/protocols/http/server.ts index 688b3faf..ddc2a235 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/server.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/server.ts @@ -317,7 +317,14 @@ function generateRegisterImplementation({ const unmarshalTarget = requestMessageModule ?? requestMessageType; statements.push(' const receivedData = await readJsonBody(request);'); if (potentialValidationFunction) { - statements.push(` ${potentialValidationFunction}`); + // The shared helper renders at zero indentation; line it up with the rest + // of the route handler body. + statements.push( + potentialValidationFunction + .split('\n') + .map((line) => ` ${line}`) + .join('\n') + ); } // The JSON *text* is passed, not the parsed object: a primitive payload's // `unmarshal` JSON-parses its argument, so an already-parsed value would diff --git a/test/codegen/generators/typescript/channels/__snapshots__/openapi-http-server.spec.ts.snap b/test/codegen/generators/typescript/channels/__snapshots__/openapi-http-server.spec.ts.snap new file mode 100644 index 00000000..e6400bff --- /dev/null +++ b/test/codegen/generators/typescript/channels/__snapshots__/openapi-http-server.spec.ts.snap @@ -0,0 +1,326 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`OpenAPI http_server channels generates one register function per operation with the common types once: http_server-protocol-code 1`] = ` +"import {AddPetRequest, AddPetRequestInterface} from './../payloads/AddPetRequest'; +import {AddPetResponse_200, AddPetResponse_200Interface} from './../payloads/AddPetResponse_200'; +import {GetPetByIdResponse_200, GetPetByIdResponse_200Interface} from './../payloads/GetPetByIdResponse_200'; +import {GetPetByIdParameters, GetPetByIdParametersInterface} from './../parameters/GetPetByIdParameters'; +import {GetPetByIdHeaders, serializeGetPetByIdHeadersHeaders, deserializeGetPetByIdHeadersHeaders} from './../headers/GetPetByIdHeaders'; +import { NextFunction, Request, Response, Router } from 'express'; + +// ============================================================================ +// Common Types - Shared across all HTTP server 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; + +/** + * Error a handler throws to answer with a specific HTTP status. + * + * Shape-compatible with the \`HttpError\` the generated HTTP client throws, so + * the same class reads the same on both sides of the wire. When \`body\` is set + * it is sent as-is; otherwise a \`{message, status, statusText}\` object is sent. + */ +export class HttpError extends HttpGlobalError { + status: number; + statusText: string; + body?: unknown; + + constructor(message: string, status: number, statusText: string, body?: unknown) { + super(message); + this.name = 'HttpError'; + this.status = status; + this.statusText = statusText; + this.body = body; + } +} + +/** + * Hooks for observing and customizing the generated request handlers. + */ +export interface HttpServerHooks { + /** + * Called before the request is read, for logging, metrics or access control. + * Throw an \`HttpError\` here to reject the request before it reaches the handler. + */ + beforeHandler?: (params: {request: Request}) => void | Promise; + + /** + * Called after a response has been sent. \`body\` is the JSON text that went + * out, or undefined when the response had no body. + */ + afterHandler?: (params: {request: Request; status: number; body?: string}) => void | Promise; + + /** + * Called when a handler throws. Return a replacement response to override the + * mapped one, or undefined to keep it. + */ + onError?: (params: {error: HttpGlobalError; request: Request}) => {status: number; body?: unknown} | undefined | Promise<{status: number; body?: unknown} | undefined>; +} + +/** + * Base context shared by every generated register function. + */ +export interface HttpServerContext { + /** Headers added to every response the registered route sends. */ + additionalHeaders?: Record; + + /** Hooks for extensibility */ + hooks?: HttpServerHooks; + + /** Skip validating the incoming request payload against its JSON Schema. */ + skipRequestValidation?: boolean; +} + +// ============================================================================ +// Helper Functions - Shared logic extracted for reuse +// ============================================================================ + +/** + * Read the request body as JSON. + * + * Works whether or not a body parser such as \`express.json()\` is mounted: an + * already-parsed body is handed back untouched, otherwise the raw stream is + * read. An empty body yields \`undefined\` instead of throwing, mirroring the + * client's defensive \`readOptionalJsonBody\`. + */ +function readJsonBody(request: Request): Promise { + const parsed = (request as {body?: unknown}).body; + if (typeof parsed === 'string') { + return Promise.resolve(parsed.length === 0 ? undefined : JSON.parse(parsed)); + } + // A body parser that saw no body leaves an empty object behind, so only a + // non-empty parsed body short-circuits the raw read. + if (parsed !== undefined && parsed !== null && (typeof parsed !== 'object' || Object.keys(parsed).length > 0)) { + return Promise.resolve(parsed); + } + return new Promise((resolve, reject) => { + // A parser upstream may already have drained the stream; waiting for an + // 'end' that will never fire again would hang the request forever. + if (request.readableEnded || request.complete) { + resolve(undefined); + return; + } + let raw = ''; + request.setEncoding('utf8'); + request.on('data', (chunk: string) => { raw += chunk; }); + request.on('end', () => { + if (raw.length === 0) { + resolve(undefined); + return; + } + try { + resolve(JSON.parse(raw)); + } catch (parseError) { + reject(parseError); + } + }); + request.on('error', reject); + }); +} + +/** + * Map a thrown error onto a response. + * + * An \`HttpError\` maps to its own status and body. Anything else is an internal + * failure and maps to a generic 500 — an internal error message must never + * become client-visible. + */ +function resolveErrorResponse(error: unknown): {status: number; body?: unknown} { + if (error instanceof HttpError) { + return { + status: error.status, + body: error.body ?? {message: error.message, status: error.status, statusText: error.statusText} + }; + } + return {status: 500, body: {message: 'Internal Server Error', status: 500, statusText: 'Internal Server Error'}}; +} + +/** + * Send a response. + * + * \`body\` is already-marshalled JSON text — a model's \`marshal()\` returns a + * string and applies the wire-name mapping, so \`send\` is correct here and + * \`json\` would double-encode it. \`undefined\` sends no body at all. + */ +function sendResponse({response, status, body, headers, additionalHeaders}: { + response: Response; + status: number; + body?: string; + headers?: Record; + additionalHeaders?: Record; +}): void { + // Per-response headers win over the context-wide ones. + for (const [name, value] of Object.entries({...additionalHeaders, ...headers})) { + response.setHeader(name, value); + } + response.status(status); + if (body === undefined) { + response.end(); + return; + } + response.setHeader('Content-Type', 'application/json'); + response.send(body); +} + +/** + * Turn an error thrown by a handler into a response. + * + * A half-written response cannot be recovered, so once headers are on the wire + * the error goes to Express' error middleware instead. + */ +async function handleHandlerError({error, request, response, next, hooks, additionalHeaders}: { + error: unknown; + request: Request; + response: Response; + next: NextFunction; + hooks?: HttpServerHooks; + additionalHeaders?: Record; +}): Promise { + if (response.headersSent) { + next(error); + return; + } + let resolved = resolveErrorResponse(error); + if (hooks?.onError && error instanceof HttpGlobalError) { + const override = await hooks.onError({error, request}); + if (override !== undefined) { + resolved = override; + } + } + sendResponse({ + response, + status: resolved.status, + body: resolved.body === undefined ? undefined : JSON.stringify(resolved.body), + additionalHeaders + }); +} + +// ============================================================================ +// Generated HTTP Server Functions +// ============================================================================ + +export type AddPetServerResponse = + | {status: 200; body: AddPetResponse_200Interface | AddPetResponse_200; headers?: Record} + | {status: 405; headers?: Record}; + +export interface RegisterAddPetContext extends HttpServerContext { + router: Router; + callback: (params: { + body: AddPetRequest; + request: Request; + }) => AddPetServerResponse | Promise; +} + +/** + * Add a new pet to the store + */ +function registerAddPet(context: RegisterAddPetContext): void { + const validator = AddPetRequest.createValidator(); + context.router.post('/pet', async (request: Request, response: Response, next: NextFunction) => { + try { + await context.hooks?.beforeHandler?.({request}); + const receivedData = await readJsonBody(request); + if(!context.skipRequestValidation) { + const {valid, errors} = AddPetRequest.validate({data: receivedData, ajvValidatorFunction: validator}); + if(!valid) { + throw new HttpError(\`Invalid request payload received; \${JSON.stringify({cause: errors})}\`, 400, 'Bad Request'); + } + } + const body = AddPetRequest.unmarshal(JSON.stringify(receivedData)); + const result = await context.callback({body, request}); + let responseBody: string | undefined = undefined; + switch (result.status) { + case 200: { + const responsePayload = result.body; + responseBody = (responsePayload instanceof AddPetResponse_200 ? responsePayload : new AddPetResponse_200(responsePayload)).marshal(); + break; + } + default: + break; + } + sendResponse({response, status: result.status, body: responseBody, headers: result.headers, additionalHeaders: context.additionalHeaders}); + await context.hooks?.afterHandler?.({request, status: result.status, body: responseBody}); + } catch (error) { + await handleHandlerError({error, request, response, next, hooks: context.hooks, additionalHeaders: context.additionalHeaders}); + } + }); +} + +export type GetPetByIdServerResponse = + | {status: 200; body: GetPetByIdResponse_200Interface | GetPetByIdResponse_200; headers?: Record} + | {status: 404; headers?: Record}; + +export interface RegisterGetPetByIdContext extends HttpServerContext { + router: Router; + callback: (params: { + parameters: GetPetByIdParameters; + requestHeaders: GetPetByIdHeaders; + request: Request; + }) => GetPetByIdServerResponse | Promise; +} + +/** + * Registers an HTTP GET handler for /pet/{petId} + */ +function registerGetPetById(context: RegisterGetPetByIdContext): void { + context.router.get('/pet/:petId', async (request: Request, response: Response, next: NextFunction) => { + try { + await context.hooks?.beforeHandler?.({request}); + const parameters = GetPetByIdParameters.fromUrl(request.url, '/pet/{petId}'); + const requestHeaders = deserializeGetPetByIdHeadersHeaders(request.headers as Record); + const result = await context.callback({parameters, requestHeaders, request}); + let responseBody: string | undefined = undefined; + switch (result.status) { + case 200: { + const responsePayload = result.body; + responseBody = (responsePayload instanceof GetPetByIdResponse_200 ? responsePayload : new GetPetByIdResponse_200(responsePayload)).marshal(); + break; + } + default: + break; + } + sendResponse({response, status: result.status, body: responseBody, headers: result.headers, additionalHeaders: context.additionalHeaders}); + await context.hooks?.afterHandler?.({request, status: result.status, body: responseBody}); + } catch (error) { + await handleHandlerError({error, request, response, next, hooks: context.hooks, additionalHeaders: context.additionalHeaders}); + } + }); +} + +export { registerAddPet, registerGetPetById }; +" +`; + +exports[`OpenAPI http_server channels groups the register functions by tag: http_server-tag-barrel 1`] = ` +"import * as internal_http_server from './http_server'; + +export const http_server = { + pet: { + registerAddPet: internal_http_server.registerAddPet, + registerGetPetById: internal_http_server.registerGetPetById + } +} as const; +" +`; + +exports[`OpenAPI http_server channels nests the register functions by path with the method as the leaf: http_server-path-barrel 1`] = ` +"import * as internal_http_server from './http_server'; + +export const http_server = { + pet: { + post: internal_http_server.registerAddPet, + get: internal_http_server.registerGetPetById + } +} as const; +" +`; diff --git a/test/codegen/generators/typescript/channels/openapi-http-server.spec.ts b/test/codegen/generators/typescript/channels/openapi-http-server.spec.ts new file mode 100644 index 00000000..a7ac56f6 --- /dev/null +++ b/test/codegen/generators/typescript/channels/openapi-http-server.spec.ts @@ -0,0 +1,313 @@ +/** + * End-to-end channels generation for the `http_server` protocol: protocol + * dispatch, coexistence with `http_client`, the `organization` groupings and + * import extensions. + */ +import path from 'node:path'; +import { + defaultTypeScriptChannelsGenerator, + generateTypeScriptChannels +} from '../../../../../src/codegen/generators'; +import {loadOpenapiFromMemory} from '../../../../../src/codegen/inputs/openapi'; +import {loadAsyncapiDocument} from '../../../../../src/codegen/inputs/asyncapi'; +import {generateTypescriptPayload} from '../../../../../src/codegen/generators/typescript/payloads'; +import {generateTypescriptParameters} from '../../../../../src/codegen/generators/typescript/parameters'; +import {generateTypescriptHeaders} from '../../../../../src/codegen/generators/typescript/headers'; +import {TypeScriptChannelRenderType} from '../../../../../src/codegen/generators/typescript/channels/types'; + +const outputPath = path.resolve(__dirname, './output-http-server'); + +const petSchema = { + type: 'object', + required: ['name'], + properties: {id: {type: 'integer'}, name: {type: 'string'}} +}; + +const spec = JSON.stringify({ + openapi: '3.0.3', + info: {title: 'Petstore', version: '1.0.0'}, + servers: [{url: 'https://api.petstore.example/v1'}], + paths: { + '/pet': { + post: { + operationId: 'addPet', + tags: ['pet'], + description: 'Add a new pet to the store', + requestBody: { + content: {'application/json': {schema: petSchema}} + }, + responses: { + 200: { + description: 'OK', + content: {'application/json': {schema: petSchema}} + }, + 405: {description: 'Invalid input'} + } + } + }, + '/pet/{petId}': { + get: { + operationId: 'getPetById', + tags: ['pet'], + parameters: [ + { + name: 'petId', + in: 'path', + required: true, + schema: {type: 'integer'} + }, + {name: 'X-Request-ID', in: 'header', schema: {type: 'string'}} + ], + responses: { + 200: { + description: 'OK', + content: {'application/json': {schema: petSchema}} + }, + 404: {description: 'Not found'} + } + } + } + } +}); + +async function generateChannels({ + protocols, + organization = 'flat', + importExtension +}: { + protocols: string[]; + organization?: 'flat' | 'tag' | 'path'; + importExtension?: '.js' | '.ts' | 'none'; +}): Promise { + const openapiDocument = await loadOpenapiFromMemory({specString: spec}); + const context = { + inputType: 'openapi' as const, + openapiDocument, + dependencyOutputs: {} + }; + const payloads = await generateTypescriptPayload({ + ...context, + generator: { + id: 'payloads-typescript', + preset: 'payloads', + outputPath: `${outputPath}/payloads`, + language: 'typescript', + dependencies: [], + serializationType: 'json', + includeValidation: true, + useForJavaScript: true, + rawPropertyNames: false, + map: 'map', + enum: 'union' + } + } as any); + const parameters = await generateTypescriptParameters({ + ...context, + generator: { + id: 'parameters-typescript', + preset: 'parameters', + outputPath: `${outputPath}/parameters`, + language: 'typescript', + dependencies: [] + } + } as any); + const headers = await generateTypescriptHeaders({ + ...context, + generator: { + id: 'headers-typescript', + preset: 'headers', + outputPath: `${outputPath}/headers`, + language: 'typescript', + dependencies: [], + serializationType: 'json', + includeValidation: true + } + } as any); + + return generateTypeScriptChannels({ + generator: { + ...defaultTypeScriptChannelsGenerator, + outputPath: `${outputPath}/channels`, + id: 'test', + organization, + ...(importExtension ? {importExtension} : {}), + protocols + }, + inputType: 'openapi', + openapiDocument, + dependencyOutputs: { + 'parameters-typescript': parameters, + 'payloads-typescript': payloads, + 'headers-typescript': headers + } + } as any); +} + +describe('OpenAPI http_server channels', () => { + it('generates one register function per operation with the common types once', async () => { + const generated = await generateChannels({protocols: ['http_server']}); + + const file = generated.protocolFiles['http_server']; + expect(file).toBeDefined(); + expect(generated.protocolFiles['http_client']).toBeUndefined(); + + // The shared block is emitted exactly once. + expect( + file.split('// Common Types - Shared across all HTTP server functions') + .length - 1 + ).toEqual(1); + expect(file).toContain('function registerAddPet('); + expect(file).toContain('function registerGetPetById('); + expect(file).toContain('export { registerAddPet, registerGetPetById };'); + expect(file).toMatchSnapshot('http_server-protocol-code'); + }); + + it('lets the client and server protocols coexist in one generation', async () => { + const generated = await generateChannels({ + protocols: ['http_client', 'http_server'] + }); + + expect(generated.protocolFiles['http_client']).toBeDefined(); + expect(generated.protocolFiles['http_server']).toBeDefined(); + // Each file carries its own copy of the shared block, including HttpError. + expect(generated.protocolFiles['http_client']).toContain( + 'export class HttpError extends HttpGlobalError' + ); + expect(generated.protocolFiles['http_server']).toContain( + 'export class HttpError extends HttpGlobalError' + ); + expect(generated.renderedFunctions['http_client']).toBeDefined(); + expect(generated.renderedFunctions['http_server']).toBeDefined(); + }); + + it('registers an operation whose responses are all bodyless', async () => { + const bodylessSpec = JSON.stringify({ + openapi: '3.0.3', + info: {title: 'Bodyless', version: '1.0.0'}, + paths: { + '/thing': { + delete: { + operationId: 'deleteThing', + responses: {204: {description: 'No content'}} + } + } + } + }); + const openapiDocument = await loadOpenapiFromMemory({ + specString: bodylessSpec + }); + const generated = await generateTypeScriptChannels({ + generator: { + ...defaultTypeScriptChannelsGenerator, + outputPath: `${outputPath}/channels`, + id: 'test', + protocols: ['http_server'] + }, + inputType: 'openapi', + openapiDocument, + dependencyOutputs: { + 'parameters-typescript': { + channelModels: {}, + generator: {outputPath: './test'} as any, + files: [] + }, + 'payloads-typescript': { + channelModels: {}, + operationModels: {}, + otherModels: [], + generator: {outputPath: './test'} as any, + files: [] + }, + 'headers-typescript': { + channelModels: {}, + generator: {outputPath: './test'} as any, + files: [] + } + } + } as any); + + // Unlike the client, the server must never skip an operation that exists. + expect(generated.protocolFiles['http_server']).toContain( + 'function registerDeleteThing(' + ); + }); + + it('generates nothing for AsyncAPI input', async () => { + const asyncapiDocument = await loadAsyncapiDocument({ + documentPath: path.resolve(__dirname, '../../../../configs/asyncapi.yaml') + }); + const generated = await generateTypeScriptChannels({ + generator: { + ...defaultTypeScriptChannelsGenerator, + outputPath: `${outputPath}/channels-asyncapi`, + id: 'test', + protocols: ['http_server'] + }, + inputType: 'asyncapi', + asyncapiDocument, + dependencyOutputs: { + 'parameters-typescript': { + channelModels: {}, + generator: {outputPath: './test'} as any, + files: [] + }, + 'payloads-typescript': { + channelModels: {}, + operationModels: {}, + otherModels: [], + generator: {outputPath: './test'} as any, + files: [] + }, + 'headers-typescript': { + channelModels: {}, + generator: {outputPath: './test'} as any, + files: [] + } + } + } as any); + + expect(generated.protocolFiles['http_server']).toBeUndefined(); + }); + + it('generates nothing for a protocol OpenAPI does not support', async () => { + const generated = await generateChannels({protocols: ['nats']}); + + expect(generated.protocolFiles['nats']).toBeUndefined(); + expect(generated.protocolFiles['http_server']).toBeUndefined(); + }); + + it('groups the register functions by tag', async () => { + const generated = await generateChannels({ + protocols: ['http_server'], + organization: 'tag' + }); + + expect(generated.result).toContain('pet'); + expect(generated.result).toMatchSnapshot('http_server-tag-barrel'); + }); + + it('nests the register functions by path with the method as the leaf', async () => { + const generated = await generateChannels({ + protocols: ['http_server'], + organization: 'path' + }); + + expect(generated.result).toMatchSnapshot('http_server-path-barrel'); + }); + + it('appends the import extension to relative imports', async () => { + const generated = await generateChannels({ + protocols: ['http_server'], + importExtension: '.js' + }); + + const file = generated.protocolFiles['http_server']; + const relativeImports = file + .split('\n') + .filter((line) => line.includes("from './")); + expect(relativeImports.length).toBeGreaterThan(0); + for (const line of relativeImports) { + expect(line).toMatch(/\.js';$/); + } + }); +}); From 438e200ca42c0c417a839b3bed78fbed553b7bb2 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Sat, 1 Aug 2026 20:01:40 +0200 Subject: [PATCH 07/16] test: add blackbox coverage for the http_server protocol Co-Authored-By: Claude Opus 5 (1M context) --- .../typescript/openapi-http-server.config.js | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 test/blackbox/configs/typescript/openapi-http-server.config.js diff --git a/test/blackbox/configs/typescript/openapi-http-server.config.js b/test/blackbox/configs/typescript/openapi-http-server.config.js new file mode 100644 index 00000000..460a41a0 --- /dev/null +++ b/test/blackbox/configs/typescript/openapi-http-server.config.js @@ -0,0 +1,32 @@ +// Exercises the OpenAPI -> HTTP server path alongside the client, in one +// generation. Generating both protocols together is intentional: it type-checks +// the coexistence of two files that each declare their own `HttpError`, and +// proves the `deserialize*Headers` import added to `http_client.ts` still +// compiles. +/** @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', 'http_server'] + } + ] +}; From 85e39c9d3178eaf7b057bbbba8b6e4ae8a57e348 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Sat, 1 Aug 2026 20:03:54 +0200 Subject: [PATCH 08/16] test: add runtime verification for generated HTTP server stubs Co-Authored-By: Claude Opus 5 (1M context) --- .../FindPetsByStatusAndCategoryHeaders.ts | 22 ++ .../channels/http_client.ts | 2 +- .../openapi-server/channels/http_server.ts | 353 ++++++++++++++++++ .../src/openapi-server/channels/index.ts | 3 +- .../FindPetsByStatusAndCategoryHeaders.ts | 22 ++ .../channels/http_client.ts | 2 +- .../src/openapi/channels/http_client.ts | 2 +- .../FindPetsByStatusAndCategoryHeaders.ts | 22 ++ 8 files changed, 424 insertions(+), 4 deletions(-) create mode 100644 test/runtime/typescript/src/openapi-server/channels/http_server.ts diff --git a/test/runtime/typescript/src/openapi-path-organization/channels/headers/FindPetsByStatusAndCategoryHeaders.ts b/test/runtime/typescript/src/openapi-path-organization/channels/headers/FindPetsByStatusAndCategoryHeaders.ts index 1c3d9915..85cd984e 100644 --- a/test/runtime/typescript/src/openapi-path-organization/channels/headers/FindPetsByStatusAndCategoryHeaders.ts +++ b/test/runtime/typescript/src/openapi-path-organization/channels/headers/FindPetsByStatusAndCategoryHeaders.ts @@ -16,4 +16,26 @@ export function serializeFindPetsByStatusAndCategoryHeadersHeaders(headers: Find if (headers.xMinusRequestMinusId !== undefined) { result['X-Request-ID'] = String(headers.xMinusRequestMinusId); } if (headers.acceptMinusLanguage !== undefined) { result['Accept-Language'] = String(headers.acceptMinusLanguage); } return result; +} + +export function deserializeFindPetsByStatusAndCategoryHeadersHeaders(headers: Record): FindPetsByStatusAndCategoryHeaders { + // Header names are case-insensitive on the wire, so match on a lower-cased + // view of whatever arrived (Express hands over lower-cased names already). + const normalized: Record = {}; + for (const [name, value] of Object.entries(headers)) { + normalized[name.toLowerCase()] = value; + } + const readHeader = (name: string): string | undefined => { + const value = normalized[name]; + if (value === undefined) { return undefined; } + return Array.isArray(value) ? value[0] : value; + }; + // Built up property by property; an absent header leaves its property absent + // rather than assigning undefined, so required properties stay required. + const result = {} as FindPetsByStatusAndCategoryHeaders; + const xMinusRequestMinusIdValue = readHeader('x-request-id'); + if (xMinusRequestMinusIdValue !== undefined) { result.xMinusRequestMinusId = xMinusRequestMinusIdValue as FindPetsByStatusAndCategoryHeaders['xMinusRequestMinusId']; } + const acceptMinusLanguageValue = readHeader('accept-language'); + if (acceptMinusLanguageValue !== undefined) { result.acceptMinusLanguage = acceptMinusLanguageValue as FindPetsByStatusAndCategoryHeaders['acceptMinusLanguage']; } + return result; } \ No newline at end of file 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 a74e7587..38aabc50 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 @@ -8,7 +8,7 @@ import {PetOrder, PetOrderInterface} from './payload/PetOrder'; import {AUser, AUserInterface} from './payload/AUser'; import {AnUploadedResponse, AnUploadedResponseInterface} from './payload/AnUploadedResponse'; import {FindPetsByStatusAndCategoryParameters, FindPetsByStatusAndCategoryParametersInterface} from './parameter/FindPetsByStatusAndCategoryParameters'; -import {FindPetsByStatusAndCategoryHeaders, serializeFindPetsByStatusAndCategoryHeadersHeaders} from './headers/FindPetsByStatusAndCategoryHeaders'; +import {FindPetsByStatusAndCategoryHeaders, serializeFindPetsByStatusAndCategoryHeadersHeaders, deserializeFindPetsByStatusAndCategoryHeadersHeaders} from './headers/FindPetsByStatusAndCategoryHeaders'; // ============================================================================ // Common Types - Shared across all HTTP client functions diff --git a/test/runtime/typescript/src/openapi-server/channels/http_server.ts b/test/runtime/typescript/src/openapi-server/channels/http_server.ts new file mode 100644 index 00000000..15f67e37 --- /dev/null +++ b/test/runtime/typescript/src/openapi-server/channels/http_server.ts @@ -0,0 +1,353 @@ +import {APet, APetInterface} from './../payloads/APet'; +import * as FindPetsByStatusAndCategoryResponse_200Module from './../payloads/FindPetsByStatusAndCategoryResponse_200'; +import {PetCategory, PetCategoryInterface} from './../payloads/PetCategory'; +import {PetTag, PetTagInterface} from './../payloads/PetTag'; +import {Status} from './../payloads/Status'; +import {ItemStatus} from './../payloads/ItemStatus'; +import {PetOrder, PetOrderInterface} from './../payloads/PetOrder'; +import {AUser, AUserInterface} from './../payloads/AUser'; +import {AnUploadedResponse, AnUploadedResponseInterface} from './../payloads/AnUploadedResponse'; +import {FindPetsByStatusAndCategoryParameters, FindPetsByStatusAndCategoryParametersInterface} from './../parameters/FindPetsByStatusAndCategoryParameters'; +import {FindPetsByStatusAndCategoryHeaders, serializeFindPetsByStatusAndCategoryHeadersHeaders, deserializeFindPetsByStatusAndCategoryHeadersHeaders} from './../headers/FindPetsByStatusAndCategoryHeaders'; +import { NextFunction, Request, Response, Router } from 'express'; + +// ============================================================================ +// Common Types - Shared across all HTTP server 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; + +/** + * Error a handler throws to answer with a specific HTTP status. + * + * Shape-compatible with the `HttpError` the generated HTTP client throws, so + * the same class reads the same on both sides of the wire. When `body` is set + * it is sent as-is; otherwise a `{message, status, statusText}` object is sent. + */ +export class HttpError extends HttpGlobalError { + status: number; + statusText: string; + body?: unknown; + + constructor(message: string, status: number, statusText: string, body?: unknown) { + super(message); + this.name = 'HttpError'; + this.status = status; + this.statusText = statusText; + this.body = body; + } +} + +/** + * Hooks for observing and customizing the generated request handlers. + */ +export interface HttpServerHooks { + /** + * Called before the request is read, for logging, metrics or access control. + * Throw an `HttpError` here to reject the request before it reaches the handler. + */ + beforeHandler?: (params: {request: Request}) => void | Promise; + + /** + * Called after a response has been sent. `body` is the JSON text that went + * out, or undefined when the response had no body. + */ + afterHandler?: (params: {request: Request; status: number; body?: string}) => void | Promise; + + /** + * Called when a handler throws. Return a replacement response to override the + * mapped one, or undefined to keep it. + */ + onError?: (params: {error: HttpGlobalError; request: Request}) => {status: number; body?: unknown} | undefined | Promise<{status: number; body?: unknown} | undefined>; +} + +/** + * Base context shared by every generated register function. + */ +export interface HttpServerContext { + /** Headers added to every response the registered route sends. */ + additionalHeaders?: Record; + + /** Hooks for extensibility */ + hooks?: HttpServerHooks; + + /** Skip validating the incoming request payload against its JSON Schema. */ + skipRequestValidation?: boolean; +} + +// ============================================================================ +// Helper Functions - Shared logic extracted for reuse +// ============================================================================ + +/** + * Read the request body as JSON. + * + * Works whether or not a body parser such as `express.json()` is mounted: an + * already-parsed body is handed back untouched, otherwise the raw stream is + * read. An empty body yields `undefined` instead of throwing, mirroring the + * client's defensive `readOptionalJsonBody`. + */ +function readJsonBody(request: Request): Promise { + const parsed = (request as {body?: unknown}).body; + if (typeof parsed === 'string') { + return Promise.resolve(parsed.length === 0 ? undefined : JSON.parse(parsed)); + } + // A body parser that saw no body leaves an empty object behind, so only a + // non-empty parsed body short-circuits the raw read. + if (parsed !== undefined && parsed !== null && (typeof parsed !== 'object' || Object.keys(parsed).length > 0)) { + return Promise.resolve(parsed); + } + return new Promise((resolve, reject) => { + // A parser upstream may already have drained the stream; waiting for an + // 'end' that will never fire again would hang the request forever. + if (request.readableEnded || request.complete) { + resolve(undefined); + return; + } + let raw = ''; + request.setEncoding('utf8'); + request.on('data', (chunk: string) => { raw += chunk; }); + request.on('end', () => { + if (raw.length === 0) { + resolve(undefined); + return; + } + try { + resolve(JSON.parse(raw)); + } catch (parseError) { + reject(parseError); + } + }); + request.on('error', reject); + }); +} + +/** + * Map a thrown error onto a response. + * + * An `HttpError` maps to its own status and body. Anything else is an internal + * failure and maps to a generic 500 — an internal error message must never + * become client-visible. + */ +function resolveErrorResponse(error: unknown): {status: number; body?: unknown} { + if (error instanceof HttpError) { + return { + status: error.status, + body: error.body ?? {message: error.message, status: error.status, statusText: error.statusText} + }; + } + return {status: 500, body: {message: 'Internal Server Error', status: 500, statusText: 'Internal Server Error'}}; +} + +/** + * Send a response. + * + * `body` is already-marshalled JSON text — a model's `marshal()` returns a + * string and applies the wire-name mapping, so `send` is correct here and + * `json` would double-encode it. `undefined` sends no body at all. + */ +function sendResponse({response, status, body, headers, additionalHeaders}: { + response: Response; + status: number; + body?: string; + headers?: Record; + additionalHeaders?: Record; +}): void { + // Per-response headers win over the context-wide ones. + for (const [name, value] of Object.entries({...additionalHeaders, ...headers})) { + response.setHeader(name, value); + } + response.status(status); + if (body === undefined) { + response.end(); + return; + } + response.setHeader('Content-Type', 'application/json'); + response.send(body); +} + +/** + * Turn an error thrown by a handler into a response. + * + * A half-written response cannot be recovered, so once headers are on the wire + * the error goes to Express' error middleware instead. + */ +async function handleHandlerError({error, request, response, next, hooks, additionalHeaders}: { + error: unknown; + request: Request; + response: Response; + next: NextFunction; + hooks?: HttpServerHooks; + additionalHeaders?: Record; +}): Promise { + if (response.headersSent) { + next(error); + return; + } + let resolved = resolveErrorResponse(error); + if (hooks?.onError && error instanceof HttpGlobalError) { + const override = await hooks.onError({error, request}); + if (override !== undefined) { + resolved = override; + } + } + sendResponse({ + response, + status: resolved.status, + body: resolved.body === undefined ? undefined : JSON.stringify(resolved.body), + additionalHeaders + }); +} + +// ============================================================================ +// Generated HTTP Server Functions +// ============================================================================ + +export type AddPetServerResponse = + | {status: 200; body: APetInterface | APet; headers?: Record} + | {status: 405; headers?: Record}; + +export interface RegisterAddPetContext extends HttpServerContext { + router: Router; + callback: (params: { + body: APet; + request: Request; + }) => AddPetServerResponse | Promise; +} + +/** + * Registers an HTTP POST handler for /pet + */ +function registerAddPet(context: RegisterAddPetContext): void { + const validator = APet.createValidator(); + context.router.post('/pet', async (request: Request, response: Response, next: NextFunction) => { + try { + await context.hooks?.beforeHandler?.({request}); + const receivedData = await readJsonBody(request); + if(!context.skipRequestValidation) { + const {valid, errors} = APet.validate({data: receivedData, ajvValidatorFunction: validator}); + if(!valid) { + throw new HttpError(`Invalid request payload received; ${JSON.stringify({cause: errors})}`, 400, 'Bad Request'); + } + } + const body = APet.unmarshal(JSON.stringify(receivedData)); + const result = await context.callback({body, request}); + let responseBody: string | undefined = undefined; + switch (result.status) { + case 200: { + const responsePayload = result.body; + responseBody = (responsePayload instanceof APet ? responsePayload : new APet(responsePayload)).marshal(); + break; + } + default: + break; + } + sendResponse({response, status: result.status, body: responseBody, headers: result.headers, additionalHeaders: context.additionalHeaders}); + await context.hooks?.afterHandler?.({request, status: result.status, body: responseBody}); + } catch (error) { + await handleHandlerError({error, request, response, next, hooks: context.hooks, additionalHeaders: context.additionalHeaders}); + } + }); +} + +export type UpdatePetServerResponse = + | {status: 200; body: APetInterface | APet; headers?: Record} + | {status: 400; headers?: Record} + | {status: 404; headers?: Record} + | {status: 405; headers?: Record}; + +export interface RegisterUpdatePetContext extends HttpServerContext { + router: Router; + callback: (params: { + body: APet; + request: Request; + }) => UpdatePetServerResponse | Promise; +} + +/** + * Registers an HTTP PUT handler for /pet + */ +function registerUpdatePet(context: RegisterUpdatePetContext): void { + const validator = APet.createValidator(); + context.router.put('/pet', async (request: Request, response: Response, next: NextFunction) => { + try { + await context.hooks?.beforeHandler?.({request}); + const receivedData = await readJsonBody(request); + if(!context.skipRequestValidation) { + const {valid, errors} = APet.validate({data: receivedData, ajvValidatorFunction: validator}); + if(!valid) { + throw new HttpError(`Invalid request payload received; ${JSON.stringify({cause: errors})}`, 400, 'Bad Request'); + } + } + const body = APet.unmarshal(JSON.stringify(receivedData)); + const result = await context.callback({body, request}); + let responseBody: string | undefined = undefined; + switch (result.status) { + case 200: { + const responsePayload = result.body; + responseBody = (responsePayload instanceof APet ? responsePayload : new APet(responsePayload)).marshal(); + break; + } + default: + break; + } + sendResponse({response, status: result.status, body: responseBody, headers: result.headers, additionalHeaders: context.additionalHeaders}); + await context.hooks?.afterHandler?.({request, status: result.status, body: responseBody}); + } catch (error) { + await handleHandlerError({error, request, response, next, hooks: context.hooks, additionalHeaders: context.additionalHeaders}); + } + }); +} + +export type FindPetsByStatusAndCategoryServerResponse = + | {status: 200; body: FindPetsByStatusAndCategoryResponse_200Module.FindPetsByStatusAndCategoryResponse_200; headers?: Record} + | {status: 400; headers?: Record} + | {status: 404; headers?: Record}; + +export interface RegisterFindPetsByStatusAndCategoryContext extends HttpServerContext { + router: Router; + callback: (params: { + parameters: FindPetsByStatusAndCategoryParameters; + requestHeaders: FindPetsByStatusAndCategoryHeaders; + request: Request; + }) => FindPetsByStatusAndCategoryServerResponse | Promise; +} + +/** + * Find pets by status and category with additional filtering options + */ +function registerFindPetsByStatusAndCategory(context: RegisterFindPetsByStatusAndCategoryContext): void { + context.router.get('/pet/findByStatus/:status/:categoryId', async (request: Request, response: Response, next: NextFunction) => { + try { + await context.hooks?.beforeHandler?.({request}); + const parameters = FindPetsByStatusAndCategoryParameters.fromUrl(request.url, '/pet/findByStatus/{status}/{categoryId}'); + const requestHeaders = deserializeFindPetsByStatusAndCategoryHeadersHeaders(request.headers as Record); + const result = await context.callback({parameters, requestHeaders, request}); + let responseBody: string | undefined = undefined; + switch (result.status) { + case 200: { + const responsePayload = result.body; + responseBody = FindPetsByStatusAndCategoryResponse_200Module.marshal(responsePayload); + break; + } + default: + break; + } + sendResponse({response, status: result.status, body: responseBody, headers: result.headers, additionalHeaders: context.additionalHeaders}); + await context.hooks?.afterHandler?.({request, status: result.status, body: responseBody}); + } catch (error) { + await handleHandlerError({error, request, response, next, hooks: context.hooks, additionalHeaders: context.additionalHeaders}); + } + }); +} + +export { registerAddPet, registerUpdatePet, registerFindPetsByStatusAndCategory }; diff --git a/test/runtime/typescript/src/openapi-server/channels/index.ts b/test/runtime/typescript/src/openapi-server/channels/index.ts index 5727531d..7a6d2642 100644 --- a/test/runtime/typescript/src/openapi-server/channels/index.ts +++ b/test/runtime/typescript/src/openapi-server/channels/index.ts @@ -1,3 +1,4 @@ import * as http_client from './http_client'; +import * as http_server from './http_server'; -export {http_client}; +export {http_client, http_server}; diff --git a/test/runtime/typescript/src/openapi-tag-organization/channels/headers/FindPetsByStatusAndCategoryHeaders.ts b/test/runtime/typescript/src/openapi-tag-organization/channels/headers/FindPetsByStatusAndCategoryHeaders.ts index 1c3d9915..85cd984e 100644 --- a/test/runtime/typescript/src/openapi-tag-organization/channels/headers/FindPetsByStatusAndCategoryHeaders.ts +++ b/test/runtime/typescript/src/openapi-tag-organization/channels/headers/FindPetsByStatusAndCategoryHeaders.ts @@ -16,4 +16,26 @@ export function serializeFindPetsByStatusAndCategoryHeadersHeaders(headers: Find if (headers.xMinusRequestMinusId !== undefined) { result['X-Request-ID'] = String(headers.xMinusRequestMinusId); } if (headers.acceptMinusLanguage !== undefined) { result['Accept-Language'] = String(headers.acceptMinusLanguage); } return result; +} + +export function deserializeFindPetsByStatusAndCategoryHeadersHeaders(headers: Record): FindPetsByStatusAndCategoryHeaders { + // Header names are case-insensitive on the wire, so match on a lower-cased + // view of whatever arrived (Express hands over lower-cased names already). + const normalized: Record = {}; + for (const [name, value] of Object.entries(headers)) { + normalized[name.toLowerCase()] = value; + } + const readHeader = (name: string): string | undefined => { + const value = normalized[name]; + if (value === undefined) { return undefined; } + return Array.isArray(value) ? value[0] : value; + }; + // Built up property by property; an absent header leaves its property absent + // rather than assigning undefined, so required properties stay required. + const result = {} as FindPetsByStatusAndCategoryHeaders; + const xMinusRequestMinusIdValue = readHeader('x-request-id'); + if (xMinusRequestMinusIdValue !== undefined) { result.xMinusRequestMinusId = xMinusRequestMinusIdValue as FindPetsByStatusAndCategoryHeaders['xMinusRequestMinusId']; } + const acceptMinusLanguageValue = readHeader('accept-language'); + if (acceptMinusLanguageValue !== undefined) { result.acceptMinusLanguage = acceptMinusLanguageValue as FindPetsByStatusAndCategoryHeaders['acceptMinusLanguage']; } + return result; } \ No newline at end of file diff --git a/test/runtime/typescript/src/openapi-tag-organization/channels/http_client.ts b/test/runtime/typescript/src/openapi-tag-organization/channels/http_client.ts index a74e7587..38aabc50 100644 --- a/test/runtime/typescript/src/openapi-tag-organization/channels/http_client.ts +++ b/test/runtime/typescript/src/openapi-tag-organization/channels/http_client.ts @@ -8,7 +8,7 @@ import {PetOrder, PetOrderInterface} from './payload/PetOrder'; import {AUser, AUserInterface} from './payload/AUser'; import {AnUploadedResponse, AnUploadedResponseInterface} from './payload/AnUploadedResponse'; import {FindPetsByStatusAndCategoryParameters, FindPetsByStatusAndCategoryParametersInterface} from './parameter/FindPetsByStatusAndCategoryParameters'; -import {FindPetsByStatusAndCategoryHeaders, serializeFindPetsByStatusAndCategoryHeadersHeaders} from './headers/FindPetsByStatusAndCategoryHeaders'; +import {FindPetsByStatusAndCategoryHeaders, serializeFindPetsByStatusAndCategoryHeadersHeaders, deserializeFindPetsByStatusAndCategoryHeadersHeaders} from './headers/FindPetsByStatusAndCategoryHeaders'; // ============================================================================ // Common Types - Shared across all HTTP client functions diff --git a/test/runtime/typescript/src/openapi/channels/http_client.ts b/test/runtime/typescript/src/openapi/channels/http_client.ts index ac373fb8..269cd211 100644 --- a/test/runtime/typescript/src/openapi/channels/http_client.ts +++ b/test/runtime/typescript/src/openapi/channels/http_client.ts @@ -8,7 +8,7 @@ import {PetOrder, PetOrderInterface} from './../payloads/PetOrder'; import {AUser, AUserInterface} from './../payloads/AUser'; import {AnUploadedResponse, AnUploadedResponseInterface} from './../payloads/AnUploadedResponse'; import {FindPetsByStatusAndCategoryParameters, FindPetsByStatusAndCategoryParametersInterface} from './../parameters/FindPetsByStatusAndCategoryParameters'; -import {FindPetsByStatusAndCategoryHeaders, serializeFindPetsByStatusAndCategoryHeadersHeaders} from './../headers/FindPetsByStatusAndCategoryHeaders'; +import {FindPetsByStatusAndCategoryHeaders, serializeFindPetsByStatusAndCategoryHeadersHeaders, deserializeFindPetsByStatusAndCategoryHeadersHeaders} from './../headers/FindPetsByStatusAndCategoryHeaders'; // ============================================================================ // Common Types - Shared across all HTTP client functions diff --git a/test/runtime/typescript/src/openapi/headers/FindPetsByStatusAndCategoryHeaders.ts b/test/runtime/typescript/src/openapi/headers/FindPetsByStatusAndCategoryHeaders.ts index 1c3d9915..85cd984e 100644 --- a/test/runtime/typescript/src/openapi/headers/FindPetsByStatusAndCategoryHeaders.ts +++ b/test/runtime/typescript/src/openapi/headers/FindPetsByStatusAndCategoryHeaders.ts @@ -16,4 +16,26 @@ export function serializeFindPetsByStatusAndCategoryHeadersHeaders(headers: Find if (headers.xMinusRequestMinusId !== undefined) { result['X-Request-ID'] = String(headers.xMinusRequestMinusId); } if (headers.acceptMinusLanguage !== undefined) { result['Accept-Language'] = String(headers.acceptMinusLanguage); } return result; +} + +export function deserializeFindPetsByStatusAndCategoryHeadersHeaders(headers: Record): FindPetsByStatusAndCategoryHeaders { + // Header names are case-insensitive on the wire, so match on a lower-cased + // view of whatever arrived (Express hands over lower-cased names already). + const normalized: Record = {}; + for (const [name, value] of Object.entries(headers)) { + normalized[name.toLowerCase()] = value; + } + const readHeader = (name: string): string | undefined => { + const value = normalized[name]; + if (value === undefined) { return undefined; } + return Array.isArray(value) ? value[0] : value; + }; + // Built up property by property; an absent header leaves its property absent + // rather than assigning undefined, so required properties stay required. + const result = {} as FindPetsByStatusAndCategoryHeaders; + const xMinusRequestMinusIdValue = readHeader('x-request-id'); + if (xMinusRequestMinusIdValue !== undefined) { result.xMinusRequestMinusId = xMinusRequestMinusIdValue as FindPetsByStatusAndCategoryHeaders['xMinusRequestMinusId']; } + const acceptMinusLanguageValue = readHeader('accept-language'); + if (acceptMinusLanguageValue !== undefined) { result.acceptMinusLanguage = acceptMinusLanguageValue as FindPetsByStatusAndCategoryHeaders['acceptMinusLanguage']; } + return result; } \ No newline at end of file From 0f0e8af3555a9883619cbba36d2fe13f0ebe50b4 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Sat, 1 Aug 2026 20:09:54 +0200 Subject: [PATCH 09/16] docs: add openapi-http-server example Co-Authored-By: Claude Opus 5 (1M context) --- examples/README.md | 3 + .../generated/headers/PostV2ConnectHeaders.ts | 20 + .../src/generated/http_client.ts | 2 +- examples/openapi-http-server/README.md | 93 + .../openapi-http-server/codegen.config.js | 14 + .../openapi-http-server/package-lock.json | 1566 +++++++++++++++++ examples/openapi-http-server/package.json | 35 + .../safepay-nordic-sample.json | 139 ++ examples/openapi-http-server/src/demo.ts | 71 + .../generated/headers/PostV2ConnectHeaders.ts | 34 + .../src/generated/http_client.ts | 1035 +++++++++++ .../src/generated/http_server.ts | 342 ++++ .../src/generated/index.ts | 4 + .../GetV2ConnectReferenceIdParameters.ts | 122 ++ ...sSafepayAccountIdBankAccountsParameters.ts | 122 ++ .../src/generated/payload/BankAccount.ts | 110 ++ .../src/generated/payload/GetConnectModel.ts | 111 ++ .../GetV2ConnectReferenceIdResponse_200.ts | 111 ++ ...afepayAccountIdBankAccountsResponse_200.ts | 93 + .../src/generated/payload/InitializeModel.ts | 98 ++ .../generated/payload/InitializeRequest.ts | 98 ++ .../generated/payload/PostV2ConnectRequest.ts | 98 ++ .../payload/PostV2ConnectResponse_200.ts | 98 ++ .../src/generated/payload/Status.ts | 7 + examples/openapi-http-server/src/server.ts | 114 ++ 25 files changed, 4539 insertions(+), 1 deletion(-) create mode 100644 examples/openapi-http-server/README.md create mode 100644 examples/openapi-http-server/codegen.config.js create mode 100644 examples/openapi-http-server/package-lock.json create mode 100644 examples/openapi-http-server/package.json create mode 100644 examples/openapi-http-server/safepay-nordic-sample.json create mode 100644 examples/openapi-http-server/src/demo.ts create mode 100644 examples/openapi-http-server/src/generated/headers/PostV2ConnectHeaders.ts create mode 100644 examples/openapi-http-server/src/generated/http_client.ts create mode 100644 examples/openapi-http-server/src/generated/http_server.ts create mode 100644 examples/openapi-http-server/src/generated/index.ts create mode 100644 examples/openapi-http-server/src/generated/parameter/GetV2ConnectReferenceIdParameters.ts create mode 100644 examples/openapi-http-server/src/generated/parameter/GetV2UsersSafepayAccountIdBankAccountsParameters.ts create mode 100644 examples/openapi-http-server/src/generated/payload/BankAccount.ts create mode 100644 examples/openapi-http-server/src/generated/payload/GetConnectModel.ts create mode 100644 examples/openapi-http-server/src/generated/payload/GetV2ConnectReferenceIdResponse_200.ts create mode 100644 examples/openapi-http-server/src/generated/payload/GetV2UsersSafepayAccountIdBankAccountsResponse_200.ts create mode 100644 examples/openapi-http-server/src/generated/payload/InitializeModel.ts create mode 100644 examples/openapi-http-server/src/generated/payload/InitializeRequest.ts create mode 100644 examples/openapi-http-server/src/generated/payload/PostV2ConnectRequest.ts create mode 100644 examples/openapi-http-server/src/generated/payload/PostV2ConnectResponse_200.ts create mode 100644 examples/openapi-http-server/src/generated/payload/Status.ts create mode 100644 examples/openapi-http-server/src/server.ts diff --git a/examples/README.md b/examples/README.md index 69e1a275..8913c573 100644 --- a/examples/README.md +++ b/examples/README.md @@ -31,6 +31,9 @@ A comprehensive example showing how to generate TypeScript types from AsyncAPI s ### [OpenAPI HTTP Client](./openapi-http-client/) A minimal, self-contained example of generating a type-safe HTTP client from an OpenAPI document — and how to consume it: building request bodies, supplying path parameters, and reading typed responses. +### [OpenAPI HTTP Server](./openapi-http-server/) +The other side of the same document: generating typed Express handler stubs with the `http_server` protocol, and driving them with the `http_client` generated from that same document. + ### [OpenAPI Filtering](./openapi-filtering/) A minimal example of the root-config `filter` option: generate code for only a subset of an OpenAPI document's paths/operations, with orphaned component schemas pruned automatically. diff --git a/examples/openapi-http-client/src/generated/headers/PostV2ConnectHeaders.ts b/examples/openapi-http-client/src/generated/headers/PostV2ConnectHeaders.ts index 6b5a2ddd..43567ed3 100644 --- a/examples/openapi-http-client/src/generated/headers/PostV2ConnectHeaders.ts +++ b/examples/openapi-http-client/src/generated/headers/PostV2ConnectHeaders.ts @@ -11,4 +11,24 @@ export function serializePostV2ConnectHeadersHeaders(headers: PostV2ConnectHeade const result: Record = {}; if (headers.xMinusCorrelationMinusId !== undefined) { result['X-Correlation-Id'] = String(headers.xMinusCorrelationMinusId); } return result; +} + +export function deserializePostV2ConnectHeadersHeaders(headers: Record): PostV2ConnectHeaders { + // Header names are case-insensitive on the wire, so match on a lower-cased + // view of whatever arrived (Express hands over lower-cased names already). + const normalized: Record = {}; + for (const [name, value] of Object.entries(headers)) { + normalized[name.toLowerCase()] = value; + } + const readHeader = (name: string): string | undefined => { + const value = normalized[name]; + if (value === undefined) { return undefined; } + return Array.isArray(value) ? value[0] : value; + }; + // Built up property by property; an absent header leaves its property absent + // rather than assigning undefined, so required properties stay required. + const result = {} as PostV2ConnectHeaders; + const xMinusCorrelationMinusIdValue = readHeader('x-correlation-id'); + if (xMinusCorrelationMinusIdValue !== undefined) { result.xMinusCorrelationMinusId = xMinusCorrelationMinusIdValue as PostV2ConnectHeaders['xMinusCorrelationMinusId']; } + return result; } \ No newline at end of file diff --git a/examples/openapi-http-client/src/generated/http_client.ts b/examples/openapi-http-client/src/generated/http_client.ts index 3181a8f0..6001261b 100644 --- a/examples/openapi-http-client/src/generated/http_client.ts +++ b/examples/openapi-http-client/src/generated/http_client.ts @@ -9,7 +9,7 @@ import {InitializeModel, InitializeModelInterface} from './payload/InitializeMod import {GetConnectModel, GetConnectModelInterface} from './payload/GetConnectModel'; import {GetV2ConnectReferenceIdParameters, GetV2ConnectReferenceIdParametersInterface} from './parameter/GetV2ConnectReferenceIdParameters'; import {GetV2UsersSafepayAccountIdBankAccountsParameters, GetV2UsersSafepayAccountIdBankAccountsParametersInterface} from './parameter/GetV2UsersSafepayAccountIdBankAccountsParameters'; -import {PostV2ConnectHeaders, serializePostV2ConnectHeadersHeaders} from './headers/PostV2ConnectHeaders'; +import {PostV2ConnectHeaders, serializePostV2ConnectHeadersHeaders, deserializePostV2ConnectHeadersHeaders} from './headers/PostV2ConnectHeaders'; // ============================================================================ // Common Types - Shared across all HTTP client functions diff --git a/examples/openapi-http-server/README.md b/examples/openapi-http-server/README.md new file mode 100644 index 00000000..2a82bf2c --- /dev/null +++ b/examples/openapi-http-server/README.md @@ -0,0 +1,93 @@ +# OpenAPI HTTP Server + +A self-contained example of generating **typed Express handler stubs** from an OpenAPI document with the `channels` generator and the `http_server` protocol — and calling them with the `http_client` generated from the *same* document. + +It uses the same `safepay-nordic-sample.json` as the [OpenAPI HTTP Client](../openapi-http-client/) example, so the two examples show the two sides of one API. + +**Files:** +- `safepay-nordic-sample.json` — a trimmed OpenAPI 3.1 document (a payments-style API). None of its operations declare an `operationId`, so function names are synthesized from method + path. +- `codegen.config.js` — configuration selecting the `channels` generator with both the `http_server` and `http_client` protocols. +- `src/server.ts` — where you write business logic: one `callback` per operation. +- `src/demo.ts` — a runnable demo booting the server and driving it with the generated client. +- `src/generated/` — the generated output (committed so you can browse it without running anything). + +## Usage + +```bash +npm install +npm run generate # regenerate src/generated from the OpenAPI document +npm run demo # boot the server and call it with the generated client +``` + +## Where you add business logic + +For every operation the generator emits three things: + +| Generated | What it is | +|---|---| +| `ServerResponse` | A **status-code-discriminated union** of everything the operation is allowed to answer with. Returning an undeclared status is a compile error. | +| `RegisterContext` | The argument to the register function: the `router` to mount on, your `callback`, and the shared server options (`hooks`, `additionalHeaders`, `skipRequestValidation`). | +| `register(context)` | Mounts the route. Handles parsing, parameter extraction, header deserialization, request validation, response marshalling and error mapping. | + +Your code is the `callback` body — nothing else: + +```ts +import {Router} from 'express'; +import {registerGetV2ConnectReferenceId} from './generated/http_server'; + +const router = Router(); + +registerGetV2ConnectReferenceId({ + router, + callback: ({parameters}) => { + // `parameters` is a typed, parsed GetV2ConnectReferenceIdParameters. + const session = lookUp(parameters.referenceId); + if (!session) { + return {status: 404}; // declared by the document + } + return {status: 200, body: {referenceId: parameters.referenceId, /* … */}}; + } +}); +``` + +The callback receives a single destructured object carrying `body` (body-carrying methods only), `parameters`, `requestHeaders` and always `request`. `response` and `next` are deliberately *not* handed over — the returned value owns the response. + +## Mounting + +Nothing generated ever calls `app.listen`, constructs a `Router`, or mounts anything — that stays yours: + +```ts +const app = express(); +app.use(express.json()); // optional: the stubs read the raw stream too +app.use('/api', router); // a prefix works — path templates still match +``` + +There is no `basePath` option because none is needed: Express makes `request.url` mount-relative, which is exactly what the generated parameter extraction matches against. + +## Errors + +Throw an `HttpError` (exported from `generated/http_server.ts`) to answer with a specific status: + +```ts +throw new HttpError('returnUrl must be https', 400, 'Bad Request', {field: 'returnUrl'}); +``` + +Anything else you throw becomes a `500` with a generic body — an internal error message is never leaked to the caller. `hooks.onError` can override the mapped response. + +It is the same `HttpError` shape the generated client throws, so the class reads the same on both sides of the wire. + +## Request validation + +Request bodies are validated against their JSON Schema before your callback runs (the `payloads` generator's `includeValidation`, on by default). A failing body is answered with `400` and the validation causes. Set `skipRequestValidation: true` on the context to turn it off for a route. + +## What is deliberately not generated + +- **No authentication/authorization checks.** Compose your own middleware; the document's `security` requirements are not enforced. +- **No frameworks other than Express.** +- **No response header models.** Response headers are a free-form `Record` per response variant. +- **No content-type negotiation.** JSON only, matching the payload models. +- **No server construction.** No `listen`, no `Router`, no mounting. + +## Known limitation + +When an operation declares several body-carrying responses, each becomes a member of a payload *union*. A non-object member (an array or a primitive) has no importable module of its own, so its body is marshalled with `JSON.stringify` rather than the model's `marshal()` — meaning no wire-name mapping is applied for that variant. diff --git a/examples/openapi-http-server/codegen.config.js b/examples/openapi-http-server/codegen.config.js new file mode 100644 index 00000000..5f9a852d --- /dev/null +++ b/examples/openapi-http-server/codegen.config.js @@ -0,0 +1,14 @@ +export default { + inputType: 'openapi', + inputPath: './safepay-nordic-sample.json', + generators: [ + { + preset: 'channels', + outputPath: './src/generated', + language: 'typescript', + // Both sides of the same document: `http_server` mounts typed handlers on + // an Express router, `http_client` calls them. + protocols: ['http_server', 'http_client'] + } + ] +}; diff --git a/examples/openapi-http-server/package-lock.json b/examples/openapi-http-server/package-lock.json new file mode 100644 index 00000000..b19b8dee --- /dev/null +++ b/examples/openapi-http-server/package-lock.json @@ -0,0 +1,1566 @@ +{ + "name": "openapi-http-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "openapi-http-server", + "version": "1.0.0", + "dependencies": { + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "express": "^4.21.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.0.0", + "tsx": "^4.0.0", + "typescript": "^5.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.9", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.9.tgz", + "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.6", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/examples/openapi-http-server/package.json b/examples/openapi-http-server/package.json new file mode 100644 index 00000000..4fd6475a --- /dev/null +++ b/examples/openapi-http-server/package.json @@ -0,0 +1,35 @@ +{ + "name": "openapi-http-server", + "version": "1.0.0", + "description": "Example showing the OpenAPI http_server generator: typed Express handler stubs generated from an OpenAPI document, called by the generated http_client", + "main": "src/server.ts", + "type": "module", + "scripts": { + "generate": "node ../../bin/run.mjs generate codegen.config.js", + "demo": "tsx src/demo.ts" + }, + "keywords": [ + "openapi", + "codegen", + "channels", + "http", + "http-server", + "express", + "rest", + "api-server" + ], + "dependencies": { + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "express": "^4.21.0" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.0.0", + "tsx": "^4.0.0", + "typescript": "^5.0.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/examples/openapi-http-server/safepay-nordic-sample.json b/examples/openapi-http-server/safepay-nordic-sample.json new file mode 100644 index 00000000..24cba34c --- /dev/null +++ b/examples/openapi-http-server/safepay-nordic-sample.json @@ -0,0 +1,139 @@ +{ + "openapi": "3.1.1", + "info": { + "title": "Safepay API V2 (sample)", + "description": "A trimmed, self-contained slice of a real payments API used to demonstrate the OpenAPI http_client generator. None of the operations declare an operationId, so function names are synthesized from the HTTP method and path.", + "version": "v2" + }, + "servers": [{"url": "https://api.example-safepay.com"}], + "paths": { + "/v2/connect": { + "post": { + "tags": ["Connect"], + "summary": "Start a connect flow", + "description": "Generates a ConnectUrl where the user can be validated and connected.", + "parameters": [ + { + "name": "X-Correlation-Id", + "in": "header", + "description": "Correlation ID used for logging.", + "schema": {"type": "string", "minLength": 20, "maxLength": 64} + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/InitializeRequest"} + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/InitializeModel"} + } + } + }, + "400": {"description": "Bad Request"} + } + } + }, + "/v2/connect/{referenceId}": { + "get": { + "tags": ["Connect"], + "summary": "Get connect information", + "description": "Translate a ReferenceId into a SafepayAccountId.", + "parameters": [ + { + "name": "referenceId", + "in": "path", + "required": true, + "schema": {"type": "string"} + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/GetConnectModel"} + } + } + }, + "404": {"description": "Not Found"} + } + } + }, + "/v2/users/{safepayAccountId}/bank-accounts": { + "get": { + "tags": ["Users"], + "summary": "List bank accounts", + "description": "Returns the bank accounts registered for a Safepay account.", + "parameters": [ + { + "name": "safepayAccountId", + "in": "path", + "required": true, + "schema": {"type": "string"} + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "bankAccounts": { + "type": "array", + "items": {"$ref": "#/components/schemas/BankAccount"} + } + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "InitializeRequest": { + "type": "object", + "required": ["returnUrl"], + "properties": { + "returnUrl": {"type": "string", "format": "uri"}, + "skipKyc": {"type": "boolean"} + } + }, + "InitializeModel": { + "type": "object", + "properties": { + "referenceId": {"type": "string"}, + "connectUrl": {"type": "string", "format": "uri"} + } + }, + "GetConnectModel": { + "type": "object", + "properties": { + "referenceId": {"type": "string"}, + "safepayAccountId": {"type": "string"}, + "status": {"type": "string", "enum": ["pending", "connected", "expired"]} + } + }, + "BankAccount": { + "type": "object", + "properties": { + "id": {"type": "string"}, + "iban": {"type": "string"}, + "isDefault": {"type": "boolean"} + } + } + } + } +} diff --git a/examples/openapi-http-server/src/demo.ts b/examples/openapi-http-server/src/demo.ts new file mode 100644 index 00000000..7e3ad9b6 --- /dev/null +++ b/examples/openapi-http-server/src/demo.ts @@ -0,0 +1,71 @@ +/** + * Demo: the generated HTTP client calling the generated HTTP server. + * + * Both sides come out of the same OpenAPI document, so they are exact + * inverses — what `http_server.ts` marshals, `http_client.ts` unmarshals. + * + * Run with `npm run demo`. + */ +import {AddressInfo} from 'node:net'; +import {http_client} from './generated/index'; +import {createSafepayApp} from './server'; + +async function main(): Promise { + const app = createSafepayApp(); + const server = app.listen(0); + await new Promise((resolve) => server.once('listening', resolve)); + const baseUrl = `http://localhost:${(server.address() as AddressInfo).port}`; + console.log(`Server listening on ${baseUrl}\n`); + + try { + // POST /v2/connect — the client marshals the body, the server validates and + // unmarshals it, and the response travels back the same way. + const created = await http_client.postV2Connect({ + baseUrl, + payload: {returnUrl: 'https://my-shop.example/return', skipKyc: false}, + requestHeaders: {xMinusCorrelationMinusId: 'demo-1'} + }); + console.log('POST /v2/connect ->', created.status, created.data.marshal()); + console.log(' X-Powered-By ->', created.headers['x-powered-by']); + console.log(' X-Correlation-Id ->', created.headers['x-correlation-id']); + + const referenceId = created.data.referenceId as string; + + // GET /v2/connect/{referenceId} — path parameters round-trip through the + // generated parameter model on both sides. + const connect = await http_client.getV2ConnectReferenceId({ + baseUrl, + parameters: {referenceId} + }); + console.log( + `\nGET /v2/connect/${referenceId} ->`, + connect.status, + connect.data.marshal() + ); + + const bankAccounts = + await http_client.getV2UsersSafepayAccountIdBankAccounts({ + baseUrl, + parameters: {safepayAccountId: connect.data.safepayAccountId as string} + }); + console.log('\nGET bank accounts ->', bankAccounts.status, bankAccounts.data.marshal()); + + // A status the server declares and returns; the client turns a non-OK + // response into a thrown HttpError. + try { + await http_client.getV2ConnectReferenceId({ + baseUrl, + parameters: {referenceId: 'does-not-exist'} + }); + } catch (error) { + console.log('\nUnknown referenceId ->', (error as {status: number}).status); + } + } finally { + server.close(); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/examples/openapi-http-server/src/generated/headers/PostV2ConnectHeaders.ts b/examples/openapi-http-server/src/generated/headers/PostV2ConnectHeaders.ts new file mode 100644 index 00000000..43567ed3 --- /dev/null +++ b/examples/openapi-http-server/src/generated/headers/PostV2ConnectHeaders.ts @@ -0,0 +1,34 @@ + +interface PostV2ConnectHeaders { + /** + * Correlation ID used for logging. + */ + xMinusCorrelationMinusId?: string; +} +export { PostV2ConnectHeaders }; + +export function serializePostV2ConnectHeadersHeaders(headers: PostV2ConnectHeaders): Record { + const result: Record = {}; + if (headers.xMinusCorrelationMinusId !== undefined) { result['X-Correlation-Id'] = String(headers.xMinusCorrelationMinusId); } + return result; +} + +export function deserializePostV2ConnectHeadersHeaders(headers: Record): PostV2ConnectHeaders { + // Header names are case-insensitive on the wire, so match on a lower-cased + // view of whatever arrived (Express hands over lower-cased names already). + const normalized: Record = {}; + for (const [name, value] of Object.entries(headers)) { + normalized[name.toLowerCase()] = value; + } + const readHeader = (name: string): string | undefined => { + const value = normalized[name]; + if (value === undefined) { return undefined; } + return Array.isArray(value) ? value[0] : value; + }; + // Built up property by property; an absent header leaves its property absent + // rather than assigning undefined, so required properties stay required. + const result = {} as PostV2ConnectHeaders; + const xMinusCorrelationMinusIdValue = readHeader('x-correlation-id'); + if (xMinusCorrelationMinusIdValue !== undefined) { result.xMinusCorrelationMinusId = xMinusCorrelationMinusIdValue as PostV2ConnectHeaders['xMinusCorrelationMinusId']; } + return result; +} \ No newline at end of file diff --git a/examples/openapi-http-server/src/generated/http_client.ts b/examples/openapi-http-server/src/generated/http_client.ts new file mode 100644 index 00000000..6001261b --- /dev/null +++ b/examples/openapi-http-server/src/generated/http_client.ts @@ -0,0 +1,1035 @@ +import {PostV2ConnectRequest, PostV2ConnectRequestInterface} from './payload/PostV2ConnectRequest'; +import {PostV2ConnectResponse_200, PostV2ConnectResponse_200Interface} from './payload/PostV2ConnectResponse_200'; +import {GetV2ConnectReferenceIdResponse_200, GetV2ConnectReferenceIdResponse_200Interface} from './payload/GetV2ConnectReferenceIdResponse_200'; +import {GetV2UsersSafepayAccountIdBankAccountsResponse_200, GetV2UsersSafepayAccountIdBankAccountsResponse_200Interface} from './payload/GetV2UsersSafepayAccountIdBankAccountsResponse_200'; +import {Status} from './payload/Status'; +import {BankAccount, BankAccountInterface} from './payload/BankAccount'; +import {InitializeRequest, InitializeRequestInterface} from './payload/InitializeRequest'; +import {InitializeModel, InitializeModelInterface} from './payload/InitializeModel'; +import {GetConnectModel, GetConnectModelInterface} from './payload/GetConnectModel'; +import {GetV2ConnectReferenceIdParameters, GetV2ConnectReferenceIdParametersInterface} from './parameter/GetV2ConnectReferenceIdParameters'; +import {GetV2UsersSafepayAccountIdBankAccountsParameters, GetV2UsersSafepayAccountIdBankAccountsParametersInterface} from './parameter/GetV2UsersSafepayAccountIdBankAccountsParameters'; +import {PostV2ConnectHeaders, serializePostV2ConnectHeadersHeaders, deserializePostV2ConnectHeadersHeaders} from './headers/PostV2ConnectHeaders'; + +// ============================================================================ +// 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 + */ +export interface HttpResponse { + ok: boolean; + status: number; + statusText: string; + headers?: Headers | Record; + json: () => Record | Promise>; +} + +/** + * Rich response wrapper returned by HTTP client functions + */ +export interface HttpClientResponse { + /** The deserialized response payload */ + data: T; + /** HTTP status code */ + status: number; + /** HTTP status text */ + statusText: string; + /** Response headers */ + headers: Record; + /** Raw JSON response before deserialization */ + rawData: Record; +} + +/** + * Error thrown for non-OK HTTP responses. + * + * Carries the HTTP `status`, `statusText`, and the parsed response `body` + * (when the error response had a JSON body). Thrown by `handleHttpError` and + * routed through the `onError` hook / retry logic unchanged. + */ +export class HttpError extends HttpGlobalError { + status: number; + statusText: string; + body?: unknown; + + constructor(message: string, status: number, statusText: string, body?: unknown) { + super(message); + this.name = 'HttpError'; + this.status = status; + this.statusText = statusText; + this.body = body; + } +} + +/** + * HTTP request parameters passed to the request hook + */ +export interface HttpRequestParams { + url: string; + headers?: Record; + method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD'; + credentials?: 'omit' | 'include' | 'same-origin'; + body?: any; +} + +/** + * Token response structure for OAuth2 flows + */ +export interface TokenResponse { + accessToken: string; + refreshToken?: string; + expiresIn?: number; +} + +// ============================================================================ +// Security Configuration Types - Grouped for better autocomplete +// ============================================================================ + +/** + * Bearer token authentication configuration + */ +export interface BearerAuth { + type: 'bearer'; + token: string; +} + +/** + * Basic authentication configuration (username/password) + */ +export interface BasicAuth { + type: 'basic'; + username: string; + password: string; +} + +/** + * API key authentication configuration + */ +export interface ApiKeyAuth { + type: 'apiKey'; + key: string; + name?: string; // Name of the API key parameter (default: 'X-API-Key') + in?: 'header' | 'query'; // Where to place the API key (default: 'header') +} + +/** + * OAuth2 authentication configuration + * + * Supports server-side flows only: + * - client_credentials: Server-to-server authentication + * - password: Resource owner password credentials (legacy, not recommended) + * - Pre-obtained accessToken: For tokens obtained via browser-based flows + * + * For browser-based flows (implicit, authorization_code), obtain the token + * separately and pass it as accessToken. + */ +export interface OAuth2Auth { + type: 'oauth2'; + /** Pre-obtained access token (required if not using a server-side flow) */ + accessToken?: string; + /** Refresh token for automatic token renewal on 401 */ + refreshToken?: string; + /** Token endpoint URL (required for client_credentials/password flows and token refresh) */ + tokenUrl?: string; + /** Client ID (required for flows and token refresh) */ + clientId?: string; + /** Client secret (optional, depends on OAuth provider) */ + clientSecret?: string; + /** Requested scopes */ + scopes?: string[]; + /** Server-side flow type */ + flow?: 'password' | 'client_credentials'; + /** Username for password flow */ + username?: string; + /** Password for password flow */ + password?: string; + /** Callback when tokens are refreshed (for caching/persistence) */ + onTokenRefresh?: (newTokens: TokenResponse) => void; +} + +/** + * Union type for all authentication methods - provides autocomplete support + */ +export type AuthConfig = BearerAuth | BasicAuth | ApiKeyAuth | OAuth2Auth; + +/** + * Feature flags indicating which auth types are available. + * Used internally to conditionally call auth-specific helpers. + */ +const AUTH_FEATURES = { + oauth2: true +} as const; + +/** + * Default values for API key authentication derived from the spec. + * These match the defaults documented in the ApiKeyAuth interface. + */ +const API_KEY_DEFAULTS = { + name: 'X-API-Key', + in: 'header' as 'header' | 'query' | 'cookie' +} as const; + +// ============================================================================ +// Retry Configuration +// ============================================================================ + +/** + * Retry policy configuration for failed requests + */ +export interface RetryConfig { + maxRetries?: number; // Maximum number of retry attempts (default: 3) + initialDelayMs?: number; // Initial delay before first retry (default: 1000) + maxDelayMs?: number; // Maximum delay between retries (default: 30000) + 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: HttpGlobalError) => void; // Callback on each retry +} + +// ============================================================================ +// Hooks Configuration - Extensible callback system +// ============================================================================ + +/** + * Hooks for customizing HTTP client behavior + */ +export interface HttpHooks { + /** + * Called before each request to transform/modify the request parameters + * Return modified params or undefined to use original + */ + beforeRequest?: (params: HttpRequestParams) => HttpRequestParams | Promise; + + /** + * The actual request implementation - allows swapping fetch for axios, etc. + * Default: uses the global fetch (Node.js 18+) + */ + makeRequest?: (params: HttpRequestParams) => Promise; + + /** + * Called after each response for logging, metrics, etc. + * Can transform the response before it's processed + */ + afterResponse?: (response: HttpResponse, params: HttpRequestParams) => HttpResponse | Promise; + + /** + * Called on request error for logging, error transformation, etc. + */ + onError?: (error: HttpGlobalError, params: HttpRequestParams) => HttpGlobalError | Promise; +} + +// ============================================================================ +// Common Request Context +// ============================================================================ + +/** + * Base context shared by all HTTP client functions + */ +export interface HttpClientContext { + baseUrl?: string; + + // Authentication - grouped for better autocomplete + auth?: AuthConfig; + + // Retry configuration + retry?: RetryConfig; + + // Hooks for extensibility + hooks?: HttpHooks; + + // Additional options + additionalHeaders?: Record; + + // Extra query parameters not covered by the typed parameters interface + additionalQueryParams?: Record; +} + +// ============================================================================ +// Helper Functions - Shared logic extracted for reuse +// ============================================================================ + +/** + * Default retry configuration + */ +const DEFAULT_RETRY_CONFIG: Required = { + maxRetries: 3, + initialDelayMs: 1000, + maxDelayMs: 30000, + backoffMultiplier: 2, + retryableStatusCodes: [408, 429, 500, 502, 503, 504], + retryOnNetworkError: true, + onRetry: () => {}, +}; + +/** + * Default request hook implementation using the global fetch (Node.js 18+) + */ +const defaultMakeRequest = async (params: HttpRequestParams): Promise => { + // Build a Headers object so multi-value headers (string[]) are preserved - + // the global fetch's HeadersInit only accepts string values in a plain object. + const headers = new Headers(); + for (const [name, value] of Object.entries(params.headers ?? {})) { + if (Array.isArray(value)) { + for (const entry of value) { + headers.append(name, entry); + } + } else { + headers.set(name, value); + } + } + return fetch(params.url, { + body: params.body, + method: params.method, + headers + }) as unknown as HttpResponse; +}; + +/** + * Apply authentication to headers and URL based on auth config + */ +function applyAuth( + auth: AuthConfig | undefined, + headers: Record, + url: string +): { headers: Record; url: string } { + if (!auth) return { headers, url }; + + switch (auth.type) { + case 'bearer': + headers['Authorization'] = `Bearer ${auth.token}`; + break; + + case 'basic': { + const credentials = Buffer.from(`${auth.username}:${auth.password}`).toString('base64'); + headers['Authorization'] = `Basic ${credentials}`; + break; + } + + case 'apiKey': { + const keyName = auth.name ?? API_KEY_DEFAULTS.name; + const keyIn = auth.in ?? API_KEY_DEFAULTS.in; + + if (keyIn === 'header') { + headers[keyName] = auth.key; + } else if (keyIn === 'query') { + const separator = url.includes('?') ? '&' : '?'; + url = `${url}${separator}${keyName}=${encodeURIComponent(auth.key)}`; + } else if (keyIn === 'cookie') { + headers['Cookie'] = `${keyName}=${auth.key}`; + } + break; + } + + case 'oauth2': { + // If we have an access token, use it directly + // Token flows (client_credentials, password) are handled separately + if (auth.accessToken) { + headers['Authorization'] = `Bearer ${auth.accessToken}`; + } + break; + } + } + + return { headers, url }; +} + +/** + * Apply query parameters to URL + */ +function applyQueryParams(queryParams: Record | undefined, url: string): string { + if (!queryParams) return url; + + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(queryParams)) { + if (value !== undefined) { + params.append(key, String(value)); + } + } + + const paramString = params.toString(); + if (!paramString) return url; + + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}${paramString}`; +} + +/** + * Sleep for a specified number of milliseconds + */ +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Calculate delay for exponential backoff + */ +function calculateBackoffDelay( + attempt: number, + config: Required +): number { + const delay = config.initialDelayMs * Math.pow(config.backoffMultiplier, attempt - 1); + return Math.min(delay, config.maxDelayMs); +} + +/** + * Determine if a request should be retried based on error/response + */ +function shouldRetry( + error: HttpGlobalError | null, + response: HttpResponse | null, + config: Required, + attempt: number +): boolean { + if (attempt >= config.maxRetries) return false; + + if (error && config.retryOnNetworkError) return true; + + if (response && config.retryableStatusCodes.includes(response.status)) return true; + + return false; +} + +/** + * Execute request with retry logic + */ +async function executeWithRetry( + params: HttpRequestParams, + makeRequest: (params: HttpRequestParams) => Promise, + retryConfig?: RetryConfig +): Promise { + const config = { ...DEFAULT_RETRY_CONFIG, ...retryConfig }; + 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 HttpGlobalError('Retry attempt')); + await sleep(delay); + } + + const response = await makeRequest(params); + + // Check if we should retry this response + if (!shouldRetry(null, response, config, attempt + 1)) { + return response; + } + + lastResponse = response; + lastError = new HttpGlobalError(`HTTP Error: ${response.status} ${response.statusText}`); + } catch (error) { + lastError = error instanceof HttpGlobalError ? error : new HttpGlobalError(String(error)); + + if (!shouldRetry(lastError, null, config, attempt + 1)) { + throw lastError; + } + } + } + + // All retries exhausted + if (lastResponse) { + return lastResponse; + } + throw lastError ?? new HttpGlobalError('Request failed after retries'); +} + +/** + * Handle HTTP error status codes by throwing a typed HttpError. + * Explicit cases are generated from the error status codes declared by the + * input document; undeclared codes fall through to the default handler. + */ +function handleHttpError(status: number, statusText: string, body?: unknown): never { + switch (status) { + case 400: + throw new HttpError("Bad Request", status, statusText, body); + case 404: + throw new HttpError("Not Found", status, statusText, body); + default: + 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 + */ +function extractHeaders(response: HttpResponse): Record { + const headers: Record = {}; + + if (response.headers) { + if (typeof (response.headers as any).forEach === 'function') { + // Headers object (fetch API) + (response.headers as Headers).forEach((value, key) => { + headers[key.toLowerCase()] = value; + }); + } else { + // Plain object + for (const [key, value] of Object.entries(response.headers)) { + headers[key.toLowerCase()] = value; + } + } + } + + return headers; +} + +/** + * Builds a URL with path parameters replaced + * @param server - Base server URL + * @param pathTemplate - Path template with {param} placeholders + * @param parameters - Parameter object with getChannelWithParameters method + */ +function buildUrlWithParameters string }>( + server: string, + pathTemplate: string, + parameters: T +): string { + const path = parameters.getChannelWithParameters(pathTemplate); + return `${server}${path}`; +} + +/** + * Extracts headers from a typed headers object and merges with additional headers + */ +function applyTypedHeaders( + typedHeaders: { marshal: () => string } | undefined, + additionalHeaders: Record | undefined +): Record { + const headers: Record = { + 'Content-Type': 'application/json', + ...additionalHeaders + }; + + if (typedHeaders) { + // Parse the marshalled headers and merge them + const marshalledHeaders = JSON.parse(typedHeaders.marshal()); + for (const [key, value] of Object.entries(marshalledHeaders)) { + headers[key] = value as string; + } + } + + return headers; +} + +/** + * Validate OAuth2 configuration based on flow type + */ +function validateOAuth2Config(auth: OAuth2Auth): void { + // If using a flow, validate required fields + switch (auth.flow) { + case 'client_credentials': + 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 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: + // No flow specified - must have accessToken for OAuth2 to work + if (!auth.accessToken && !auth.flow) { + // This is fine - token refresh can still work if refreshToken is provided + // Or the request will just be made without auth + } + break; + } +} + +/** + * Handle OAuth2 token flows (client_credentials, password) + */ +async function handleOAuth2TokenFlow( + auth: OAuth2Auth, + originalParams: HttpRequestParams, + makeRequest: (params: HttpRequestParams) => Promise, + retryConfig?: RetryConfig +): Promise { + if (!auth.flow || !auth.tokenUrl) return null; + + const params = new URLSearchParams(); + + if (auth.flow === 'client_credentials') { + params.append('grant_type', 'client_credentials'); + params.append('client_id', auth.clientId!); + } else if (auth.flow === 'password') { + params.append('grant_type', 'password'); + params.append('username', auth.username || ''); + params.append('password', auth.password || ''); + params.append('client_id', auth.clientId!); + } else { + return null; + } + + if (auth.clientSecret) { + params.append('client_secret', auth.clientSecret); + } + if (auth.scopes && auth.scopes.length > 0) { + params.append('scope', auth.scopes.join(' ')); + } + + const authHeaders: Record = { + 'Content-Type': 'application/x-www-form-urlencoded' + }; + + // Use basic auth for client credentials if both client ID and secret are provided + if (auth.flow === 'client_credentials' && auth.clientId && auth.clientSecret) { + const credentials = Buffer.from(`${auth.clientId}:${auth.clientSecret}`).toString('base64'); + authHeaders['Authorization'] = `Basic ${credentials}`; + params.delete('client_id'); + params.delete('client_secret'); + } + + const tokenResponse = await fetch(auth.tokenUrl, { + method: 'POST', + headers: authHeaders, + body: params.toString() + }); + + if (!tokenResponse.ok) { + throw new HttpGlobalError(`OAuth2 token request failed: ${tokenResponse.statusText}`); + } + + const tokenData = await tokenResponse.json(); + const tokens: TokenResponse = { + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token, + expiresIn: tokenData.expires_in + }; + + // Notify the client about the tokens + if (auth.onTokenRefresh) { + auth.onTokenRefresh(tokens); + } + + // Retry the original request with the new token + const updatedHeaders = { ...originalParams.headers }; + updatedHeaders['Authorization'] = `Bearer ${tokens.accessToken}`; + + return executeWithRetry({ ...originalParams, headers: updatedHeaders }, makeRequest, retryConfig); +} + +/** + * Handle OAuth2 token refresh on 401 response + */ +async function handleTokenRefresh( + auth: OAuth2Auth, + originalParams: HttpRequestParams, + makeRequest: (params: HttpRequestParams) => Promise, + retryConfig?: RetryConfig +): Promise { + if (!auth.refreshToken || !auth.tokenUrl || !auth.clientId) return null; + + const refreshResponse = await fetch(auth.tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: auth.refreshToken, + client_id: auth.clientId, + ...(auth.clientSecret ? { client_secret: auth.clientSecret } : {}) + }).toString() + }); + + if (!refreshResponse.ok) { + throw new HttpGlobalError('Unauthorized'); + } + + const tokenData = await refreshResponse.json(); + const newTokens: TokenResponse = { + accessToken: tokenData.access_token, + refreshToken: tokenData.refresh_token || auth.refreshToken, + expiresIn: tokenData.expires_in + }; + + // Notify the client about the refreshed tokens + if (auth.onTokenRefresh) { + auth.onTokenRefresh(newTokens); + } + + // Retry the original request with the new token + const updatedHeaders = { ...originalParams.headers }; + updatedHeaders['Authorization'] = `Bearer ${newTokens.accessToken}`; + + return executeWithRetry({ ...originalParams, headers: updatedHeaders }, makeRequest, retryConfig); +} +// ============================================================================ +// Generated HTTP Client Functions +// ============================================================================ + +export interface PostV2ConnectContext extends HttpClientContext { + payload: PostV2ConnectRequestInterface | PostV2ConnectRequest; + requestHeaders?: PostV2ConnectHeaders; +} + +/** + * Generates a ConnectUrl where the user can be validated and connected. + */ +async function postV2Connect(context: PostV2ConnectContext): Promise> { + // Apply defaults + const config = { + baseUrl: 'https://api.example-safepay.com', + ...context, + }; + + // Validate OAuth2 config if present + if (config.auth?.type === 'oauth2' && AUTH_FEATURES.oauth2) { + validateOAuth2Config(config.auth); + } + + // Build headers + let headers = { 'Content-Type': 'application/json', ...config.additionalHeaders, ...(context.requestHeaders ? serializePostV2ConnectHeadersHeaders(context.requestHeaders) : {}) } as Record; + + // Build URL + let url = `${config.baseUrl}/v2/connect`; + url = applyQueryParams(config.additionalQueryParams, url); + + // Apply authentication + const authResult = applyAuth(config.auth, headers, url); + headers = authResult.headers; + url = authResult.url; + + // Prepare body + const payload = context.payload instanceof PostV2ConnectRequest ? context.payload : new PostV2ConnectRequest(context.payload); + const body = payload?.marshal(); + + // Determine request function + const makeRequest = config.hooks?.makeRequest ?? defaultMakeRequest; + + // Build request params + let requestParams: HttpRequestParams = { + url, + method: 'POST', + headers, + body + }; + + // Apply beforeRequest hook + if (config.hooks?.beforeRequest) { + requestParams = await config.hooks.beforeRequest(requestParams); + } + + try { + // Execute request with retry logic + let response = await executeWithRetry(requestParams, makeRequest, config.retry); + + // Apply afterResponse hook + if (config.hooks?.afterResponse) { + response = await config.hooks.afterResponse(response, requestParams); + } + + // Handle OAuth2 token flows that require getting a token first + if (config.auth?.type === 'oauth2' && !config.auth.accessToken && AUTH_FEATURES.oauth2) { + const tokenFlowResponse = await handleOAuth2TokenFlow(config.auth, requestParams, makeRequest, config.retry); + if (tokenFlowResponse) { + response = tokenFlowResponse; + } + } + + // Handle 401 with token refresh + if (response.status === 401 && config.auth?.type === 'oauth2' && AUTH_FEATURES.oauth2) { + try { + const refreshResponse = await handleTokenRefresh(config.auth, requestParams, makeRequest, config.retry); + if (refreshResponse) { + response = refreshResponse; + } + } catch { + throw new HttpGlobalError('Unauthorized'); + } + } + + // Handle error responses + if (!response.ok) { + const errorBody = await response.json().catch(() => undefined); + handleHttpError(response.status, response.statusText, errorBody); + } + + // Parse response + const rawData = await response.json(); + const responseData = PostV2ConnectResponse_200.unmarshal(JSON.stringify(rawData)); + + // Extract response metadata + const responseHeaders = extractHeaders(response); + + const result: HttpClientResponse = { + data: responseData, + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + rawData: rawData ?? {}, + }; + + return result; + + } catch (error) { + // Apply onError hook if present + if (config.hooks?.onError && error instanceof HttpGlobalError) { + throw await config.hooks.onError(error, requestParams); + } + throw error; + } +} + +export interface GetV2ConnectReferenceIdContext extends HttpClientContext { + parameters: GetV2ConnectReferenceIdParametersInterface | GetV2ConnectReferenceIdParameters; +} + +/** + * Translate a ReferenceId into a SafepayAccountId. + */ +async function getV2ConnectReferenceId(context: GetV2ConnectReferenceIdContext): Promise> { + // Apply defaults + const config = { + baseUrl: 'https://api.example-safepay.com', + ...context, + }; + + const parameters = context.parameters instanceof GetV2ConnectReferenceIdParameters ? context.parameters : new GetV2ConnectReferenceIdParameters(context.parameters); + + // Validate OAuth2 config if present + if (config.auth?.type === 'oauth2' && AUTH_FEATURES.oauth2) { + validateOAuth2Config(config.auth); + } + + // Build headers + let headers = { 'Content-Type': 'application/json', ...config.additionalHeaders } as Record; + + // Build URL + let url = buildUrlWithParameters(config.baseUrl, '/v2/connect/{referenceId}', parameters); + url = applyQueryParams(config.additionalQueryParams, url); + + // Apply authentication + const authResult = applyAuth(config.auth, headers, url); + headers = authResult.headers; + url = authResult.url; + + // Prepare body + const body = undefined; + + // Determine request function + const makeRequest = config.hooks?.makeRequest ?? defaultMakeRequest; + + // Build request params + let requestParams: HttpRequestParams = { + url, + method: 'GET', + headers, + body + }; + + // Apply beforeRequest hook + if (config.hooks?.beforeRequest) { + requestParams = await config.hooks.beforeRequest(requestParams); + } + + try { + // Execute request with retry logic + let response = await executeWithRetry(requestParams, makeRequest, config.retry); + + // Apply afterResponse hook + if (config.hooks?.afterResponse) { + response = await config.hooks.afterResponse(response, requestParams); + } + + // Handle OAuth2 token flows that require getting a token first + if (config.auth?.type === 'oauth2' && !config.auth.accessToken && AUTH_FEATURES.oauth2) { + const tokenFlowResponse = await handleOAuth2TokenFlow(config.auth, requestParams, makeRequest, config.retry); + if (tokenFlowResponse) { + response = tokenFlowResponse; + } + } + + // Handle 401 with token refresh + if (response.status === 401 && config.auth?.type === 'oauth2' && AUTH_FEATURES.oauth2) { + try { + const refreshResponse = await handleTokenRefresh(config.auth, requestParams, makeRequest, config.retry); + if (refreshResponse) { + response = refreshResponse; + } + } catch { + throw new HttpGlobalError('Unauthorized'); + } + } + + // Handle error responses + if (!response.ok) { + const errorBody = await response.json().catch(() => undefined); + handleHttpError(response.status, response.statusText, errorBody); + } + + // Parse response + const rawData = await response.json(); + const responseData = GetV2ConnectReferenceIdResponse_200.unmarshal(JSON.stringify(rawData)); + + // Extract response metadata + const responseHeaders = extractHeaders(response); + + const result: HttpClientResponse = { + data: responseData, + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + rawData: rawData ?? {}, + }; + + return result; + + } catch (error) { + // Apply onError hook if present + if (config.hooks?.onError && error instanceof HttpGlobalError) { + throw await config.hooks.onError(error, requestParams); + } + throw error; + } +} + +export interface GetV2UsersSafepayAccountIdBankAccountsContext extends HttpClientContext { + parameters: GetV2UsersSafepayAccountIdBankAccountsParametersInterface | GetV2UsersSafepayAccountIdBankAccountsParameters; +} + +/** + * Returns the bank accounts registered for a Safepay account. + */ +async function getV2UsersSafepayAccountIdBankAccounts(context: GetV2UsersSafepayAccountIdBankAccountsContext): Promise> { + // Apply defaults + const config = { + baseUrl: 'https://api.example-safepay.com', + ...context, + }; + + const parameters = context.parameters instanceof GetV2UsersSafepayAccountIdBankAccountsParameters ? context.parameters : new GetV2UsersSafepayAccountIdBankAccountsParameters(context.parameters); + + // Validate OAuth2 config if present + if (config.auth?.type === 'oauth2' && AUTH_FEATURES.oauth2) { + validateOAuth2Config(config.auth); + } + + // Build headers + let headers = { 'Content-Type': 'application/json', ...config.additionalHeaders } as Record; + + // Build URL + let url = buildUrlWithParameters(config.baseUrl, '/v2/users/{safepayAccountId}/bank-accounts', parameters); + url = applyQueryParams(config.additionalQueryParams, url); + + // Apply authentication + const authResult = applyAuth(config.auth, headers, url); + headers = authResult.headers; + url = authResult.url; + + // Prepare body + const body = undefined; + + // Determine request function + const makeRequest = config.hooks?.makeRequest ?? defaultMakeRequest; + + // Build request params + let requestParams: HttpRequestParams = { + url, + method: 'GET', + headers, + body + }; + + // Apply beforeRequest hook + if (config.hooks?.beforeRequest) { + requestParams = await config.hooks.beforeRequest(requestParams); + } + + try { + // Execute request with retry logic + let response = await executeWithRetry(requestParams, makeRequest, config.retry); + + // Apply afterResponse hook + if (config.hooks?.afterResponse) { + response = await config.hooks.afterResponse(response, requestParams); + } + + // Handle OAuth2 token flows that require getting a token first + if (config.auth?.type === 'oauth2' && !config.auth.accessToken && AUTH_FEATURES.oauth2) { + const tokenFlowResponse = await handleOAuth2TokenFlow(config.auth, requestParams, makeRequest, config.retry); + if (tokenFlowResponse) { + response = tokenFlowResponse; + } + } + + // Handle 401 with token refresh + if (response.status === 401 && config.auth?.type === 'oauth2' && AUTH_FEATURES.oauth2) { + try { + const refreshResponse = await handleTokenRefresh(config.auth, requestParams, makeRequest, config.retry); + if (refreshResponse) { + response = refreshResponse; + } + } catch { + throw new HttpGlobalError('Unauthorized'); + } + } + + // Handle error responses + if (!response.ok) { + const errorBody = await response.json().catch(() => undefined); + handleHttpError(response.status, response.statusText, errorBody); + } + + // Parse response + const rawData = await response.json(); + const responseData = GetV2UsersSafepayAccountIdBankAccountsResponse_200.unmarshal(JSON.stringify(rawData)); + + // Extract response metadata + const responseHeaders = extractHeaders(response); + + const result: HttpClientResponse = { + data: responseData, + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + rawData: rawData ?? {}, + }; + + return result; + + } catch (error) { + // Apply onError hook if present + if (config.hooks?.onError && error instanceof HttpGlobalError) { + throw await config.hooks.onError(error, requestParams); + } + throw error; + } +} + +export { postV2Connect, getV2ConnectReferenceId, getV2UsersSafepayAccountIdBankAccounts }; diff --git a/examples/openapi-http-server/src/generated/http_server.ts b/examples/openapi-http-server/src/generated/http_server.ts new file mode 100644 index 00000000..48a8c5dd --- /dev/null +++ b/examples/openapi-http-server/src/generated/http_server.ts @@ -0,0 +1,342 @@ +import {PostV2ConnectRequest, PostV2ConnectRequestInterface} from './payload/PostV2ConnectRequest'; +import {PostV2ConnectResponse_200, PostV2ConnectResponse_200Interface} from './payload/PostV2ConnectResponse_200'; +import {GetV2ConnectReferenceIdResponse_200, GetV2ConnectReferenceIdResponse_200Interface} from './payload/GetV2ConnectReferenceIdResponse_200'; +import {GetV2UsersSafepayAccountIdBankAccountsResponse_200, GetV2UsersSafepayAccountIdBankAccountsResponse_200Interface} from './payload/GetV2UsersSafepayAccountIdBankAccountsResponse_200'; +import {Status} from './payload/Status'; +import {BankAccount, BankAccountInterface} from './payload/BankAccount'; +import {InitializeRequest, InitializeRequestInterface} from './payload/InitializeRequest'; +import {InitializeModel, InitializeModelInterface} from './payload/InitializeModel'; +import {GetConnectModel, GetConnectModelInterface} from './payload/GetConnectModel'; +import {GetV2ConnectReferenceIdParameters, GetV2ConnectReferenceIdParametersInterface} from './parameter/GetV2ConnectReferenceIdParameters'; +import {GetV2UsersSafepayAccountIdBankAccountsParameters, GetV2UsersSafepayAccountIdBankAccountsParametersInterface} from './parameter/GetV2UsersSafepayAccountIdBankAccountsParameters'; +import {PostV2ConnectHeaders, serializePostV2ConnectHeadersHeaders, deserializePostV2ConnectHeadersHeaders} from './headers/PostV2ConnectHeaders'; +import { NextFunction, Request, Response, Router } from 'express'; + +// ============================================================================ +// Common Types - Shared across all HTTP server 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; + +/** + * Error a handler throws to answer with a specific HTTP status. + * + * Shape-compatible with the `HttpError` the generated HTTP client throws, so + * the same class reads the same on both sides of the wire. When `body` is set + * it is sent as-is; otherwise a `{message, status, statusText}` object is sent. + */ +export class HttpError extends HttpGlobalError { + status: number; + statusText: string; + body?: unknown; + + constructor(message: string, status: number, statusText: string, body?: unknown) { + super(message); + this.name = 'HttpError'; + this.status = status; + this.statusText = statusText; + this.body = body; + } +} + +/** + * Hooks for observing and customizing the generated request handlers. + */ +export interface HttpServerHooks { + /** + * Called before the request is read, for logging, metrics or access control. + * Throw an `HttpError` here to reject the request before it reaches the handler. + */ + beforeHandler?: (params: {request: Request}) => void | Promise; + + /** + * Called after a response has been sent. `body` is the JSON text that went + * out, or undefined when the response had no body. + */ + afterHandler?: (params: {request: Request; status: number; body?: string}) => void | Promise; + + /** + * Called when a handler throws. Return a replacement response to override the + * mapped one, or undefined to keep it. + */ + onError?: (params: {error: HttpGlobalError; request: Request}) => {status: number; body?: unknown} | undefined | Promise<{status: number; body?: unknown} | undefined>; +} + +/** + * Base context shared by every generated register function. + */ +export interface HttpServerContext { + /** Headers added to every response the registered route sends. */ + additionalHeaders?: Record; + + /** Hooks for extensibility */ + hooks?: HttpServerHooks; + + /** Skip validating the incoming request payload against its JSON Schema. */ + skipRequestValidation?: boolean; +} + +// ============================================================================ +// Helper Functions - Shared logic extracted for reuse +// ============================================================================ + +/** + * Read the request body as JSON. + * + * Works whether or not a body parser such as `express.json()` is mounted: an + * already-parsed body is handed back untouched, otherwise the raw stream is + * read. An empty body yields `undefined` instead of throwing, mirroring the + * client's defensive `readOptionalJsonBody`. + */ +function readJsonBody(request: Request): Promise { + const parsed = (request as {body?: unknown}).body; + if (typeof parsed === 'string') { + return Promise.resolve(parsed.length === 0 ? undefined : JSON.parse(parsed)); + } + // A body parser that saw no body leaves an empty object behind, so only a + // non-empty parsed body short-circuits the raw read. + if (parsed !== undefined && parsed !== null && (typeof parsed !== 'object' || Object.keys(parsed).length > 0)) { + return Promise.resolve(parsed); + } + return new Promise((resolve, reject) => { + // A parser upstream may already have drained the stream; waiting for an + // 'end' that will never fire again would hang the request forever. + if (request.readableEnded || request.complete) { + resolve(undefined); + return; + } + let raw = ''; + request.setEncoding('utf8'); + request.on('data', (chunk: string) => { raw += chunk; }); + request.on('end', () => { + if (raw.length === 0) { + resolve(undefined); + return; + } + try { + resolve(JSON.parse(raw)); + } catch (parseError) { + reject(parseError); + } + }); + request.on('error', reject); + }); +} + +/** + * Map a thrown error onto a response. + * + * An `HttpError` maps to its own status and body. Anything else is an internal + * failure and maps to a generic 500 — an internal error message must never + * become client-visible. + */ +function resolveErrorResponse(error: unknown): {status: number; body?: unknown} { + if (error instanceof HttpError) { + return { + status: error.status, + body: error.body ?? {message: error.message, status: error.status, statusText: error.statusText} + }; + } + return {status: 500, body: {message: 'Internal Server Error', status: 500, statusText: 'Internal Server Error'}}; +} + +/** + * Send a response. + * + * `body` is already-marshalled JSON text — a model's `marshal()` returns a + * string and applies the wire-name mapping, so `send` is correct here and + * `json` would double-encode it. `undefined` sends no body at all. + */ +function sendResponse({response, status, body, headers, additionalHeaders}: { + response: Response; + status: number; + body?: string; + headers?: Record; + additionalHeaders?: Record; +}): void { + // Per-response headers win over the context-wide ones. + for (const [name, value] of Object.entries({...additionalHeaders, ...headers})) { + response.setHeader(name, value); + } + response.status(status); + if (body === undefined) { + response.end(); + return; + } + response.setHeader('Content-Type', 'application/json'); + response.send(body); +} + +/** + * Turn an error thrown by a handler into a response. + * + * A half-written response cannot be recovered, so once headers are on the wire + * the error goes to Express' error middleware instead. + */ +async function handleHandlerError({error, request, response, next, hooks, additionalHeaders}: { + error: unknown; + request: Request; + response: Response; + next: NextFunction; + hooks?: HttpServerHooks; + additionalHeaders?: Record; +}): Promise { + if (response.headersSent) { + next(error); + return; + } + let resolved = resolveErrorResponse(error); + if (hooks?.onError && error instanceof HttpGlobalError) { + const override = await hooks.onError({error, request}); + if (override !== undefined) { + resolved = override; + } + } + sendResponse({ + response, + status: resolved.status, + body: resolved.body === undefined ? undefined : JSON.stringify(resolved.body), + additionalHeaders + }); +} + +// ============================================================================ +// Generated HTTP Server Functions +// ============================================================================ + +export type PostV2ConnectServerResponse = + | {status: 200; body: PostV2ConnectResponse_200Interface | PostV2ConnectResponse_200; headers?: Record} + | {status: 400; headers?: Record}; + +export interface RegisterPostV2ConnectContext extends HttpServerContext { + router: Router; + callback: (params: { + body: PostV2ConnectRequest; + requestHeaders: PostV2ConnectHeaders; + request: Request; + }) => PostV2ConnectServerResponse | Promise; +} + +/** + * Generates a ConnectUrl where the user can be validated and connected. + */ +function registerPostV2Connect(context: RegisterPostV2ConnectContext): void { + const validator = PostV2ConnectRequest.createValidator(); + context.router.post('/v2/connect', async (request: Request, response: Response, next: NextFunction) => { + try { + await context.hooks?.beforeHandler?.({request}); + const receivedData = await readJsonBody(request); + if(!context.skipRequestValidation) { + const {valid, errors} = PostV2ConnectRequest.validate({data: receivedData, ajvValidatorFunction: validator}); + if(!valid) { + throw new HttpError(`Invalid request payload received; ${JSON.stringify({cause: errors})}`, 400, 'Bad Request'); + } + } + const body = PostV2ConnectRequest.unmarshal(JSON.stringify(receivedData)); + const requestHeaders = deserializePostV2ConnectHeadersHeaders(request.headers as Record); + const result = await context.callback({body, requestHeaders, request}); + let responseBody: string | undefined = undefined; + switch (result.status) { + case 200: { + const responsePayload = result.body; + responseBody = (responsePayload instanceof PostV2ConnectResponse_200 ? responsePayload : new PostV2ConnectResponse_200(responsePayload)).marshal(); + break; + } + default: + break; + } + sendResponse({response, status: result.status, body: responseBody, headers: result.headers, additionalHeaders: context.additionalHeaders}); + await context.hooks?.afterHandler?.({request, status: result.status, body: responseBody}); + } catch (error) { + await handleHandlerError({error, request, response, next, hooks: context.hooks, additionalHeaders: context.additionalHeaders}); + } + }); +} + +export type GetV2ConnectReferenceIdServerResponse = + | {status: 200; body: GetV2ConnectReferenceIdResponse_200Interface | GetV2ConnectReferenceIdResponse_200; headers?: Record} + | {status: 404; headers?: Record}; + +export interface RegisterGetV2ConnectReferenceIdContext extends HttpServerContext { + router: Router; + callback: (params: { + parameters: GetV2ConnectReferenceIdParameters; + request: Request; + }) => GetV2ConnectReferenceIdServerResponse | Promise; +} + +/** + * Translate a ReferenceId into a SafepayAccountId. + */ +function registerGetV2ConnectReferenceId(context: RegisterGetV2ConnectReferenceIdContext): void { + context.router.get('/v2/connect/:referenceId', async (request: Request, response: Response, next: NextFunction) => { + try { + await context.hooks?.beforeHandler?.({request}); + const parameters = GetV2ConnectReferenceIdParameters.fromUrl(request.url, '/v2/connect/{referenceId}'); + const result = await context.callback({parameters, request}); + let responseBody: string | undefined = undefined; + switch (result.status) { + case 200: { + const responsePayload = result.body; + responseBody = (responsePayload instanceof GetV2ConnectReferenceIdResponse_200 ? responsePayload : new GetV2ConnectReferenceIdResponse_200(responsePayload)).marshal(); + break; + } + default: + break; + } + sendResponse({response, status: result.status, body: responseBody, headers: result.headers, additionalHeaders: context.additionalHeaders}); + await context.hooks?.afterHandler?.({request, status: result.status, body: responseBody}); + } catch (error) { + await handleHandlerError({error, request, response, next, hooks: context.hooks, additionalHeaders: context.additionalHeaders}); + } + }); +} + +export type GetV2UsersSafepayAccountIdBankAccountsServerResponse = + | {status: 200; body: GetV2UsersSafepayAccountIdBankAccountsResponse_200Interface | GetV2UsersSafepayAccountIdBankAccountsResponse_200; headers?: Record}; + +export interface RegisterGetV2UsersSafepayAccountIdBankAccountsContext extends HttpServerContext { + router: Router; + callback: (params: { + parameters: GetV2UsersSafepayAccountIdBankAccountsParameters; + request: Request; + }) => GetV2UsersSafepayAccountIdBankAccountsServerResponse | Promise; +} + +/** + * Returns the bank accounts registered for a Safepay account. + */ +function registerGetV2UsersSafepayAccountIdBankAccounts(context: RegisterGetV2UsersSafepayAccountIdBankAccountsContext): void { + context.router.get('/v2/users/:safepayAccountId/bank-accounts', async (request: Request, response: Response, next: NextFunction) => { + try { + await context.hooks?.beforeHandler?.({request}); + const parameters = GetV2UsersSafepayAccountIdBankAccountsParameters.fromUrl(request.url, '/v2/users/{safepayAccountId}/bank-accounts'); + const result = await context.callback({parameters, request}); + let responseBody: string | undefined = undefined; + switch (result.status) { + case 200: { + const responsePayload = result.body; + responseBody = (responsePayload instanceof GetV2UsersSafepayAccountIdBankAccountsResponse_200 ? responsePayload : new GetV2UsersSafepayAccountIdBankAccountsResponse_200(responsePayload)).marshal(); + break; + } + default: + break; + } + sendResponse({response, status: result.status, body: responseBody, headers: result.headers, additionalHeaders: context.additionalHeaders}); + await context.hooks?.afterHandler?.({request, status: result.status, body: responseBody}); + } catch (error) { + await handleHandlerError({error, request, response, next, hooks: context.hooks, additionalHeaders: context.additionalHeaders}); + } + }); +} + +export { registerPostV2Connect, registerGetV2ConnectReferenceId, registerGetV2UsersSafepayAccountIdBankAccounts }; diff --git a/examples/openapi-http-server/src/generated/index.ts b/examples/openapi-http-server/src/generated/index.ts new file mode 100644 index 00000000..c3525ad8 --- /dev/null +++ b/examples/openapi-http-server/src/generated/index.ts @@ -0,0 +1,4 @@ +import * as http_server from './http_server'; +import * as http_client from './http_client'; + +export {http_server, http_client}; diff --git a/examples/openapi-http-server/src/generated/parameter/GetV2ConnectReferenceIdParameters.ts b/examples/openapi-http-server/src/generated/parameter/GetV2ConnectReferenceIdParameters.ts new file mode 100644 index 00000000..43fd94be --- /dev/null +++ b/examples/openapi-http-server/src/generated/parameter/GetV2ConnectReferenceIdParameters.ts @@ -0,0 +1,122 @@ + +interface GetV2ConnectReferenceIdParametersInterface { + referenceId: string +} +class GetV2ConnectReferenceIdParameters { + private _referenceId: string; + + constructor(input: GetV2ConnectReferenceIdParametersInterface) { + this._referenceId = input.referenceId; + } + + get referenceId(): string { return this._referenceId; } + set referenceId(referenceId: string) { this._referenceId = referenceId; } + + + + /** + * Serialize path parameters according to OpenAPI 2.0/3.x specification + * @returns Record of parameter names to their serialized values for path substitution + */ + serializePathParameters(): Record { + const result: Record = {}; + + // Serialize path parameter: referenceId (style: simple, explode: false) + if (this.referenceId !== undefined && this.referenceId !== null) { + const value = this.referenceId; + if (Array.isArray(value)) { + result['referenceId'] = value.map(val => encodeURIComponent(String(val))).join(','); + } else if (typeof value === 'object' && value !== null) { + result['referenceId'] = Object.entries(value).map(([key, val]) => `${encodeURIComponent(key)},${encodeURIComponent(String(val))}`).join(','); + } else { + result['referenceId'] = encodeURIComponent(String(value)); + } + } + + return result; + } + /** + * Get the complete serialized URL with path and query parameters + * @param basePath The base path template (e.g., '/users/{id}') + * @returns The complete URL with serialized parameters + */ + serializeUrl(basePath: string): string { + let url = basePath; + + // Replace path parameters + + const pathParams = this.serializePathParameters(); + for (const [name, value] of Object.entries(pathParams)) { + url = url.replace(new RegExp(`{${name}}`, 'g'), value); + } + + // Add query parameters + + + return url; + } + + /** + * Get the channel path with parameters substituted (compatible with AsyncAPI channel interface) + * @param basePath The base path template (e.g., '/pet/findByStatus/{status}/{categoryId}') + * @returns The path with parameters replaced + */ + getChannelWithParameters(basePath: string): string { + return this.serializeUrl(basePath); + } + + /** + * Static method to create a new instance from a URL + * @param url The URL to parse + * @param basePath The base path template (e.g., '/pet/findByStatus/{status}/{categoryId}') + + * @returns A new GetV2ConnectReferenceIdParameters instance + */ + static fromUrl(url: string, basePath: string): GetV2ConnectReferenceIdParameters { + // Extract path parameters from URL + const pathParams = this.extractPathParameters(url, basePath); + const instance = new GetV2ConnectReferenceIdParameters({ referenceId: pathParams.referenceId }); + return instance; + } + + /** + * Extract path parameters from a URL using a base path template + * @param url The URL to extract parameters from + * @param basePath The base path template (e.g., '/pet/findByStatus/{status}/{categoryId}') + * @returns Object containing extracted path parameter values + */ + private static extractPathParameters(url: string, basePath: string): { referenceId: string } { + // Remove query string from URL for path matching + const urlPath = url.split('?')[0]; + + // Create regex pattern from base path template + const regexPattern = basePath.replace(/\{([^}]+)\}/g, '([^/]+)'); + const regex = new RegExp('^' + regexPattern + '$'); + + const match = urlPath.match(regex); + if (!match) { + throw new Error(`URL path '${urlPath}' does not match base path template '${basePath}'`); + } + + // Extract parameter names from base path template + const paramNames = basePath.match(/\{([^}]+)\}/g)?.map(p => p.slice(1, -1)) || []; + + // Map matched values to parameter names + const result: any = {}; + paramNames.forEach((paramName, index) => { + const rawValue = match[index + 1]; + const decodeValue = decodeURIComponent(rawValue); + switch (paramName) { + case 'referenceId': + result.referenceId = decodeValue; + break; + default: + result[paramName] = decodeValue; + } + }); + + return result; + } +} +export { GetV2ConnectReferenceIdParameters }; +export type { GetV2ConnectReferenceIdParametersInterface }; \ No newline at end of file diff --git a/examples/openapi-http-server/src/generated/parameter/GetV2UsersSafepayAccountIdBankAccountsParameters.ts b/examples/openapi-http-server/src/generated/parameter/GetV2UsersSafepayAccountIdBankAccountsParameters.ts new file mode 100644 index 00000000..b6bcf988 --- /dev/null +++ b/examples/openapi-http-server/src/generated/parameter/GetV2UsersSafepayAccountIdBankAccountsParameters.ts @@ -0,0 +1,122 @@ + +interface GetV2UsersSafepayAccountIdBankAccountsParametersInterface { + safepayAccountId: string +} +class GetV2UsersSafepayAccountIdBankAccountsParameters { + private _safepayAccountId: string; + + constructor(input: GetV2UsersSafepayAccountIdBankAccountsParametersInterface) { + this._safepayAccountId = input.safepayAccountId; + } + + get safepayAccountId(): string { return this._safepayAccountId; } + set safepayAccountId(safepayAccountId: string) { this._safepayAccountId = safepayAccountId; } + + + + /** + * Serialize path parameters according to OpenAPI 2.0/3.x specification + * @returns Record of parameter names to their serialized values for path substitution + */ + serializePathParameters(): Record { + const result: Record = {}; + + // Serialize path parameter: safepayAccountId (style: simple, explode: false) + if (this.safepayAccountId !== undefined && this.safepayAccountId !== null) { + const value = this.safepayAccountId; + if (Array.isArray(value)) { + result['safepayAccountId'] = value.map(val => encodeURIComponent(String(val))).join(','); + } else if (typeof value === 'object' && value !== null) { + result['safepayAccountId'] = Object.entries(value).map(([key, val]) => `${encodeURIComponent(key)},${encodeURIComponent(String(val))}`).join(','); + } else { + result['safepayAccountId'] = encodeURIComponent(String(value)); + } + } + + return result; + } + /** + * Get the complete serialized URL with path and query parameters + * @param basePath The base path template (e.g., '/users/{id}') + * @returns The complete URL with serialized parameters + */ + serializeUrl(basePath: string): string { + let url = basePath; + + // Replace path parameters + + const pathParams = this.serializePathParameters(); + for (const [name, value] of Object.entries(pathParams)) { + url = url.replace(new RegExp(`{${name}}`, 'g'), value); + } + + // Add query parameters + + + return url; + } + + /** + * Get the channel path with parameters substituted (compatible with AsyncAPI channel interface) + * @param basePath The base path template (e.g., '/pet/findByStatus/{status}/{categoryId}') + * @returns The path with parameters replaced + */ + getChannelWithParameters(basePath: string): string { + return this.serializeUrl(basePath); + } + + /** + * Static method to create a new instance from a URL + * @param url The URL to parse + * @param basePath The base path template (e.g., '/pet/findByStatus/{status}/{categoryId}') + + * @returns A new GetV2UsersSafepayAccountIdBankAccountsParameters instance + */ + static fromUrl(url: string, basePath: string): GetV2UsersSafepayAccountIdBankAccountsParameters { + // Extract path parameters from URL + const pathParams = this.extractPathParameters(url, basePath); + const instance = new GetV2UsersSafepayAccountIdBankAccountsParameters({ safepayAccountId: pathParams.safepayAccountId }); + return instance; + } + + /** + * Extract path parameters from a URL using a base path template + * @param url The URL to extract parameters from + * @param basePath The base path template (e.g., '/pet/findByStatus/{status}/{categoryId}') + * @returns Object containing extracted path parameter values + */ + private static extractPathParameters(url: string, basePath: string): { safepayAccountId: string } { + // Remove query string from URL for path matching + const urlPath = url.split('?')[0]; + + // Create regex pattern from base path template + const regexPattern = basePath.replace(/\{([^}]+)\}/g, '([^/]+)'); + const regex = new RegExp('^' + regexPattern + '$'); + + const match = urlPath.match(regex); + if (!match) { + throw new Error(`URL path '${urlPath}' does not match base path template '${basePath}'`); + } + + // Extract parameter names from base path template + const paramNames = basePath.match(/\{([^}]+)\}/g)?.map(p => p.slice(1, -1)) || []; + + // Map matched values to parameter names + const result: any = {}; + paramNames.forEach((paramName, index) => { + const rawValue = match[index + 1]; + const decodeValue = decodeURIComponent(rawValue); + switch (paramName) { + case 'safepayAccountId': + result.safepayAccountId = decodeValue; + break; + default: + result[paramName] = decodeValue; + } + }); + + return result; + } +} +export { GetV2UsersSafepayAccountIdBankAccountsParameters }; +export type { GetV2UsersSafepayAccountIdBankAccountsParametersInterface }; \ No newline at end of file diff --git a/examples/openapi-http-server/src/generated/payload/BankAccount.ts b/examples/openapi-http-server/src/generated/payload/BankAccount.ts new file mode 100644 index 00000000..0e604d38 --- /dev/null +++ b/examples/openapi-http-server/src/generated/payload/BankAccount.ts @@ -0,0 +1,110 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import addFormatsModule from 'ajv-formats'; +interface BankAccountInterface { + id?: string + iban?: string + isDefault?: boolean + additionalProperties?: Record +} +class BankAccount { + private _id?: string; + private _iban?: string; + private _isDefault?: boolean; + private _additionalProperties?: Record; + + constructor(input: BankAccountInterface) { + this._id = input.id; + this._iban = input.iban; + this._isDefault = input.isDefault; + this._additionalProperties = input.additionalProperties; + } + + get id(): string | undefined { return this._id; } + set id(id: string | undefined) { this._id = id; } + + get iban(): string | undefined { return this._iban; } + set iban(iban: string | undefined) { this._iban = iban; } + + get isDefault(): boolean | undefined { return this._isDefault; } + set isDefault(isDefault: boolean | undefined) { this._isDefault = isDefault; } + + get additionalProperties(): Record | undefined { return this._additionalProperties; } + set additionalProperties(additionalProperties: Record | undefined) { this._additionalProperties = additionalProperties; } + + public toJson(): Record { + const json: Record = {}; + if(this.id !== undefined) { + json["id"] = this.id; + } + if(this.iban !== undefined) { + json["iban"] = this.iban; + } + if(this.isDefault !== undefined) { + json["isDefault"] = this.isDefault; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of Object.entries(this.additionalProperties)) { + //Only unwrap those that are not already a property in the JSON object + if(["id","iban","isDefault","additionalProperties"].includes(String(key))) continue; + json[key] = value; + } + } + return json; + } + + public marshal(): string { + return JSON.stringify(this.toJson()); + } + + public static fromJson(obj: Record): BankAccount { + const instance = new BankAccount({} as any); + + if (obj["id"] !== undefined) { + instance.id = obj["id"] as string; + } + if (obj["iban"] !== undefined) { + instance.iban = obj["iban"] as string; + } + if (obj["isDefault"] !== undefined) { + instance.isDefault = obj["isDefault"] as boolean; + } + + instance.additionalProperties = {}; + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["id","iban","isDefault","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties[key] = value as any; + } + return instance; + } + + public static unmarshal(json: string | object): BankAccount { + const obj = typeof json === "object" ? json : JSON.parse(json); + return BankAccount.fromJson(obj as Record); + } + public static theCodeGenSchema = {"type":"object","properties":{"id":{"type":"string"},"iban":{"type":"string"},"isDefault":{"type":"boolean"}}}; + public static validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } { + const {data, ajvValidatorFunction} = context ?? {}; + // Intentionally parse JSON strings to support validation of marshalled output. + // Example: validate({data: marshal(obj)}) works because marshal returns JSON string. + // Note: String 'true' will be coerced to boolean true due to JSON.parse. + const parsedData = typeof data === 'string' ? JSON.parse(data) : data; + const validate = ajvValidatorFunction ?? this.createValidator(context) + return { + valid: validate(parsedData), + errors: validate.errors ?? undefined, + }; + } + public static createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction { + const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})}; + // `ajv-formats` is CommonJS; its default import is the module namespace under + // `moduleResolution: node16`/`nodenext`, so unwrap `.default` when present. + const addFormats = ((addFormatsModule as unknown as {default?: unknown}).default ?? addFormatsModule) as (ajv: Ajv) => Ajv; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { BankAccount }; +export type { BankAccountInterface }; \ No newline at end of file diff --git a/examples/openapi-http-server/src/generated/payload/GetConnectModel.ts b/examples/openapi-http-server/src/generated/payload/GetConnectModel.ts new file mode 100644 index 00000000..518b5a9b --- /dev/null +++ b/examples/openapi-http-server/src/generated/payload/GetConnectModel.ts @@ -0,0 +1,111 @@ +import {Status} from './Status'; +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import addFormatsModule from 'ajv-formats'; +interface GetConnectModelInterface { + referenceId?: string + safepayAccountId?: string + status?: Status + additionalProperties?: Record +} +class GetConnectModel { + private _referenceId?: string; + private _safepayAccountId?: string; + private _status?: Status; + private _additionalProperties?: Record; + + constructor(input: GetConnectModelInterface) { + this._referenceId = input.referenceId; + this._safepayAccountId = input.safepayAccountId; + this._status = input.status; + this._additionalProperties = input.additionalProperties; + } + + get referenceId(): string | undefined { return this._referenceId; } + set referenceId(referenceId: string | undefined) { this._referenceId = referenceId; } + + get safepayAccountId(): string | undefined { return this._safepayAccountId; } + set safepayAccountId(safepayAccountId: string | undefined) { this._safepayAccountId = safepayAccountId; } + + get status(): Status | undefined { return this._status; } + set status(status: Status | undefined) { this._status = status; } + + get additionalProperties(): Record | undefined { return this._additionalProperties; } + set additionalProperties(additionalProperties: Record | undefined) { this._additionalProperties = additionalProperties; } + + public toJson(): Record { + const json: Record = {}; + if(this.referenceId !== undefined) { + json["referenceId"] = this.referenceId; + } + if(this.safepayAccountId !== undefined) { + json["safepayAccountId"] = this.safepayAccountId; + } + if(this.status !== undefined) { + json["status"] = this.status; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of Object.entries(this.additionalProperties)) { + //Only unwrap those that are not already a property in the JSON object + if(["referenceId","safepayAccountId","status","additionalProperties"].includes(String(key))) continue; + json[key] = value; + } + } + return json; + } + + public marshal(): string { + return JSON.stringify(this.toJson()); + } + + public static fromJson(obj: Record): GetConnectModel { + const instance = new GetConnectModel({} as any); + + if (obj["referenceId"] !== undefined) { + instance.referenceId = obj["referenceId"] as string; + } + if (obj["safepayAccountId"] !== undefined) { + instance.safepayAccountId = obj["safepayAccountId"] as string; + } + if (obj["status"] !== undefined) { + instance.status = obj["status"] as Status; + } + + instance.additionalProperties = {}; + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["referenceId","safepayAccountId","status","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties[key] = value as any; + } + return instance; + } + + public static unmarshal(json: string | object): GetConnectModel { + const obj = typeof json === "object" ? json : JSON.parse(json); + return GetConnectModel.fromJson(obj as Record); + } + public static theCodeGenSchema = {"type":"object","properties":{"referenceId":{"type":"string"},"safepayAccountId":{"type":"string"},"status":{"type":"string","enum":["pending","connected","expired"]}},"$id":"GetConnectModel","$schema":"http://json-schema.org/draft-07/schema"}; + public static validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } { + const {data, ajvValidatorFunction} = context ?? {}; + // Intentionally parse JSON strings to support validation of marshalled output. + // Example: validate({data: marshal(obj)}) works because marshal returns JSON string. + // Note: String 'true' will be coerced to boolean true due to JSON.parse. + const parsedData = typeof data === 'string' ? JSON.parse(data) : data; + const validate = ajvValidatorFunction ?? this.createValidator(context) + return { + valid: validate(parsedData), + errors: validate.errors ?? undefined, + }; + } + public static createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction { + const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})}; + // `ajv-formats` is CommonJS; its default import is the module namespace under + // `moduleResolution: node16`/`nodenext`, so unwrap `.default` when present. + const addFormats = ((addFormatsModule as unknown as {default?: unknown}).default ?? addFormatsModule) as (ajv: Ajv) => Ajv; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { GetConnectModel }; +export type { GetConnectModelInterface }; \ No newline at end of file diff --git a/examples/openapi-http-server/src/generated/payload/GetV2ConnectReferenceIdResponse_200.ts b/examples/openapi-http-server/src/generated/payload/GetV2ConnectReferenceIdResponse_200.ts new file mode 100644 index 00000000..5c1035df --- /dev/null +++ b/examples/openapi-http-server/src/generated/payload/GetV2ConnectReferenceIdResponse_200.ts @@ -0,0 +1,111 @@ +import {Status} from './Status'; +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import addFormatsModule from 'ajv-formats'; +interface GetV2ConnectReferenceIdResponse_200Interface { + referenceId?: string + safepayAccountId?: string + status?: Status + additionalProperties?: Record +} +class GetV2ConnectReferenceIdResponse_200 { + private _referenceId?: string; + private _safepayAccountId?: string; + private _status?: Status; + private _additionalProperties?: Record; + + constructor(input: GetV2ConnectReferenceIdResponse_200Interface) { + this._referenceId = input.referenceId; + this._safepayAccountId = input.safepayAccountId; + this._status = input.status; + this._additionalProperties = input.additionalProperties; + } + + get referenceId(): string | undefined { return this._referenceId; } + set referenceId(referenceId: string | undefined) { this._referenceId = referenceId; } + + get safepayAccountId(): string | undefined { return this._safepayAccountId; } + set safepayAccountId(safepayAccountId: string | undefined) { this._safepayAccountId = safepayAccountId; } + + get status(): Status | undefined { return this._status; } + set status(status: Status | undefined) { this._status = status; } + + get additionalProperties(): Record | undefined { return this._additionalProperties; } + set additionalProperties(additionalProperties: Record | undefined) { this._additionalProperties = additionalProperties; } + + public toJson(): Record { + const json: Record = {}; + if(this.referenceId !== undefined) { + json["referenceId"] = this.referenceId; + } + if(this.safepayAccountId !== undefined) { + json["safepayAccountId"] = this.safepayAccountId; + } + if(this.status !== undefined) { + json["status"] = this.status; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of Object.entries(this.additionalProperties)) { + //Only unwrap those that are not already a property in the JSON object + if(["referenceId","safepayAccountId","status","additionalProperties"].includes(String(key))) continue; + json[key] = value; + } + } + return json; + } + + public marshal(): string { + return JSON.stringify(this.toJson()); + } + + public static fromJson(obj: Record): GetV2ConnectReferenceIdResponse_200 { + const instance = new GetV2ConnectReferenceIdResponse_200({} as any); + + if (obj["referenceId"] !== undefined) { + instance.referenceId = obj["referenceId"] as string; + } + if (obj["safepayAccountId"] !== undefined) { + instance.safepayAccountId = obj["safepayAccountId"] as string; + } + if (obj["status"] !== undefined) { + instance.status = obj["status"] as Status; + } + + instance.additionalProperties = {}; + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["referenceId","safepayAccountId","status","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties[key] = value as any; + } + return instance; + } + + public static unmarshal(json: string | object): GetV2ConnectReferenceIdResponse_200 { + const obj = typeof json === "object" ? json : JSON.parse(json); + return GetV2ConnectReferenceIdResponse_200.fromJson(obj as Record); + } + public static theCodeGenSchema = {"type":"object","properties":{"referenceId":{"type":"string"},"safepayAccountId":{"type":"string"},"status":{"type":"string","enum":["pending","connected","expired"]}},"$id":"getV2ConnectReferenceId_Response_200","$schema":"http://json-schema.org/draft-07/schema"}; + public static validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } { + const {data, ajvValidatorFunction} = context ?? {}; + // Intentionally parse JSON strings to support validation of marshalled output. + // Example: validate({data: marshal(obj)}) works because marshal returns JSON string. + // Note: String 'true' will be coerced to boolean true due to JSON.parse. + const parsedData = typeof data === 'string' ? JSON.parse(data) : data; + const validate = ajvValidatorFunction ?? this.createValidator(context) + return { + valid: validate(parsedData), + errors: validate.errors ?? undefined, + }; + } + public static createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction { + const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})}; + // `ajv-formats` is CommonJS; its default import is the module namespace under + // `moduleResolution: node16`/`nodenext`, so unwrap `.default` when present. + const addFormats = ((addFormatsModule as unknown as {default?: unknown}).default ?? addFormatsModule) as (ajv: Ajv) => Ajv; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { GetV2ConnectReferenceIdResponse_200 }; +export type { GetV2ConnectReferenceIdResponse_200Interface }; \ No newline at end of file diff --git a/examples/openapi-http-server/src/generated/payload/GetV2UsersSafepayAccountIdBankAccountsResponse_200.ts b/examples/openapi-http-server/src/generated/payload/GetV2UsersSafepayAccountIdBankAccountsResponse_200.ts new file mode 100644 index 00000000..bd065a4d --- /dev/null +++ b/examples/openapi-http-server/src/generated/payload/GetV2UsersSafepayAccountIdBankAccountsResponse_200.ts @@ -0,0 +1,93 @@ +import {BankAccount} from './BankAccount'; +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import addFormatsModule from 'ajv-formats'; +interface GetV2UsersSafepayAccountIdBankAccountsResponse_200Interface { + bankAccounts?: BankAccount[] + additionalProperties?: Record +} +class GetV2UsersSafepayAccountIdBankAccountsResponse_200 { + private _bankAccounts?: BankAccount[]; + private _additionalProperties?: Record; + + constructor(input: GetV2UsersSafepayAccountIdBankAccountsResponse_200Interface) { + this._bankAccounts = input.bankAccounts; + this._additionalProperties = input.additionalProperties; + } + + get bankAccounts(): BankAccount[] | undefined { return this._bankAccounts; } + set bankAccounts(bankAccounts: BankAccount[] | undefined) { this._bankAccounts = bankAccounts; } + + get additionalProperties(): Record | undefined { return this._additionalProperties; } + set additionalProperties(additionalProperties: Record | undefined) { this._additionalProperties = additionalProperties; } + + public toJson(): Record { + const json: Record = {}; + if(this.bankAccounts !== undefined) { + json["bankAccounts"] = this.bankAccounts.map((item: any) => + item && typeof item === 'object' && 'toJson' in item && typeof item.toJson === 'function' + ? item.toJson() + : item + ); + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of Object.entries(this.additionalProperties)) { + //Only unwrap those that are not already a property in the JSON object + if(["bankAccounts","additionalProperties"].includes(String(key))) continue; + json[key] = value; + } + } + return json; + } + + public marshal(): string { + return JSON.stringify(this.toJson()); + } + + public static fromJson(obj: Record): GetV2UsersSafepayAccountIdBankAccountsResponse_200 { + const instance = new GetV2UsersSafepayAccountIdBankAccountsResponse_200({} as any); + + if (obj["bankAccounts"] !== undefined) { + instance.bankAccounts = obj["bankAccounts"] == null + ? undefined + : (obj["bankAccounts"] as Record[]).map((item: Record) => BankAccount.fromJson(item)); + } + + instance.additionalProperties = {}; + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["bankAccounts","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties[key] = value as any; + } + return instance; + } + + public static unmarshal(json: string | object): GetV2UsersSafepayAccountIdBankAccountsResponse_200 { + const obj = typeof json === "object" ? json : JSON.parse(json); + return GetV2UsersSafepayAccountIdBankAccountsResponse_200.fromJson(obj as Record); + } + public static theCodeGenSchema = {"type":"object","properties":{"bankAccounts":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"iban":{"type":"string"},"isDefault":{"type":"boolean"}}}}},"$id":"getV2UsersSafepayAccountIdBankAccounts_Response_200","$schema":"http://json-schema.org/draft-07/schema"}; + public static validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } { + const {data, ajvValidatorFunction} = context ?? {}; + // Intentionally parse JSON strings to support validation of marshalled output. + // Example: validate({data: marshal(obj)}) works because marshal returns JSON string. + // Note: String 'true' will be coerced to boolean true due to JSON.parse. + const parsedData = typeof data === 'string' ? JSON.parse(data) : data; + const validate = ajvValidatorFunction ?? this.createValidator(context) + return { + valid: validate(parsedData), + errors: validate.errors ?? undefined, + }; + } + public static createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction { + const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})}; + // `ajv-formats` is CommonJS; its default import is the module namespace under + // `moduleResolution: node16`/`nodenext`, so unwrap `.default` when present. + const addFormats = ((addFormatsModule as unknown as {default?: unknown}).default ?? addFormatsModule) as (ajv: Ajv) => Ajv; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { GetV2UsersSafepayAccountIdBankAccountsResponse_200 }; +export type { GetV2UsersSafepayAccountIdBankAccountsResponse_200Interface }; \ No newline at end of file diff --git a/examples/openapi-http-server/src/generated/payload/InitializeModel.ts b/examples/openapi-http-server/src/generated/payload/InitializeModel.ts new file mode 100644 index 00000000..e2bea5cf --- /dev/null +++ b/examples/openapi-http-server/src/generated/payload/InitializeModel.ts @@ -0,0 +1,98 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import addFormatsModule from 'ajv-formats'; +interface InitializeModelInterface { + referenceId?: string + connectUrl?: string + additionalProperties?: Record +} +class InitializeModel { + private _referenceId?: string; + private _connectUrl?: string; + private _additionalProperties?: Record; + + constructor(input: InitializeModelInterface) { + this._referenceId = input.referenceId; + this._connectUrl = input.connectUrl; + this._additionalProperties = input.additionalProperties; + } + + get referenceId(): string | undefined { return this._referenceId; } + set referenceId(referenceId: string | undefined) { this._referenceId = referenceId; } + + get connectUrl(): string | undefined { return this._connectUrl; } + set connectUrl(connectUrl: string | undefined) { this._connectUrl = connectUrl; } + + get additionalProperties(): Record | undefined { return this._additionalProperties; } + set additionalProperties(additionalProperties: Record | undefined) { this._additionalProperties = additionalProperties; } + + public toJson(): Record { + const json: Record = {}; + if(this.referenceId !== undefined) { + json["referenceId"] = this.referenceId; + } + if(this.connectUrl !== undefined) { + json["connectUrl"] = this.connectUrl; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of Object.entries(this.additionalProperties)) { + //Only unwrap those that are not already a property in the JSON object + if(["referenceId","connectUrl","additionalProperties"].includes(String(key))) continue; + json[key] = value; + } + } + return json; + } + + public marshal(): string { + return JSON.stringify(this.toJson()); + } + + public static fromJson(obj: Record): InitializeModel { + const instance = new InitializeModel({} as any); + + if (obj["referenceId"] !== undefined) { + instance.referenceId = obj["referenceId"] as string; + } + if (obj["connectUrl"] !== undefined) { + instance.connectUrl = obj["connectUrl"] as string; + } + + instance.additionalProperties = {}; + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["referenceId","connectUrl","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties[key] = value as any; + } + return instance; + } + + public static unmarshal(json: string | object): InitializeModel { + const obj = typeof json === "object" ? json : JSON.parse(json); + return InitializeModel.fromJson(obj as Record); + } + public static theCodeGenSchema = {"type":"object","properties":{"referenceId":{"type":"string"},"connectUrl":{"type":"string","format":"uri"}},"$id":"InitializeModel","$schema":"http://json-schema.org/draft-07/schema"}; + public static validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } { + const {data, ajvValidatorFunction} = context ?? {}; + // Intentionally parse JSON strings to support validation of marshalled output. + // Example: validate({data: marshal(obj)}) works because marshal returns JSON string. + // Note: String 'true' will be coerced to boolean true due to JSON.parse. + const parsedData = typeof data === 'string' ? JSON.parse(data) : data; + const validate = ajvValidatorFunction ?? this.createValidator(context) + return { + valid: validate(parsedData), + errors: validate.errors ?? undefined, + }; + } + public static createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction { + const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})}; + // `ajv-formats` is CommonJS; its default import is the module namespace under + // `moduleResolution: node16`/`nodenext`, so unwrap `.default` when present. + const addFormats = ((addFormatsModule as unknown as {default?: unknown}).default ?? addFormatsModule) as (ajv: Ajv) => Ajv; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { InitializeModel }; +export type { InitializeModelInterface }; \ No newline at end of file diff --git a/examples/openapi-http-server/src/generated/payload/InitializeRequest.ts b/examples/openapi-http-server/src/generated/payload/InitializeRequest.ts new file mode 100644 index 00000000..09cc1282 --- /dev/null +++ b/examples/openapi-http-server/src/generated/payload/InitializeRequest.ts @@ -0,0 +1,98 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import addFormatsModule from 'ajv-formats'; +interface InitializeRequestInterface { + returnUrl: string + skipKyc?: boolean + additionalProperties?: Record +} +class InitializeRequest { + private _returnUrl: string; + private _skipKyc?: boolean; + private _additionalProperties?: Record; + + constructor(input: InitializeRequestInterface) { + this._returnUrl = input.returnUrl; + this._skipKyc = input.skipKyc; + this._additionalProperties = input.additionalProperties; + } + + get returnUrl(): string { return this._returnUrl; } + set returnUrl(returnUrl: string) { this._returnUrl = returnUrl; } + + get skipKyc(): boolean | undefined { return this._skipKyc; } + set skipKyc(skipKyc: boolean | undefined) { this._skipKyc = skipKyc; } + + get additionalProperties(): Record | undefined { return this._additionalProperties; } + set additionalProperties(additionalProperties: Record | undefined) { this._additionalProperties = additionalProperties; } + + public toJson(): Record { + const json: Record = {}; + if(this.returnUrl !== undefined) { + json["returnUrl"] = this.returnUrl; + } + if(this.skipKyc !== undefined) { + json["skipKyc"] = this.skipKyc; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of Object.entries(this.additionalProperties)) { + //Only unwrap those that are not already a property in the JSON object + if(["returnUrl","skipKyc","additionalProperties"].includes(String(key))) continue; + json[key] = value; + } + } + return json; + } + + public marshal(): string { + return JSON.stringify(this.toJson()); + } + + public static fromJson(obj: Record): InitializeRequest { + const instance = new InitializeRequest({} as any); + + if (obj["returnUrl"] !== undefined) { + instance.returnUrl = obj["returnUrl"] as string; + } + if (obj["skipKyc"] !== undefined) { + instance.skipKyc = obj["skipKyc"] as boolean; + } + + instance.additionalProperties = {}; + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["returnUrl","skipKyc","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties[key] = value as any; + } + return instance; + } + + public static unmarshal(json: string | object): InitializeRequest { + const obj = typeof json === "object" ? json : JSON.parse(json); + return InitializeRequest.fromJson(obj as Record); + } + public static theCodeGenSchema = {"type":"object","required":["returnUrl"],"properties":{"returnUrl":{"type":"string","format":"uri"},"skipKyc":{"type":"boolean"}},"$id":"InitializeRequest","$schema":"http://json-schema.org/draft-07/schema"}; + public static validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } { + const {data, ajvValidatorFunction} = context ?? {}; + // Intentionally parse JSON strings to support validation of marshalled output. + // Example: validate({data: marshal(obj)}) works because marshal returns JSON string. + // Note: String 'true' will be coerced to boolean true due to JSON.parse. + const parsedData = typeof data === 'string' ? JSON.parse(data) : data; + const validate = ajvValidatorFunction ?? this.createValidator(context) + return { + valid: validate(parsedData), + errors: validate.errors ?? undefined, + }; + } + public static createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction { + const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})}; + // `ajv-formats` is CommonJS; its default import is the module namespace under + // `moduleResolution: node16`/`nodenext`, so unwrap `.default` when present. + const addFormats = ((addFormatsModule as unknown as {default?: unknown}).default ?? addFormatsModule) as (ajv: Ajv) => Ajv; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { InitializeRequest }; +export type { InitializeRequestInterface }; \ No newline at end of file diff --git a/examples/openapi-http-server/src/generated/payload/PostV2ConnectRequest.ts b/examples/openapi-http-server/src/generated/payload/PostV2ConnectRequest.ts new file mode 100644 index 00000000..61c66c1d --- /dev/null +++ b/examples/openapi-http-server/src/generated/payload/PostV2ConnectRequest.ts @@ -0,0 +1,98 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import addFormatsModule from 'ajv-formats'; +interface PostV2ConnectRequestInterface { + returnUrl: string + skipKyc?: boolean + additionalProperties?: Record +} +class PostV2ConnectRequest { + private _returnUrl: string; + private _skipKyc?: boolean; + private _additionalProperties?: Record; + + constructor(input: PostV2ConnectRequestInterface) { + this._returnUrl = input.returnUrl; + this._skipKyc = input.skipKyc; + this._additionalProperties = input.additionalProperties; + } + + get returnUrl(): string { return this._returnUrl; } + set returnUrl(returnUrl: string) { this._returnUrl = returnUrl; } + + get skipKyc(): boolean | undefined { return this._skipKyc; } + set skipKyc(skipKyc: boolean | undefined) { this._skipKyc = skipKyc; } + + get additionalProperties(): Record | undefined { return this._additionalProperties; } + set additionalProperties(additionalProperties: Record | undefined) { this._additionalProperties = additionalProperties; } + + public toJson(): Record { + const json: Record = {}; + if(this.returnUrl !== undefined) { + json["returnUrl"] = this.returnUrl; + } + if(this.skipKyc !== undefined) { + json["skipKyc"] = this.skipKyc; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of Object.entries(this.additionalProperties)) { + //Only unwrap those that are not already a property in the JSON object + if(["returnUrl","skipKyc","additionalProperties"].includes(String(key))) continue; + json[key] = value; + } + } + return json; + } + + public marshal(): string { + return JSON.stringify(this.toJson()); + } + + public static fromJson(obj: Record): PostV2ConnectRequest { + const instance = new PostV2ConnectRequest({} as any); + + if (obj["returnUrl"] !== undefined) { + instance.returnUrl = obj["returnUrl"] as string; + } + if (obj["skipKyc"] !== undefined) { + instance.skipKyc = obj["skipKyc"] as boolean; + } + + instance.additionalProperties = {}; + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["returnUrl","skipKyc","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties[key] = value as any; + } + return instance; + } + + public static unmarshal(json: string | object): PostV2ConnectRequest { + const obj = typeof json === "object" ? json : JSON.parse(json); + return PostV2ConnectRequest.fromJson(obj as Record); + } + public static theCodeGenSchema = {"type":"object","required":["returnUrl"],"properties":{"returnUrl":{"type":"string","format":"uri"},"skipKyc":{"type":"boolean"}},"$id":"PostV2ConnectRequest","$schema":"http://json-schema.org/draft-07/schema"}; + public static validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } { + const {data, ajvValidatorFunction} = context ?? {}; + // Intentionally parse JSON strings to support validation of marshalled output. + // Example: validate({data: marshal(obj)}) works because marshal returns JSON string. + // Note: String 'true' will be coerced to boolean true due to JSON.parse. + const parsedData = typeof data === 'string' ? JSON.parse(data) : data; + const validate = ajvValidatorFunction ?? this.createValidator(context) + return { + valid: validate(parsedData), + errors: validate.errors ?? undefined, + }; + } + public static createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction { + const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})}; + // `ajv-formats` is CommonJS; its default import is the module namespace under + // `moduleResolution: node16`/`nodenext`, so unwrap `.default` when present. + const addFormats = ((addFormatsModule as unknown as {default?: unknown}).default ?? addFormatsModule) as (ajv: Ajv) => Ajv; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { PostV2ConnectRequest }; +export type { PostV2ConnectRequestInterface }; \ No newline at end of file diff --git a/examples/openapi-http-server/src/generated/payload/PostV2ConnectResponse_200.ts b/examples/openapi-http-server/src/generated/payload/PostV2ConnectResponse_200.ts new file mode 100644 index 00000000..93fb84eb --- /dev/null +++ b/examples/openapi-http-server/src/generated/payload/PostV2ConnectResponse_200.ts @@ -0,0 +1,98 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import addFormatsModule from 'ajv-formats'; +interface PostV2ConnectResponse_200Interface { + referenceId?: string + connectUrl?: string + additionalProperties?: Record +} +class PostV2ConnectResponse_200 { + private _referenceId?: string; + private _connectUrl?: string; + private _additionalProperties?: Record; + + constructor(input: PostV2ConnectResponse_200Interface) { + this._referenceId = input.referenceId; + this._connectUrl = input.connectUrl; + this._additionalProperties = input.additionalProperties; + } + + get referenceId(): string | undefined { return this._referenceId; } + set referenceId(referenceId: string | undefined) { this._referenceId = referenceId; } + + get connectUrl(): string | undefined { return this._connectUrl; } + set connectUrl(connectUrl: string | undefined) { this._connectUrl = connectUrl; } + + get additionalProperties(): Record | undefined { return this._additionalProperties; } + set additionalProperties(additionalProperties: Record | undefined) { this._additionalProperties = additionalProperties; } + + public toJson(): Record { + const json: Record = {}; + if(this.referenceId !== undefined) { + json["referenceId"] = this.referenceId; + } + if(this.connectUrl !== undefined) { + json["connectUrl"] = this.connectUrl; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of Object.entries(this.additionalProperties)) { + //Only unwrap those that are not already a property in the JSON object + if(["referenceId","connectUrl","additionalProperties"].includes(String(key))) continue; + json[key] = value; + } + } + return json; + } + + public marshal(): string { + return JSON.stringify(this.toJson()); + } + + public static fromJson(obj: Record): PostV2ConnectResponse_200 { + const instance = new PostV2ConnectResponse_200({} as any); + + if (obj["referenceId"] !== undefined) { + instance.referenceId = obj["referenceId"] as string; + } + if (obj["connectUrl"] !== undefined) { + instance.connectUrl = obj["connectUrl"] as string; + } + + instance.additionalProperties = {}; + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["referenceId","connectUrl","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties[key] = value as any; + } + return instance; + } + + public static unmarshal(json: string | object): PostV2ConnectResponse_200 { + const obj = typeof json === "object" ? json : JSON.parse(json); + return PostV2ConnectResponse_200.fromJson(obj as Record); + } + public static theCodeGenSchema = {"type":"object","properties":{"referenceId":{"type":"string"},"connectUrl":{"type":"string","format":"uri"}},"$id":"postV2Connect_Response_200","$schema":"http://json-schema.org/draft-07/schema"}; + public static validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } { + const {data, ajvValidatorFunction} = context ?? {}; + // Intentionally parse JSON strings to support validation of marshalled output. + // Example: validate({data: marshal(obj)}) works because marshal returns JSON string. + // Note: String 'true' will be coerced to boolean true due to JSON.parse. + const parsedData = typeof data === 'string' ? JSON.parse(data) : data; + const validate = ajvValidatorFunction ?? this.createValidator(context) + return { + valid: validate(parsedData), + errors: validate.errors ?? undefined, + }; + } + public static createValidator(context?: {ajvInstance?: Ajv, ajvOptions?: AjvOptions}): ValidateFunction { + const {ajvInstance} = {...context ?? {}, ajvInstance: new Ajv(context?.ajvOptions ?? {})}; + // `ajv-formats` is CommonJS; its default import is the module namespace under + // `moduleResolution: node16`/`nodenext`, so unwrap `.default` when present. + const addFormats = ((addFormatsModule as unknown as {default?: unknown}).default ?? addFormatsModule) as (ajv: Ajv) => Ajv; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { PostV2ConnectResponse_200 }; +export type { PostV2ConnectResponse_200Interface }; \ No newline at end of file diff --git a/examples/openapi-http-server/src/generated/payload/Status.ts b/examples/openapi-http-server/src/generated/payload/Status.ts new file mode 100644 index 00000000..f1a0a8e4 --- /dev/null +++ b/examples/openapi-http-server/src/generated/payload/Status.ts @@ -0,0 +1,7 @@ + +enum Status { + PENDING = "pending", + CONNECTED = "connected", + EXPIRED = "expired", +} +export { Status }; \ No newline at end of file diff --git a/examples/openapi-http-server/src/server.ts b/examples/openapi-http-server/src/server.ts new file mode 100644 index 00000000..6fce74a4 --- /dev/null +++ b/examples/openapi-http-server/src/server.ts @@ -0,0 +1,114 @@ +/** + * The server side: mount the generated handler stubs on an Express router. + * + * Everything here that is *not* business logic is generated — routing, request + * parsing, parameter extraction, header deserialization, request validation, + * response marshalling and error mapping. What you write is the body of each + * `callback`, and the type system tells you exactly which statuses and payload + * shapes you are allowed to return. + */ +import express, {Express, Router} from 'express'; +import { + HttpError, + registerGetV2ConnectReferenceId, + registerGetV2UsersSafepayAccountIdBankAccounts, + registerPostV2Connect +} from './generated/http_server'; +import {BankAccount} from './generated/payload/BankAccount'; +import {Status} from './generated/payload/Status'; + +/** A stand-in for whatever your real data store is. */ +const connectSessions = new Map< + string, + {safepayAccountId: string; status: Status} +>(); + +export function createSafepayRouter(): Router { + const router = Router(); + + // POST /v2/connect — a request WITH a body. + // `body` arrives as a validated `PostV2ConnectRequest` instance; the request + // is rejected with 400 before this runs if it does not match the schema. + registerPostV2Connect({ + router, + additionalHeaders: {'X-Powered-By': 'the-codegen-project'}, + callback: ({body, requestHeaders}) => { + if (!body.returnUrl.startsWith('https://')) { + // Throw an HttpError to answer with a specific status. Anything else + // you throw becomes a 500 with no details leaked. + throw new HttpError('returnUrl must be https', 400, 'Bad Request', { + field: 'returnUrl' + }); + } + + const referenceId = `ref_${connectSessions.size + 1}`; + connectSessions.set(referenceId, { + safepayAccountId: `acct_${connectSessions.size + 1}`, + status: Status.PENDING + }); + + // The return value is the response. A plain object literal is fine — it + // is normalized to the generated model before being marshalled. + return { + status: 200, + body: {referenceId, connectUrl: `https://connect.example/${referenceId}`}, + headers: { + 'X-Correlation-Id': requestHeaders.xMinusCorrelationMinusId ?? 'none' + } + }; + } + }); + + // GET /v2/connect/{referenceId} — path parameters arrive typed and parsed. + registerGetV2ConnectReferenceId({ + router, + callback: ({parameters}) => { + const session = connectSessions.get(parameters.referenceId); + if (!session) { + // 404 is a declared response for this operation, so it is returnable + // directly. Returning an undeclared status is a compile error. + return {status: 404}; + } + return { + status: 200, + body: { + referenceId: parameters.referenceId, + safepayAccountId: session.safepayAccountId, + status: session.status + } + }; + } + }); + + registerGetV2UsersSafepayAccountIdBankAccounts({ + router, + callback: ({parameters}) => ({ + status: 200, + // Only the top-level response body accepts a plain object literal; + // nested models are typed as their generated class, so build those. + body: { + bankAccounts: [ + new BankAccount({ + id: `ba_${parameters.safepayAccountId}`, + iban: 'DK5000400440116243', + isDefault: true + }) + ] + } + }) + }); + + return router; +} + +/** + * Nothing generated ever calls `listen` or constructs a `Router` — mounting is + * yours. `app.use('/prefix', router)` works too: the generated code reads the + * mount-relative `request.url`, so path templates still match. + */ +export function createSafepayApp(): Express { + const app = express(); + app.use(express.json()); + app.use(createSafepayRouter()); + return app; +} From aaaf6aa6a520a6641987db3caf81d6381158fa79 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Sat, 1 Aug 2026 20:17:13 +0200 Subject: [PATCH 10/16] docs: document the http_server protocol Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 +- docs/README.md | 1 + docs/generators/README.md | 2 +- docs/generators/channels.md | 4 +- docs/getting-started/protocols.md | 1 + docs/protocols/http_server.md | 180 ++++++++++++++++++ mcp-server/lib/data/examples.ts | 40 ++++ mcp-server/lib/data/generators.ts | 17 +- schemas/configuration-schema-0-with-docs.json | 2 + schemas/configuration-schema-0.json | 2 + .../channels/protocols/http/server.ts | 10 +- 11 files changed, 252 insertions(+), 9 deletions(-) create mode 100644 docs/protocols/http_server.md diff --git a/README.md b/README.md index 7aeb84ed..8ff53229 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ - 📊 Every generator fully customizable to fit your hearts desire - 👀 Integrate it into any project (Demos in [Next.JS](./examples/typescript-nextjs), [TypeScript Libraries](./examples/typescript-library)) - 💅 [Create custom generators to your use-case](https://the-codegen-project.org/docs/generators/custom) -- 🗄️ Protocol agnostic generator ([NATS](https://the-codegen-project.org/docs/protocols/nats), [Kafka](https://the-codegen-project.org/docs/protocols/kafka), [MQTT](https://the-codegen-project.org/docs/protocols/mqtt), [AMQP](https://the-codegen-project.org/docs/protocols/amqp), [event-source](https://the-codegen-project.org/docs/protocols/eventsource), [HTTP Client](https://the-codegen-project.org/docs/protocols/http_client), [WebSocket](https://the-codegen-project.org/docs/protocols/websocket), read the [docs](https://the-codegen-project.org/docs#protocols) for the full list and information) +- 🗄️ Protocol agnostic generator ([NATS](https://the-codegen-project.org/docs/protocols/nats), [Kafka](https://the-codegen-project.org/docs/protocols/kafka), [MQTT](https://the-codegen-project.org/docs/protocols/mqtt), [AMQP](https://the-codegen-project.org/docs/protocols/amqp), [event-source](https://the-codegen-project.org/docs/protocols/eventsource), [HTTP Client](https://the-codegen-project.org/docs/protocols/http_client), [HTTP Server](https://the-codegen-project.org/docs/protocols/http_server), [WebSocket](https://the-codegen-project.org/docs/protocols/websocket), read the [docs](https://the-codegen-project.org/docs#protocols) for the full list and information) - ⭐ And much more... # How it works diff --git a/docs/README.md b/docs/README.md index e33093d8..792ce36e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -56,6 +56,7 @@ Each protocol has its own limitations, corner cases, and features; thus, each ha - [MQTT](./protocols/mqtt.md) - [EventSource](./protocols/eventsource.md) - [HTTP Client](./protocols/http_client.md) +- [HTTP Server](./protocols/http_server.md) - [WebSocket client and server](./protocols/websocket.md) ### [Migrations](./migrations/README.md) diff --git a/docs/generators/README.md b/docs/generators/README.md index 02044fd6..52ecd5c2 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](https://github.com/the-codegen-project/cli/tree/main/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). OpenAPI `channels` can also generate the *server* side with the [`http_server`](../protocols/http_server.md) protocol — see the [`openapi-http-server` example](https://github.com/the-codegen-project/cli/tree/main/examples/openapi-http-server). | **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/channels.md b/docs/generators/channels.md index 02277598..e5aba965 100644 --- a/docs/generators/channels.md +++ b/docs/generators/channels.md @@ -28,7 +28,7 @@ This is supported through the following inputs: [`asyncapi`](../inputs/asyncapi. It supports the following languages; [`typescript`](#typescript) -It supports the following protocols; [`nats`](../protocols/nats.md), [`kafka`](../protocols/kafka.md), [`mqtt`](../protocols/mqtt.md), [`amqp`](../protocols/amqp.md), [`event_source`](../protocols/eventsource.md), [`http_client`](../protocols/http_client.md), [`websocket`](../protocols/websocket.md) +It supports the following protocols; [`nats`](../protocols/nats.md), [`kafka`](../protocols/kafka.md), [`mqtt`](../protocols/mqtt.md), [`amqp`](../protocols/amqp.md), [`event_source`](../protocols/eventsource.md), [`http_client`](../protocols/http_client.md), [`http_server`](../protocols/http_server.md), [`websocket`](../protocols/websocket.md) ## Options These are the available options for the `channels` generator; @@ -53,7 +53,7 @@ Depending on which protocol, these are the dependencies: - `MQTT`: https://github.com/mqttjs/MQTT.js v5 - `AMQP`: https://github.com/amqp-node/amqplib v0 - `EventSource`: `event_source_fetch`: https://github.com/Azure/fetch-event-source v2, `event_source_express`: https://github.com/expressjs/express v4 -- `HTTP`: none — uses the global `fetch` built into Node.js 18+ (the generated client relies on the native `fetch`/`Headers`; swap in `node-fetch`, `axios`, etc. via the `makeRequest` hook if needed) +- `HTTP`: `http_client`: none — uses the global `fetch` built into Node.js 18+ (the generated client relies on the native `fetch`/`Headers`; swap in `node-fetch`, `axios`, etc. via the `makeRequest` hook if needed), `http_server`: https://github.com/expressjs/express v4 - `WebSocket`: https://github.com/websockets/ws v8 For TypeScript, the generator creates one file per protocol plus an index file that re-exports all protocols as namespaces. For example; diff --git a/docs/getting-started/protocols.md b/docs/getting-started/protocols.md index 4e8a8027..b9af26bd 100644 --- a/docs/getting-started/protocols.md +++ b/docs/getting-started/protocols.md @@ -22,6 +22,7 @@ The Codegen Project currently supports these messaging protocols: | **[AMQP](../protocols/amqp.md)** | Advanced Message Queuing Protocol | Enterprise messaging, reliable message delivery | | **[EventSource](../protocols/eventsource.md)** | Server-Sent Events (SSE) protocol | Real-time web updates, streaming data to browsers | | **[HTTP Client](../protocols/http_client.md)** | RESTful API communication | HTTP APIs, REST services | +| **[HTTP Server](../protocols/http_server.md)** | Typed Express handler stubs from an OpenAPI document | Implementing a REST API against its specification | | **[WebSocket](../protocols/websocket.md)** | Full-duplex communication protocol | Real-time web applications, bidirectional communication | ## How Protocol Support Works diff --git a/docs/protocols/http_server.md b/docs/protocols/http_server.md new file mode 100644 index 00000000..7e57569e --- /dev/null +++ b/docs/protocols/http_server.md @@ -0,0 +1,180 @@ +--- +sidebar_position: 99 +--- + +# HTTP Server + +The HTTP server generator creates typed [Express](https://expressjs.com/) handler stubs from your API specification — the structural inverse of the [HTTP client](./http_client.md). For every operation you get a `register(context)` function that mounts a route on a `Router` you supply, hands your handler typed path/query parameters, a typed request body and typed request headers, and marshals whatever your handler returns. + +It is currently available through the generators ([channels](../generators/channels.md)). + +This is available **only from [OpenAPI](../inputs/openapi.md) documents** (Swagger 2.0, OpenAPI 3.0 and 3.1). AsyncAPI input generates nothing for this protocol: the AsyncAPI HTTP path requires an `operation.reply()` plus an HTTP `method` binding, which is a poor fit for server stubs. + +## TypeScript + +Dependency: [express](https://github.com/expressjs/express) v4. + +| **Feature** | Is supported? | +|---|---| +| Typed path & query parameters | ✅ | +| Typed request headers | ✅ | +| Typed request body | ✅ | +| Typed, status-code-discriminated responses | ✅ | +| Request payload validation | ✅ (via the [`payloads`](../generators/payloads.md) generator's `includeValidation`) | +| Handler hooks (before/after/onError) | ✅ | +| Router mounting under a prefix | ✅ | +| JSON based API | ✅ | +| XML based API | ❌ | +| Authentication / authorization enforcement | ❌ (compose your own middleware) | +| Typed response headers | ❌ | +| Frameworks other than Express | ❌ | +| Server / listener construction | ❌ (mounting stays yours) | +| POST / GET / PUT / PATCH / DELETE / HEAD / OPTIONS | ✅ | + +## Configuration + +```js +export default { + inputType: 'openapi', + inputPath: './openapi.json', + generators: [ + { + preset: 'channels', + outputPath: './src/__gen__/channels', + language: 'typescript', + protocols: ['http_server'] + } + ] +}; +``` + +`http_server` and `http_client` can be listed together. They produce two independent files, each with its own copy of the shared types (including `HttpError`), so generating both sides of the same document is a supported and useful setup — see the [`openapi-http-server` example](https://github.com/the-codegen-project/cli/tree/main/examples/openapi-http-server). + +## What is generated + +For each operation, three things: + +| Generated | What it is | +|---|---| +| `ServerResponse` | A status-code-discriminated union of everything the operation may answer with, assembled from the operation's declared `responses`. | +| `RegisterContext` | The register function's argument: `router`, your `callback`, and the shared `HttpServerContext` options. | +| `register(context)` | Mounts the route and handles parsing, parameter extraction, header deserialization, validation, response marshalling and error mapping. | + +```ts +export type GetPetByIdServerResponse = + | {status: 200; body: APetInterface | APet; headers?: Record} + | {status: 404; headers?: Record}; + +export interface RegisterGetPetByIdContext extends HttpServerContext { + router: Router; + callback: (params: { + parameters: GetPetByIdParameters; + requestHeaders: GetPetByIdHeaders; + request: Request; + }) => GetPetByIdServerResponse | Promise; +} +``` + +Using it: + +```ts +import {Router} from 'express'; +import {registerGetPetById} from './__gen__/channels/http_server'; + +const router = Router(); + +registerGetPetById({ + router, + callback: ({parameters}) => { + const pet = petStore.get(parameters.petId); + if (!pet) { + return {status: 404}; // declared by the document + } + return {status: 200, body: pet}; + } +}); +``` + +Returning a status the document does not declare is a **compile error**, not a runtime surprise. + +### The handler callback + +The callback takes a single destructured object: + +| Field | When it is present | +|---|---| +| `body` | Body-carrying methods (`POST`, `PUT`, `PATCH`) whose request body has a JSON schema. Already unmarshalled into the payload model. | +| `parameters` | The operation declares path or query parameters. A parsed instance of the generated parameter model. | +| `requestHeaders` | The operation declares header parameters. A typed object produced by the generated `deserializeHeaders`. | +| `request` | Always. The raw Express `Request`, for anything not modelled (cookies, raw auth headers, the socket). | + +`response` and `next` are deliberately **not** handed over: the callback's return value owns the response, and passing them would make that contract ambiguous. + +The returned `body` accepts either a plain object literal or a model instance — object bodies are normalized to the model before `marshal()`, so the wire-name mapping is always applied. + +## `HttpServerContext` + +Every `RegisterContext` extends it: + +| Option | Type | Description | +|---|---|---| +| `additionalHeaders` | `Record` | Headers added to every response the route sends. A per-response `headers` field wins over these. | +| `hooks` | `HttpServerHooks` | `beforeHandler`, `afterHandler` and `onError` — see below. | +| `skipRequestValidation` | `boolean` | Skip validating the incoming request payload against its JSON Schema. | + +### Hooks + +```ts +export interface HttpServerHooks { + beforeHandler?: (params: {request: Request}) => void | Promise; + afterHandler?: (params: {request: Request; status: number; body?: string}) => void | Promise; + onError?: (params: {error: HttpGlobalError; request: Request}) => + {status: number; body?: unknown} | undefined | Promise<{status: number; body?: unknown} | undefined>; +} +``` + +`beforeHandler` runs before the request is read — throw an `HttpError` there to reject a request before it reaches your handler. `afterHandler` receives the JSON text that was sent. `onError` may return a replacement response, or `undefined` to keep the mapped one. + +## Errors + +Throw an `HttpError` (exported from the generated file) to answer with a specific status: + +```ts +throw new HttpError('pet is not for sale', 409, 'Conflict', {petId}); +``` + +Anything else you throw maps to a generic `500` — an internal error message is **never** leaked into the response body. If the response has already started (`headersSent`), the error is handed to Express' error middleware instead, because a half-written response cannot be recovered. + +`HttpError` is shape-compatible with the one the generated HTTP client throws, so the same class reads the same on both sides of the wire. + +## Request validation + +When the [`payloads`](../generators/payloads.md) generator has `includeValidation` enabled (the default), request bodies are validated against their JSON Schema before your callback runs. A failing body is answered with `400` and the validation causes. The Ajv validator is compiled **once**, outside the route handler. + +There is no separate configuration option — set `skipRequestValidation: true` on the context to turn it off per route. + +## Mounting and prefixes + +Generated code never calls `app.listen`, never constructs a `Router` and never mounts anything: + +```ts +const app = express(); +app.use(express.json()); // optional — the stubs also read the raw stream +app.use('/api/v2', router); +``` + +There is no `basePath` option because none is needed: Express makes `request.url` mount-relative, which is exactly what the generated parameter extraction matches the path template against. + +`express.json()` is optional. `readJsonBody` returns an already-parsed body when a body parser populated one, and otherwise reads the raw stream itself. + +## Explicit non-goals + +- **No authentication or authorization.** Nothing is generated for verifying credentials, and per-operation `security` requirements are not enforced. Compose your own middleware. +- **No response header models.** Response headers are a free-form `Record` on each response variant. +- **No content-type negotiation.** JSON only, matching the payload models — non-JSON content types are dropped with a warning during payload processing. +- **No frameworks other than Express**, and no framework-agnostic core layer. +- **No listener or server construction.** + +## Known limitation + +When an operation declares several body-carrying responses, the response payload becomes a *union* model. A non-object member of that union (an array or a primitive) has no importable module of its own, so its body is marshalled with `JSON.stringify` rather than the model's `marshal()` — no wire-name mapping is applied for that particular variant. Single-response operations and object union members are unaffected. diff --git a/mcp-server/lib/data/examples.ts b/mcp-server/lib/data/examples.ts index f5a6edd2..abfede22 100644 --- a/mcp-server/lib/data/examples.ts +++ b/mcp-server/lib/data/examples.ts @@ -442,6 +442,46 @@ console.log('Order:', order);`, }, ], + http_server: [ + { + title: 'HTTP Server Stubs', + description: + 'Mount typed Express handlers generated from an OpenAPI document', + code: `import express, { Router } from 'express'; +import { HttpError, registerCreateOrder, registerGetOrder } from './channels/http_server'; + +const router = Router(); + +// POST /orders - the body arrives validated and unmarshalled. +registerCreateOrder({ + router, + callback: ({ body }) => { + const order = orders.create(body); + // The return value is the response; only declared statuses type-check. + return { status: 201, body: order }; + }, +}); + +// GET /orders/{orderId} - path parameters arrive typed and parsed. +registerGetOrder({ + router, + callback: ({ parameters }) => { + const order = orders.find(parameters.orderId); + if (!order) { + throw new HttpError('no such order', 404, 'Not Found'); + } + return { status: 200, body: order }; + }, +}); + +const app = express(); +app.use(express.json()); +app.use('/api', router); +app.listen(3000);`, + dependencies: ['express'], + }, + ], + event_source: [ { title: 'Server-Sent Events', diff --git a/mcp-server/lib/data/generators.ts b/mcp-server/lib/data/generators.ts index 7b40cc0e..435b080f 100644 --- a/mcp-server/lib/data/generators.ts +++ b/mcp-server/lib/data/generators.ts @@ -20,6 +20,7 @@ export type Protocol = | 'amqp' | 'websocket' | 'http_client' + | 'http_server' | 'event_source'; export interface GeneratorOption { @@ -162,6 +163,7 @@ export const generators: Record = { 'amqp', 'websocket', 'http_client', + 'http_server', 'event_source', ], }, @@ -281,7 +283,18 @@ export function getGenerator(preset: GeneratorPreset): GeneratorDefinition | und */ export const inputTypeGenerators: Record = { asyncapi: ['payloads', 'parameters', 'headers', 'types', 'channels', 'client', 'models', 'custom'], - openapi: ['payloads', 'parameters', 'headers', 'types', 'models', 'custom'], + // OpenAPI supports `channels` (the `http_client` and `http_server` protocols) + // and `client` too — they were missing from this hand-maintained map. + openapi: [ + 'payloads', + 'parameters', + 'headers', + 'types', + 'channels', + 'client', + 'models', + 'custom', + ], jsonschema: ['models', 'custom'], }; @@ -295,5 +308,7 @@ export const protocolDescriptions: Record = { amqp: 'AMQP 0-9-1 (RabbitMQ) with queues and exchanges', websocket: 'WebSocket bidirectional messaging for real-time communication', http_client: 'HTTP/REST client with various authentication methods', + http_server: + 'Typed Express handler stubs generated from an OpenAPI document (the inverse of http_client)', event_source: 'Server-Sent Events (SSE) for server-to-client streaming', }; diff --git a/schemas/configuration-schema-0-with-docs.json b/schemas/configuration-schema-0-with-docs.json index 1fb2b009..cd779dff 100644 --- a/schemas/configuration-schema-0-with-docs.json +++ b/schemas/configuration-schema-0-with-docs.json @@ -402,6 +402,7 @@ "amqp", "event_source", "http_client", + "http_server", "websocket" ] }, @@ -460,6 +461,7 @@ "amqp_queue_subscribe", "amqp_exchange_publish", "http_client", + "http_server", "event_source_fetch", "event_source_express", "websocket_publish", diff --git a/schemas/configuration-schema-0.json b/schemas/configuration-schema-0.json index fd5ae002..510a6699 100644 --- a/schemas/configuration-schema-0.json +++ b/schemas/configuration-schema-0.json @@ -402,6 +402,7 @@ "amqp", "event_source", "http_client", + "http_server", "websocket" ] }, @@ -460,6 +461,7 @@ "amqp_queue_subscribe", "amqp_exchange_publish", "http_client", + "http_server", "event_source_fetch", "event_source_express", "websocket_publish", diff --git a/src/codegen/generators/typescript/channels/protocols/http/server.ts b/src/codegen/generators/typescript/channels/protocols/http/server.ts index ddc2a235..470ec06c 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/server.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/server.ts @@ -22,8 +22,7 @@ import { } from '../../utils'; const METHODS_WITH_BODY = ['POST', 'PUT', 'PATCH']; -const RESPONSE_HEADERS_FIELD = - 'headers?: Record'; +const RESPONSE_HEADERS_FIELD = 'headers?: Record'; /** * Renders the register function and its supporting types for one operation. @@ -132,7 +131,8 @@ function generateResponseUnion({ } const members = responses.map((variant) => { - const status = variant.statusCode === 'default' ? 'number' : variant.statusCode; + const status = + variant.statusCode === 'default' ? 'number' : variant.statusCode; const body = variant.bodyInputType ? `body: ${variant.bodyInputType}; ` : ''; @@ -299,7 +299,9 @@ function generateRegisterImplementation({ }): string { // `{param}` -> `:param`, with a leading slash guaranteed, exactly as the // EventSource Express registration does. - const routePath = ensureLeadingSlash(requestTopic.replace(/{([^}]+)}/g, ':$1')); + const routePath = ensureLeadingSlash( + requestTopic.replace(/{([^}]+)}/g, ':$1') + ); const callbackArguments: string[] = []; const statements: string[] = []; From 892ec6ef80eee9662e98fb675be4637ffd6565f0 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Sat, 1 Aug 2026 20:30:55 +0200 Subject: [PATCH 11/16] refactor: consolidate shared HTTP client/server generator pieces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Factor HTTP_REASON_PHRASES, the HttpGlobalError alias block and the HttpError class source into protocols/http/shared.ts. Both protocol files still emit their own copy — they are independent modules — but the generator now has one source for each. Generated output is byte-identical (all snapshots unchanged). Co-Authored-By: Claude Opus 5 (1M context) --- .../channels/protocols/http/common-types.ts | 91 +++---------------- .../protocols/http/server-common-types.ts | 44 +++------ .../channels/protocols/http/shared.ts | 91 +++++++++++++++++++ 3 files changed, 118 insertions(+), 108 deletions(-) create mode 100644 src/codegen/generators/typescript/channels/protocols/http/shared.ts 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 d6757273..e8afe452 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/common-types.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/common-types.ts @@ -11,54 +11,19 @@ import { renderOAuth2Helpers, renderSecurityTypes } from './security'; +import { + HTTP_GLOBAL_ERROR_ALIAS, + HTTP_REASON_PHRASES, + renderHttpErrorClass +} from './shared'; -/** - * Standard HTTP reason phrases used to bake a message into each generated - * `handleHttpError` case. Keyed by status code; deterministic and - * collision-free across operations that declare the same code. - */ -const HTTP_REASON_PHRASES: Record = { - 400: 'Bad Request', - 401: 'Unauthorized', - 402: 'Payment Required', - 403: 'Forbidden', - 404: 'Not Found', - 405: 'Method Not Allowed', - 406: 'Not Acceptable', - 407: 'Proxy Authentication Required', - 408: 'Request Timeout', - 409: 'Conflict', - 410: 'Gone', - 411: 'Length Required', - 412: 'Precondition Failed', - 413: 'Payload Too Large', - 414: 'URI Too Long', - 415: 'Unsupported Media Type', - 416: 'Range Not Satisfiable', - 417: 'Expectation Failed', - 418: "I'm a Teapot", - 421: 'Misdirected Request', - 422: 'Unprocessable Entity', - 423: 'Locked', - 424: 'Failed Dependency', - 425: 'Too Early', - 426: 'Upgrade Required', - 428: 'Precondition Required', - 429: 'Too Many Requests', - 431: 'Request Header Fields Too Large', - 451: 'Unavailable For Legal Reasons', - 500: 'Internal Server Error', - 501: 'Not Implemented', - 502: 'Bad Gateway', - 503: 'Service Unavailable', - 504: 'Gateway Timeout', - 505: 'HTTP Version Not Supported', - 506: 'Variant Also Negotiates', - 507: 'Insufficient Storage', - 508: 'Loop Detected', - 510: 'Not Extended', - 511: 'Network Authentication Required' -}; +const CLIENT_HTTP_ERROR_DESCRIPTION = `/** + * Error thrown for non-OK HTTP responses. + * + * Carries the HTTP \`status\`, \`statusText\`, and the parsed response \`body\` + * (when the error response had a JSON body). Thrown by \`handleHttpError\` and + * routed through the \`onError\` hook / retry logic unchanged. + */`; /** * Build the body of the generated `handleHttpError` function from the set of @@ -154,16 +119,7 @@ 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; +${HTTP_GLOBAL_ERROR_ALIAS} /** * Standard HTTP response interface that wraps fetch-like responses @@ -192,26 +148,7 @@ export interface HttpClientResponse { rawData: Record; } -/** - * Error thrown for non-OK HTTP responses. - * - * Carries the HTTP \`status\`, \`statusText\`, and the parsed response \`body\` - * (when the error response had a JSON body). Thrown by \`handleHttpError\` and - * routed through the \`onError\` hook / retry logic unchanged. - */ -export class HttpError extends HttpGlobalError { - status: number; - statusText: string; - body?: unknown; - - constructor(message: string, status: number, statusText: string, body?: unknown) { - super(message); - this.name = 'HttpError'; - this.status = status; - this.statusText = statusText; - this.body = body; - } -} +${renderHttpErrorClass(CLIENT_HTTP_ERROR_DESCRIPTION)} /** * HTTP request parameters passed to the request hook diff --git a/src/codegen/generators/typescript/channels/protocols/http/server-common-types.ts b/src/codegen/generators/typescript/channels/protocols/http/server-common-types.ts index 39299a97..909dd5bb 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/server-common-types.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/server-common-types.ts @@ -5,8 +5,18 @@ * * The two sides deliberately emit their own copy of `HttpError` and the * `HttpGlobalError` alias: `http_client.ts` and `http_server.ts` are - * independent modules, so neither may import from the other. + * independent modules, so neither may import from the other. The *source* of + * those fragments is shared — see `./shared`. */ +import {HTTP_GLOBAL_ERROR_ALIAS, renderHttpErrorClass} from './shared'; + +const SERVER_HTTP_ERROR_DESCRIPTION = `/** + * Error a handler throws to answer with a specific HTTP status. + * + * Shape-compatible with the \`HttpError\` the generated HTTP client throws, so + * the same class reads the same on both sides of the wire. When \`body\` is set + * it is sent as-is; otherwise a \`{message, status, statusText}\` object is sent. + */`; /** * Renders the shared infrastructure block for the HTTP server protocol. @@ -19,37 +29,9 @@ export function renderHttpServerCommonTypes(): string { // Common Types - Shared across all HTTP server 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; +${HTTP_GLOBAL_ERROR_ALIAS} -/** - * Error a handler throws to answer with a specific HTTP status. - * - * Shape-compatible with the \`HttpError\` the generated HTTP client throws, so - * the same class reads the same on both sides of the wire. When \`body\` is set - * it is sent as-is; otherwise a \`{message, status, statusText}\` object is sent. - */ -export class HttpError extends HttpGlobalError { - status: number; - statusText: string; - body?: unknown; - - constructor(message: string, status: number, statusText: string, body?: unknown) { - super(message); - this.name = 'HttpError'; - this.status = status; - this.statusText = statusText; - this.body = body; - } -} +${renderHttpErrorClass(SERVER_HTTP_ERROR_DESCRIPTION)} /** * Hooks for observing and customizing the generated request handlers. diff --git a/src/codegen/generators/typescript/channels/protocols/http/shared.ts b/src/codegen/generators/typescript/channels/protocols/http/shared.ts new file mode 100644 index 00000000..d992d9c1 --- /dev/null +++ b/src/codegen/generators/typescript/channels/protocols/http/shared.ts @@ -0,0 +1,91 @@ +/** + * Source fragments the HTTP client and HTTP server protocols both emit. + * + * Two copies in the *generated output* are correct — `http_client.ts` and + * `http_server.ts` are independent modules and neither may import from the + * other. Two copies in the *generator* are not, so the fragments live here. + */ + +/** + * Standard HTTP reason phrases, keyed by status code. Deterministic and + * collision-free across operations that declare the same code. + */ +export const HTTP_REASON_PHRASES: Record = { + 400: 'Bad Request', + 401: 'Unauthorized', + 402: 'Payment Required', + 403: 'Forbidden', + 404: 'Not Found', + 405: 'Method Not Allowed', + 406: 'Not Acceptable', + 407: 'Proxy Authentication Required', + 408: 'Request Timeout', + 409: 'Conflict', + 410: 'Gone', + 411: 'Length Required', + 412: 'Precondition Failed', + 413: 'Payload Too Large', + 414: 'URI Too Long', + 415: 'Unsupported Media Type', + 416: 'Range Not Satisfiable', + 417: 'Expectation Failed', + 418: "I'm a Teapot", + 421: 'Misdirected Request', + 422: 'Unprocessable Entity', + 423: 'Locked', + 424: 'Failed Dependency', + 425: 'Too Early', + 426: 'Upgrade Required', + 428: 'Precondition Required', + 429: 'Too Many Requests', + 431: 'Request Header Fields Too Large', + 451: 'Unavailable For Legal Reasons', + 500: 'Internal Server Error', + 501: 'Not Implemented', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + 504: 'Gateway Timeout', + 505: 'HTTP Version Not Supported', + 506: 'Variant Also Negotiates', + 507: 'Insufficient Storage', + 508: 'Loop Detected', + 510: 'Not Extended', + 511: 'Network Authentication Required' +}; + +/** + * The `HttpGlobalError` alias block. Every generated HTTP module opens with it + * so nothing has to reference the ambient `Error` binding by its bare name. + */ +export const HTTP_GLOBAL_ERROR_ALIAS = `/** + * 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;`; + +/** + * The `HttpError` class both protocols emit. The body is identical on both + * sides — only the doc comment differs, because the client *throws* it on a + * non-OK response while the server *catches* it to build one. + */ +export function renderHttpErrorClass(description: string): string { + return `${description} +export class HttpError extends HttpGlobalError { + status: number; + statusText: string; + body?: unknown; + + constructor(message: string, status: number, statusText: string, body?: unknown) { + super(message); + this.name = 'HttpError'; + this.status = status; + this.statusText = statusText; + this.body = body; + } +}`; +} From 7b930f5a4bdf14bf8fb99d1615d13c0f144c5706 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Sat, 1 Aug 2026 21:13:45 +0200 Subject: [PATCH 12/16] fix: offer http_server in the codegen init wizard `--channels-protocols=http_server` was rejected outright, so `codegen init` could not produce an http_server configuration at all. Split the protocol options per input type: the messaging protocols stay AsyncAPI-only and http_server is offered for OpenAPI, so the interactive prompt (previously AsyncAPI-only) now runs for OpenAPI too without offering protocols the input cannot use. The OpenAPI branch honours an explicit selection instead of hard-coding http_client. Co-Authored-By: Claude Opus 5 (1M context) --- docs/usage.md | 11 ++--- src/commands/init.ts | 86 +++++++++++++++++++++++++++----------- test/commands/init.spec.ts | 11 +++++ 3 files changed, 79 insertions(+), 29 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 48cda2a0..d8d04afb 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -123,16 +123,17 @@ USAGE $ codegen init [--json] [--no-color] [--debug | | [--silent | -v | -q]] [--help] [--input-file ] [--config-name ] [--input-type asyncapi|openapi|jsonschema] [--output-directory ] [--config-type esm|json|yaml|ts] [--languages typescript] [--channels-protocols - nats|kafka|mqtt|amqp|event_source|http_client|websocket] [--no-tty] [--include-payloads] [--include-headers] - [--include-client] [--include-parameters] [--include-channels] [--include-types] [--include-models] - [--gitignore-generated] + nats|kafka|mqtt|amqp|event_source|http_client|websocket|http_server] [--no-tty] [--include-payloads] + [--include-headers] [--include-client] [--include-parameters] [--include-channels] [--include-types] + [--include-models] [--gitignore-generated] FLAGS -q, --quiet Only show errors and warnings -v, --verbose Show detailed output --channels-protocols=