diff --git a/docs/generators/channels.md b/docs/generators/channels.md index 5e81b8ce..e276f671 100644 --- a/docs/generators/channels.md +++ b/docs/generators/channels.md @@ -38,6 +38,7 @@ These are the available options for the `channels` generator; | functionTypeMapping | `{}` | Record\ | Used in conjunction with AsyncAPI input, can define channel ID along side the type of functions that should be rendered. | | kafkaTopicSeparator | `'.'` | String | Used with AsyncAPI to ensure the right character separate topics, example if address is my/resource/path it will be converted to my.resource.path | | eventSourceDependency | `'@microsoft/fetch-event-source'` | String | Because @microsoft/fetch-event-source is out-dated in some areas we allow you to change the fork/variant that can be used instead | +| organization | `'flat'` | `'flat' \| 'tag' \| 'path'` | Controls how generated channel functions are organized in the barrel `index.ts`. `flat` re-exports each function directly under its protocol namespace (default, unchanged). `tag` groups them under their API tag (operation tag first, then a v3 channel tag, otherwise an `untagged` bucket). `path` nests them by URL path / channel address segments; the leaf is the HTTP method for OpenAPI and a clean action verb (`publish`, `subscribe`, `jetStreamPublish`, …) for AsyncAPI. Only the barrel shape changes — the per-protocol function code is identical across styles. [See Organization](#organization) | ## TypeScript Regardless of protocol, these are the dependencies: @@ -83,3 +84,64 @@ outputPath/ ``` Each protocol file contains standalone exported functions for interacting with channels defined in your AsyncAPI document. + +### Organization + +The `organization` option controls how the generated functions are surfaced in the barrel `index.ts`. The per-protocol `.ts` files are **identical** across every style — only the re-export shape changes, so switching styles never changes the generated function code. + +| Value | Behavior | +|---|---| +| `flat` (default) | Every function is re-exported directly under its protocol namespace. Byte-identical to previous versions. | +| `tag` | Functions are grouped under their API tag. | +| `path` | Functions are nested by their URL path / channel address segments. | + +#### `flat` (default) + +```ts +import { http_client } from './channels'; +await http_client.updatePet({ /* ... */ }); +``` + +#### `tag` + +Functions are grouped one level deep under their first tag. Leaf names are kept **verbatim** (the operationId / generated function name is unchanged). + +- **OpenAPI**: the tag comes from the operation's `tags`. +- **AsyncAPI**: the tag comes from the operation's `tags` first; if the operation has none, the (AsyncAPI v3-only) channel `tags` are used. AsyncAPI v2 channels have no tags. Functions with no resolvable tag fall into an `untagged` bucket. + +```ts +import { http_client } from './channels'; +await http_client.pet.updatePet({ /* ... */ }); // OpenAPI, grouped by tag "pet" + +import { nats } from './channels'; +await nats.user.publishToSendUserSignedup({ /* ... */ }); // AsyncAPI operation tagged "user" +await nats.untagged.publishToSendSystemPing({ /* ... */ }); // operation with no tag +``` + +#### `path` + +Functions are nested through the static segments of the URL path (OpenAPI) or channel address (AsyncAPI); `{parameter}` placeholders and empty segments are dropped. The leaf differs by input: + +- **OpenAPI**: the leaf is the lowercased HTTP **method** (so `POST /pet` and `PUT /pet` coexist as `pet.post` and `pet.put`). +- **AsyncAPI**: the leaf is a clean **action verb** derived from the function type — `publish`, `subscribe`, `request`, `reply`, `jetStreamPublish`, `jetStreamPullSubscribe`, `jetStreamPushSubscribe`, etc. — mirroring the OpenAPI method leaf (an address has no HTTP method). If two functions would resolve to the same leaf at the same node, the second falls back to its full function name so nothing is ever dropped. + +```ts +import { http_client } from './channels'; +await http_client.pet.put({ /* ... */ }); // PUT /pet +await http_client.pet.findByStatus.get({ /* ... */ }); // GET /pet/findByStatus/{status}/{categoryId} + +import { nats } from './channels'; +await nats.user.signedup.publish({ /* ... */ }); // address user/signedup/{id} +await nats.user.signedup.jetStreamPublish({ /* ... */ }); +``` + +Configure it per channels generator: + +```js +{ + preset: 'channels', + outputPath: './src/__gen__/channels', + protocols: ['http_client'], + organization: 'tag' // 'flat' | 'tag' | 'path' +} +``` diff --git a/examples/openapi-http-client/README.md b/examples/openapi-http-client/README.md index 0124c384..df7b3d1c 100644 --- a/examples/openapi-http-client/README.md +++ b/examples/openapi-http-client/README.md @@ -60,3 +60,32 @@ The operations in this document have **no `operationId`**, so names are synthesi | `GET /v2/users/{safepayAccountId}/bank-accounts` | `getV2UsersSafepayAccountIdBankAccounts` | If your document declares `operationId`s, those are used verbatim (camel-cased) instead — which is almost always what you want. Give your operations meaningful `operationId`s for the cleanest client. + +## Organizing the client surface + +By default every function is a flat sibling under the `http_client` namespace. For larger APIs you can group them with the `organization` option — the generated function code is unchanged; only the barrel `index.ts` re-export shape changes. + +```js +// codegen.config.js +{ + preset: 'channels', + outputPath: './src/generated', + protocols: ['http_client'], + organization: 'tag' // 'flat' (default) | 'tag' | 'path' +} +``` + +Call sites, before and after: + +```ts +// organization: 'flat' (default) +await http_client.postV2Connect({server, payload}); + +// organization: 'tag' — grouped under the operation's OpenAPI tag (tag & leaf verbatim; here the tag is "Connect") +await http_client.Connect.postV2Connect({server, payload}); + +// organization: 'path' — nested by URL path segments, HTTP method as the leaf +await http_client.v2.connect.post({server, payload}); +``` + +See the [channels generator docs](https://the-codegen-project.org/docs/generators/channels) for the full description of each style. diff --git a/schemas/configuration-schema-0-with-docs.json b/schemas/configuration-schema-0-with-docs.json index e482912a..b9057439 100644 --- a/schemas/configuration-schema-0-with-docs.json +++ b/schemas/configuration-schema-0-with-docs.json @@ -456,6 +456,16 @@ "default": "@microsoft/fetch-event-source", "markdownDescription": "The npm package used as the EventSource (Server-Sent Events) client implementation. Override this when you need a fork or alternative because @microsoft/fetch-event-source is out of date in some areas. [Read more about the EventSource protocol here](https://the-codegen-project.org/docs/protocols/eventsource)" }, + "organization": { + "type": "string", + "enum": [ + "flat", + "tag", + "path" + ], + "default": "flat", + "markdownDescription": "Controls how the generated channel functions are organized in the barrel `index.ts`. \"flat\" (default) re-exports every function directly under each protocol namespace (today's behavior). \"tag\" groups the functions under their API tag (operation tag first, then a v3 channel tag, otherwise an \"untagged\" bucket). \"path\" nests the functions by their URL path / channel address segments; the leaf key is the HTTP method for OpenAPI inputs and a clean action verb (publish, subscribe, jetStreamPublish, etc.) for AsyncAPI inputs. The per-protocol function code is identical across all three styles — only the barrel re-export shape changes. [Read more about the channels generator here](https://the-codegen-project.org/docs/generators/channels)" + }, "language": { "type": "string", "const": "typescript", diff --git a/schemas/configuration-schema-0.json b/schemas/configuration-schema-0.json index 596307ce..1b3309f6 100644 --- a/schemas/configuration-schema-0.json +++ b/schemas/configuration-schema-0.json @@ -456,6 +456,16 @@ "default": "@microsoft/fetch-event-source", "description": "The npm package used as the EventSource (Server-Sent Events) client implementation. Override this when you need a fork or alternative because @microsoft/fetch-event-source is out of date in some areas. Read more about the EventSource protocol here" }, + "organization": { + "type": "string", + "enum": [ + "flat", + "tag", + "path" + ], + "default": "flat", + "description": "Controls how the generated channel functions are organized in the barrel `index.ts`. \"flat\" (default) re-exports every function directly under each protocol namespace (today's behavior). \"tag\" groups the functions under their API tag (operation tag first, then a v3 channel tag, otherwise an \"untagged\" bucket). \"path\" nests the functions by their URL path / channel address segments; the leaf key is the HTTP method for OpenAPI inputs and a clean action verb (publish, subscribe, jetStreamPublish, etc.) for AsyncAPI inputs. The per-protocol function code is identical across all three styles — only the barrel re-export shape changes. Read more about the channels generator here" + }, "language": { "type": "string", "const": "typescript", diff --git a/src/codegen/generators/typescript/channels/index.ts b/src/codegen/generators/typescript/channels/index.ts index 7804a8eb..0d17290a 100644 --- a/src/codegen/generators/typescript/channels/index.ts +++ b/src/codegen/generators/typescript/channels/index.ts @@ -2,11 +2,7 @@ /* eslint-disable security/detect-object-injection */ /* eslint-disable sonarjs/no-duplicate-string */ import {TheCodegenConfiguration, GeneratedFile} from '../../../types'; -import { - appendImportExtension, - resolveImportExtension, - joinPath -} from '../../../utils'; +import {resolveImportExtension, joinPath} from '../../../utils'; import { defaultTypeScriptParametersOptions, TypeScriptParameterRenderType, @@ -34,6 +30,7 @@ import { } from './types'; import {generateTypeScriptChannelsForAsyncAPI} from './asyncapi'; import {generateTypeScriptChannelsForOpenAPI} from './openapi'; +import {renderChannelIndex} from './utils'; export { TypeScriptChannelRenderedFunctionType, TypeScriptChannelRenderType, @@ -147,21 +144,14 @@ async function finalizeGeneration( protocolFiles[protocol] = fileContent; } - // Generate index.ts with namespace re-exports - let indexContent: string; - if (generatedProtocols.length > 0) { - const ext = resolveImportExtension(context.generator, context.config); - const imports = generatedProtocols - .map((p) => { - const importPath = appendImportExtension(`./${p}`, ext); - return `import * as ${p} from '${importPath}';`; - }) - .join('\n'); - const exports = generatedProtocols.join(', '); - indexContent = `${imports}\n\nexport {${exports}};\n`; - } else { - indexContent = '// No protocols generated\n'; - } + // Generate index.ts. The per-protocol `.ts` files are identical + // across every organization style — only this barrel changes shape. + const indexContent = renderChannelIndex({ + generatedProtocols, + externalProtocolFunctionInformation, + organization: context.generator.organization, + importExtension: resolveImportExtension(context.generator, context.config) + }); const indexFilePath = joinPath(context.generator.outputPath, 'index.ts'); files.push({path: indexFilePath, content: indexContent}); diff --git a/src/codegen/generators/typescript/channels/openapi.ts b/src/codegen/generators/typescript/channels/openapi.ts index 0957fc5f..9fb4045a 100644 --- a/src/codegen/generators/typescript/channels/openapi.ts +++ b/src/codegen/generators/typescript/channels/openapi.ts @@ -12,13 +12,13 @@ import { TypeScriptChannelsContext } from './types'; import {ConstrainedObjectModel} from '@asyncapi/modelina'; -import {collectProtocolDependencies} from './utils'; +import {collectProtocolDependencies, addRendersToExternal} from './utils'; import { renderHttpFetchClient, renderHttpCommonTypes, analyzeSecuritySchemes } from './protocols/http'; -import {getMessageTypeAndModule} from './utils'; +import {getMessageTypeAndModule, splitAddressSegments} from './utils'; import {camelCase} from '../utils'; import {createMissingInputDocumentError} from '../../../errors'; import {resolveImportExtension} from '../../../utils'; @@ -116,21 +116,17 @@ export async function generateTypeScriptChannelsForOpenAPI( protocolCodeFunctions['http_client'].unshift(commonTypesCode); } - // Add renders to output - protocolCodeFunctions['http_client'].push(...renders.map((r) => r.code)); - externalProtocolFunctionInformation['http_client'].push( - ...renders.map((r) => ({ - functionType: r.functionType, - functionName: r.functionName, - messageType: r.messageType ?? '', - replyType: r.replyType, - parameterType: r.parameterType - })) - ); - - // Add dependencies - const renderedDeps = renders.flatMap((r) => r.dependencies); - deps.push(...new Set(renderedDeps)); + // Add renders (code + external function information + dependencies) to output + // via the shared helper, so the `organization` grouping metadata and the + // path-parameter model name are forwarded from the same single place every + // protocol uses. + addRendersToExternal({ + protocol: 'http_client', + renders, + protocolCodeFunctions, + externalProtocolFunctionInformation, + dependencies: deps + }); } /** @@ -243,7 +239,7 @@ function processOperation( // Use the operationId directly as the function name; the HTTP method is // already encoded in synthesized ids (and meaningful in spec-provided ones), // so prepending the method here would double the verb (e.g. getGetUser). - return renderHttpFetchClient({ + const render = renderHttpFetchClient({ functionName: camelCase(operationId), requestMessageModule: hasBody ? requestMessageModule : undefined, requestMessageType: hasBody ? requestMessageType : undefined, @@ -266,6 +262,15 @@ function processOperation( deprecated, oauth2Enabled }); + + // Grouping metadata for the `organization` option (consumed in + // finalizeGeneration). tag → operation tag; path → static path segments with + // the HTTP method as the leaf. + render.tags = operation.tags ?? []; + render.pathSegments = splitAddressSegments(path); + render.method = method.toLowerCase(); + + return render; } /** diff --git a/src/codegen/generators/typescript/channels/protocols/amqp/index.ts b/src/codegen/generators/typescript/channels/protocols/amqp/index.ts index 9495c3ad..30f88933 100644 --- a/src/codegen/generators/typescript/channels/protocols/amqp/index.ts +++ b/src/codegen/generators/typescript/channels/protocols/amqp/index.ts @@ -11,7 +11,11 @@ import { findOperationId, getOperationMetadata } from '../../../../../utils'; -import {getMessageTypeAndModule} from '../../utils'; +import { + getMessageTypeAndModule, + attachGroupingMetadata, + addRendersToExternal +} from '../../utils'; import { shouldRenderFunctionType, getFunctionTypeMappingFromAsyncAPI @@ -21,7 +25,6 @@ import {renderPublishQueue} from './publishQueue'; import {renderSubscribeQueue} from './subscribeQueue'; import {ChannelInterface} from '@asyncapi/parser'; import {SingleFunctionRenderType} from '../../../../../types'; -import {ConstrainedObjectModel} from '@asyncapi/modelina'; import {createMissingPayloadError} from '../../../../../errors'; export {renderPublishExchange, renderPublishQueue, renderSubscribeQueue}; @@ -54,39 +57,14 @@ export async function generateAmqpChannels( ? await generateForOperations(context, channel, amqpContext) : await generateForChannels(context, channel, amqpContext); - addRendersToExternal( + addRendersToExternal({ + protocol: 'amqp', renders, protocolCodeFunctions, externalProtocolFunctionInformation, dependencies, parameter - ); -} - -function addRendersToExternal( - renders: SingleFunctionRenderType[], - protocolCodeFunctions: Record, - externalProtocolFunctionInformation: Record< - string, - TypeScriptChannelRenderedFunctionType[] - >, - dependencies: string[], - parameter?: ConstrainedObjectModel -) { - protocolCodeFunctions['amqp'].push(...renders.map((value) => value.code)); - externalProtocolFunctionInformation['amqp'].push( - ...renders.map((value) => ({ - functionType: value.functionType, - functionName: value.functionName, - messageType: value.messageType, - replyType: value.replyType, - parameterType: parameter?.type - })) - ); - const renderedDependencies = renders - .map((value) => value.dependencies) - .flat(Infinity); - dependencies.push(...(new Set(renderedDependencies) as any)); + }); } async function generateForOperations( @@ -128,15 +106,20 @@ async function generateForOperations( deprecated }; - renders.push( - ...generateOperationRenders( - operation, - updatedContext, - updatedFunctionTypeMapping, - generator, - exchangeName - ) + const operationRenders = generateOperationRenders( + operation, + updatedContext, + updatedFunctionTypeMapping, + generator, + exchangeName ); + attachGroupingMetadata({ + renders: operationRenders, + operation, + channel, + topic: context.topic + }); + renders.push(...operationRenders); } return renders; } @@ -242,5 +225,10 @@ async function generateForChannels( renders.push(render({...updatedContext, additionalProperties})); } } + attachGroupingMetadata({ + renders, + channel, + topic: context.topic + }); return renders; } diff --git a/src/codegen/generators/typescript/channels/protocols/eventsource/index.ts b/src/codegen/generators/typescript/channels/protocols/eventsource/index.ts index ff36d06a..0003dd6c 100644 --- a/src/codegen/generators/typescript/channels/protocols/eventsource/index.ts +++ b/src/codegen/generators/typescript/channels/protocols/eventsource/index.ts @@ -11,7 +11,11 @@ import { findOperationId, getOperationMetadata } from '../../../../../utils'; -import {getMessageTypeAndModule} from '../../utils'; +import { + getMessageTypeAndModule, + attachGroupingMetadata, + addRendersToExternal +} from '../../utils'; import { shouldRenderFunctionType, getFunctionTypeMappingFromAsyncAPI @@ -20,7 +24,6 @@ import {renderExpress} from './express'; import {renderFetch} from './fetch'; import {ChannelInterface} from '@asyncapi/parser'; import {SingleFunctionRenderType} from '../../../../../types'; -import {ConstrainedObjectModel} from '@asyncapi/modelina'; import {createMissingPayloadError} from '../../../../../errors'; export {renderFetch, renderExpress}; @@ -53,41 +56,14 @@ export async function generateEventSourceChannels( ? await generateForOperations(context, channel, eventSourceContext) : await generateForChannels(context, channel, eventSourceContext); - addRendersToExternal( + addRendersToExternal({ + protocol: 'event_source', renders, protocolCodeFunctions, externalProtocolFunctionInformation, dependencies, parameter - ); -} - -function addRendersToExternal( - renders: SingleFunctionRenderType[], - protocolCodeFunctions: Record, - externalProtocolFunctionInformation: Record< - string, - TypeScriptChannelRenderedFunctionType[] - >, - dependencies: string[], - parameter?: ConstrainedObjectModel -) { - protocolCodeFunctions['event_source'].push( - ...renders.map((value) => value.code) - ); - externalProtocolFunctionInformation['event_source'].push( - ...renders.map((value) => ({ - functionType: value.functionType, - functionName: value.functionName, - messageType: value.messageType, - replyType: value.replyType, - parameterType: parameter?.type - })) - ); - const renderedDependencies = renders - .map((value) => value.dependencies) - .flat(Infinity); - dependencies.push(...(new Set(renderedDependencies) as any)); + }); } async function generateForOperations( @@ -127,14 +103,19 @@ async function generateForOperations( deprecated }; - renders.push( - ...generateOperationRenders( - operation, - updatedContext, - updatedFunctionTypeMapping, - generator - ) + const operationRenders = generateOperationRenders( + operation, + updatedContext, + updatedFunctionTypeMapping, + generator ); + attachGroupingMetadata({ + renders: operationRenders, + operation, + channel, + topic: context.topic + }); + renders.push(...operationRenders); } return renders; } @@ -239,5 +220,10 @@ async function generateForChannels( ); } } + attachGroupingMetadata({ + renders, + channel, + topic: context.topic + }); return renders; } diff --git a/src/codegen/generators/typescript/channels/protocols/http/index.ts b/src/codegen/generators/typescript/channels/protocols/http/index.ts index 7a8eff52..c884044f 100644 --- a/src/codegen/generators/typescript/channels/protocols/http/index.ts +++ b/src/codegen/generators/typescript/channels/protocols/http/index.ts @@ -10,13 +10,17 @@ import { findReplyId, getOperationMetadata } from '../../../../../utils'; -import {getMessageTypeAndModule} from '../../utils'; +import { + getMessageTypeAndModule, + attachGroupingMetadata, + addRendersToExternal +} from '../../utils'; import { shouldRenderFunctionType, getFunctionTypeMappingFromAsyncAPI } from '../../asyncapi'; import {ChannelInterface} from '@asyncapi/parser'; -import {HttpRenderType, SingleFunctionRenderType} from '../../../../../types'; +import {HttpRenderType} from '../../../../../types'; import {ConstrainedObjectModel} from '@asyncapi/modelina'; import {renderHttpCommonTypes} from './common-types'; import {renderHttpFetchClient} from './client'; @@ -62,42 +66,14 @@ export async function generatehttpChannels( protocolCodeFunctions['http_client'].unshift(commonTypesCode); } - addRendersToExternal( + addRendersToExternal({ + protocol: 'http_client', renders, protocolCodeFunctions, externalProtocolFunctionInformation, dependencies, parameter - ); -} - -function addRendersToExternal( - renders: SingleFunctionRenderType[], - protocolCodeFunctions: Record, - externalProtocolFunctionInformation: Record< - string, - TypeScriptChannelRenderedFunctionType[] - >, - dependencies: string[], - parameter?: ConstrainedObjectModel -) { - protocolCodeFunctions['http_client'].push( - ...renders.map((value) => value.code) - ); - - externalProtocolFunctionInformation['http_client'].push( - ...renders.map((value) => ({ - functionType: value.functionType, - functionName: value.functionName, - messageType: value.messageType, - replyType: value.replyType, - parameterType: parameter?.type - })) - ); - const renderedDependencies = renders - .map((value) => value.dependencies) - .flat(Infinity); - dependencies.push(...(new Set(renderedDependencies) as any)); + }); } // eslint-disable-next-line sonarjs/cognitive-complexity @@ -112,6 +88,7 @@ function generateForOperations( const functionTypeMapping = generator.functionTypeMapping?.[channel.id()]; for (const operation of channel.operations().all()) { + const rendersBeforeOperation = renders.length; const updatedFunctionTypeMapping = getFunctionTypeMappingFromAsyncAPI(operation) ?? functionTypeMapping; const action = operation.action(); @@ -168,6 +145,12 @@ function generateForOperations( ); } } + attachGroupingMetadata({ + renders: renders.slice(rendersBeforeOperation), + operation, + channel, + topic + }); } return renders; } diff --git a/src/codegen/generators/typescript/channels/protocols/kafka/index.ts b/src/codegen/generators/typescript/channels/protocols/kafka/index.ts index 9565f260..432a8b2b 100644 --- a/src/codegen/generators/typescript/channels/protocols/kafka/index.ts +++ b/src/codegen/generators/typescript/channels/protocols/kafka/index.ts @@ -11,7 +11,11 @@ import { findOperationId, getOperationMetadata } from '../../../../../utils'; -import {getMessageTypeAndModule} from '../../utils'; +import { + getMessageTypeAndModule, + attachGroupingMetadata, + addRendersToExternal +} from '../../utils'; import { shouldRenderFunctionType, getFunctionTypeMappingFromAsyncAPI @@ -20,7 +24,6 @@ import {renderPublish} from './publish'; import {renderSubscribe} from './subscribe'; import {ChannelInterface} from '@asyncapi/parser'; import {SingleFunctionRenderType} from '../../../../../types'; -import {ConstrainedObjectModel} from '@asyncapi/modelina'; import {createMissingPayloadError} from '../../../../../errors'; export {renderPublish, renderSubscribe}; @@ -55,39 +58,14 @@ export async function generateKafkaChannels( ? await generateForOperations(context, channel, kafkaContext) : await generateForChannels(context, channel, kafkaContext); - addRendersToExternal( + addRendersToExternal({ + protocol: 'kafka', renders, protocolCodeFunctions, externalProtocolFunctionInformation, dependencies, parameter - ); -} - -function addRendersToExternal( - renders: SingleFunctionRenderType[], - protocolCodeFunctions: Record, - externalProtocolFunctionInformation: Record< - string, - TypeScriptChannelRenderedFunctionType[] - >, - dependencies: string[], - parameter?: ConstrainedObjectModel -) { - protocolCodeFunctions['kafka'].push(...renders.map((value) => value.code)); - externalProtocolFunctionInformation['kafka'].push( - ...renders.map((value) => ({ - functionType: value.functionType, - functionName: value.functionName, - messageType: value.messageType, - replyType: value.replyType, - parameterType: parameter?.type - })) - ); - const renderedDependencies = renders - .map((value) => value.dependencies) - .flat(Infinity); - dependencies.push(...(new Set(renderedDependencies) as any)); + }); } async function generateForOperations( @@ -128,14 +106,19 @@ async function generateForOperations( deprecated }; - renders.push( - ...generateOperationRenders( - operation, - updatedContext, - updatedFunctionTypeMapping, - generator - ) + const operationRenders = generateOperationRenders( + operation, + updatedContext, + updatedFunctionTypeMapping, + generator ); + attachGroupingMetadata({ + renders: operationRenders, + operation, + channel, + topic: context.topic + }); + renders.push(...operationRenders); } return renders; } @@ -225,5 +208,10 @@ async function generateForChannels( renders.push(render(updatedContext)); } } + attachGroupingMetadata({ + renders, + channel, + topic: context.topic + }); return renders; } diff --git a/src/codegen/generators/typescript/channels/protocols/mqtt/index.ts b/src/codegen/generators/typescript/channels/protocols/mqtt/index.ts index 32ff2cb4..da8cd07c 100644 --- a/src/codegen/generators/typescript/channels/protocols/mqtt/index.ts +++ b/src/codegen/generators/typescript/channels/protocols/mqtt/index.ts @@ -11,7 +11,11 @@ import { findOperationId, getOperationMetadata } from '../../../../../utils'; -import {getMessageTypeAndModule} from '../../utils'; +import { + getMessageTypeAndModule, + attachGroupingMetadata, + addRendersToExternal +} from '../../utils'; import {renderPublish} from './publish'; import {renderSubscribe} from './subscribe'; import { @@ -20,7 +24,6 @@ import { } from '../../asyncapi'; import {ChannelInterface} from '@asyncapi/parser'; import {SingleFunctionRenderType} from '../../../../../types'; -import {ConstrainedObjectModel} from '@asyncapi/modelina'; import {createMissingPayloadError} from '../../../../../errors'; export {renderPublish, renderSubscribe}; @@ -52,40 +55,14 @@ export async function generateMqttChannels( } else { renders = generateForChannels(context, channel, mqttContext); } - addRendersToExternal( + addRendersToExternal({ + protocol: 'mqtt', renders, protocolCodeFunctions, externalProtocolFunctionInformation, dependencies, parameter - ); -} - -function addRendersToExternal( - renders: SingleFunctionRenderType[], - protocolCodeFunctions: Record, - externalProtocolFunctionInformation: Record< - string, - TypeScriptChannelRenderedFunctionType[] - >, - dependencies: string[], - parameter?: ConstrainedObjectModel -) { - protocolCodeFunctions['mqtt'].push(...renders.map((value) => value.code)); - - externalProtocolFunctionInformation['mqtt'].push( - ...renders.map((value) => ({ - functionType: value.functionType, - functionName: value.functionName, - messageType: value.messageType, - replyType: value.replyType, - parameterType: parameter?.type - })) - ); - const renderedDependencies = renders - .map((value) => value.dependencies) - .flat(Infinity); - dependencies.push(...(new Set(renderedDependencies) as any)); + }); } function generateForOperations( @@ -98,6 +75,7 @@ function generateForOperations( const functionTypeMapping = generator.functionTypeMapping?.[channel.id()]; for (const operation of channel.operations().all()) { + const rendersBeforeOperation = renders.length; const updatedFunctionTypeMapping = getFunctionTypeMappingFromAsyncAPI(operation) ?? functionTypeMapping; const payloadId = findOperationId(operation, channel); @@ -146,6 +124,12 @@ function generateForOperations( ) { renders.push(renderSubscribe(updatedContext)); } + attachGroupingMetadata({ + renders: renders.slice(rendersBeforeOperation), + operation, + channel, + topic: context.topic + }); } return renders; } @@ -195,5 +179,10 @@ function generateForChannels( ) { renders.push(renderSubscribe(updatedContext)); } + attachGroupingMetadata({ + renders, + channel, + topic: context.topic + }); return renders; } diff --git a/src/codegen/generators/typescript/channels/protocols/nats/index.ts b/src/codegen/generators/typescript/channels/protocols/nats/index.ts index b2bb0690..169749f8 100644 --- a/src/codegen/generators/typescript/channels/protocols/nats/index.ts +++ b/src/codegen/generators/typescript/channels/protocols/nats/index.ts @@ -12,7 +12,11 @@ import { findReplyId, getOperationMetadata } from '../../../../../utils'; -import {getMessageTypeAndModule} from '../../utils'; +import { + getMessageTypeAndModule, + attachGroupingMetadata, + addRendersToExternal +} from '../../utils'; import { getFunctionTypeMappingFromAsyncAPI, shouldRenderFunctionType @@ -26,7 +30,6 @@ import {renderJetstreamPushSubscription} from './jetstreamPushSubscription'; import {renderJetstreamPublish} from './jetstreamPublish'; import {ChannelInterface, OperationInterface} from '@asyncapi/parser'; import {SingleFunctionRenderType} from '../../../../../types'; -import {ConstrainedObjectModel} from '@asyncapi/modelina'; import {TypeScriptPayloadRenderType} from '../../../payloads'; import {createMissingPayloadError} from '../../../../../errors'; @@ -70,39 +73,14 @@ export async function generateNatsChannels( ? await generateForOperations(context, channel, natsContext) : await generateForChannels(context, channel, natsContext); - addRendersToExternal( + addRendersToExternal({ + protocol: 'nats', renders, protocolCodeFunctions, externalProtocolFunctionInformation, dependencies, parameter - ); -} - -function addRendersToExternal( - renders: SingleFunctionRenderType[], - protocolCodeFunctions: Record, - externalProtocolFunctionInformation: Record< - string, - TypeScriptChannelRenderedFunctionType[] - >, - dependencies: string[], - parameter?: ConstrainedObjectModel -) { - protocolCodeFunctions['nats'].push(...renders.map((value) => value.code)); - externalProtocolFunctionInformation['nats'].push( - ...renders.map((value) => ({ - functionType: value.functionType, - functionName: value.functionName, - messageType: value.messageType, - replyType: value.replyType, - parameterType: parameter?.type - })) - ); - const renderedDependencies = renders - .map((value) => value.dependencies) - .flat(Infinity); - dependencies.push(...(new Set(renderedDependencies) as any)); + }); } async function generateForOperations( @@ -159,16 +137,21 @@ async function generateForOperations( deprecated }; - renders.push( - ...(await generateOperationRenders( - operation, - updatedContext, - updatedFunctionTypeMapping, - generator, - payloads, - channel - )) + const operationRenders = await generateOperationRenders( + operation, + updatedContext, + updatedFunctionTypeMapping, + generator, + payloads, + channel ); + attachGroupingMetadata({ + renders: operationRenders, + operation, + channel, + topic: context.topic + }); + renders.push(...operationRenders); } return renders; } @@ -383,5 +366,10 @@ async function generateForChannels( renders.push(render(updatedContext)); } } + attachGroupingMetadata({ + renders, + channel, + topic: context.topic + }); return renders; } diff --git a/src/codegen/generators/typescript/channels/protocols/websocket/index.ts b/src/codegen/generators/typescript/channels/protocols/websocket/index.ts index 27febfe1..40c90167 100644 --- a/src/codegen/generators/typescript/channels/protocols/websocket/index.ts +++ b/src/codegen/generators/typescript/channels/protocols/websocket/index.ts @@ -11,7 +11,11 @@ import { findOperationId, getOperationMetadata } from '../../../../../utils'; -import {getMessageTypeAndModule} from '../../utils'; +import { + getMessageTypeAndModule, + attachGroupingMetadata, + addRendersToExternal +} from '../../utils'; import { getFunctionTypeMappingFromAsyncAPI, shouldRenderFunctionType @@ -21,7 +25,6 @@ import {renderWebSocketSubscribe} from './subscribe'; import {renderWebSocketRegister} from './register'; import {ChannelInterface, OperationInterface} from '@asyncapi/parser'; import {SingleFunctionRenderType} from '../../../../../types'; -import {ConstrainedObjectModel} from '@asyncapi/modelina'; import {createMissingPayloadError} from '../../../../../errors'; export { @@ -62,41 +65,14 @@ export async function generateWebSocketChannels( ? await generateForOperations(context, channel, websocketContext) : await generateForChannels(context, channel, websocketContext); - addRendersToExternal( + addRendersToExternal({ + protocol: 'websocket', renders, protocolCodeFunctions, externalProtocolFunctionInformation, dependencies, parameter - ); -} - -function addRendersToExternal( - renders: SingleFunctionRenderType[], - protocolCodeFunctions: Record, - externalProtocolFunctionInformation: Record< - string, - TypeScriptChannelRenderedFunctionType[] - >, - dependencies: string[], - parameter?: ConstrainedObjectModel -) { - protocolCodeFunctions['websocket'].push( - ...renders.map((value) => value.code) - ); - externalProtocolFunctionInformation['websocket'].push( - ...renders.map((value) => ({ - functionType: value.functionType, - functionName: value.functionName, - messageType: value.messageType, - replyType: value.replyType, - parameterType: parameter?.type - })) - ); - const renderedDependencies = renders - .map((value) => value.dependencies) - .flat(Infinity); - dependencies.push(...(new Set(renderedDependencies) as any)); + }); } async function generateForOperations( @@ -148,14 +124,19 @@ async function generateForOperations( deprecated }; - renders.push( - ...(await generateOperationRenders( - operation, - updatedContext, - updatedFunctionTypeMapping, - generator - )) + const operationRenders = await generateOperationRenders( + operation, + updatedContext, + updatedFunctionTypeMapping, + generator ); + attachGroupingMetadata({ + renders: operationRenders, + operation, + channel, + topic: context.topic + }); + renders.push(...operationRenders); } return renders; } @@ -256,5 +237,10 @@ async function generateForChannels( renders.push(render(updatedContext)); } } + attachGroupingMetadata({ + renders, + channel, + topic: context.topic + }); return renders; } diff --git a/src/codegen/generators/typescript/channels/types.ts b/src/codegen/generators/typescript/channels/types.ts index 7d3413b8..005a15ef 100644 --- a/src/codegen/generators/typescript/channels/types.ts +++ b/src/codegen/generators/typescript/channels/types.ts @@ -163,6 +163,13 @@ export const zodTypescriptChannelsGenerator = z.object({ .describe( 'The npm package used as the EventSource (Server-Sent Events) client implementation. Override this when you need a fork or alternative because @microsoft/fetch-event-source is out of date in some areas. [Read more about the EventSource protocol here](https://the-codegen-project.org/docs/protocols/eventsource)' ), + organization: z + .enum(['flat', 'tag', 'path']) + .optional() + .default('flat') + .describe( + 'Controls how the generated channel functions are organized in the barrel `index.ts`. "flat" (default) re-exports every function directly under each protocol namespace (today\'s behavior). "tag" groups the functions under their API tag (operation tag first, then a v3 channel tag, otherwise an "untagged" bucket). "path" nests the functions by their URL path / channel address segments; the leaf key is the HTTP method for OpenAPI inputs and a clean action verb (publish, subscribe, jetStreamPublish, etc.) for AsyncAPI inputs. The per-protocol function code is identical across all three styles — only the barrel re-export shape changes. [Read more about the channels generator here](https://the-codegen-project.org/docs/generators/channels)' + ), language: z.literal('typescript').optional().default('typescript'), importExtension: zodImportExtension.describe( 'File extension appended to relative import paths in generated channel code. Use ".ts" for moduleResolution: "node16"/"nodenext", ".js" for compiled ESM output, or "none" (default) for bundlers. Overrides the global importExtension. [Read more about import extensions here](https://the-codegen-project.org/docs/configurations)' @@ -202,6 +209,15 @@ export type TypeScriptChannelRenderedFunctionType = { messageType: string; replyType?: string; parameterType?: string; + /** + * Grouping metadata consumed only by `finalizeGeneration` when `organization` + * is `tag` or `path`. `tags` is the ordered list of tag names for the source + * operation/channel, `pathSegments` the static (param-stripped) address/path + * segments, and `method` the lowercased HTTP method (OpenAPI path leaf only). + */ + tags?: string[]; + pathSegments?: string[]; + method?: string; }; export interface TypeScriptChannelRenderType { payloadRender: TypeScriptPayloadRenderType; diff --git a/src/codegen/generators/typescript/channels/utils.ts b/src/codegen/generators/typescript/channels/utils.ts index 57c5908d..c45587eb 100644 --- a/src/codegen/generators/typescript/channels/utils.ts +++ b/src/codegen/generators/typescript/channels/utils.ts @@ -3,7 +3,7 @@ import { ConstrainedObjectModel, OutputModel } from '@asyncapi/modelina'; -import {ChannelPayload} from '../../../types'; +import {ChannelPayload, SingleFunctionRenderType} from '../../../types'; import { ensureRelativePath, appendImportExtension, @@ -14,7 +14,13 @@ import { import {TypeScriptPayloadRenderType} from '../payloads'; import {TypeScriptParameterRenderType} from '../parameters'; import {TypeScriptHeadersRenderType} from '../headers'; -import {TypeScriptChannelsContext} from './types'; +import { + TypeScriptChannelsContext, + TypeScriptChannelRenderedFunctionType, + ChannelFunctionTypes +} from './types'; +import {ChannelInterface, OperationInterface} from '@asyncapi/parser'; +import {Logger} from '../../../../LoggingInterface'; export function addPayloadsToDependencies( models: ChannelPayload[], @@ -290,3 +296,430 @@ export function renderChannelJSDoc(params: { return parts.join('\n'); } + +/** + * A render object that can carry grouping metadata. Both + * `SingleFunctionRenderType` and `HttpRenderType` are structurally compatible. + */ +interface GroupableRender { + tags?: string[]; + pathSegments?: string[]; + method?: string; +} + +/** + * Split a channel address / URL path into its static segments, dropping empty + * segments and `{param}` placeholders (they are supplied at call time). + */ +export function splitAddressSegments(address: string): string[] { + return address + .split('/') + .filter( + (segment) => + segment.length > 0 && + !(segment.startsWith('{') && segment.endsWith('}')) + ); +} + +/** + * Resolve the grouping metadata (tags + path segments) for a rendered function. + * + * Tags follow the generation source: an operation-sourced function groups by + * its operation's tags; a channel-sourced function falls back to the channel's + * tags when the (v3-only) channel exposes them. AsyncAPI v2 channels have no + * `tags()` accessor, so the channel lookup is guarded and never throws. + */ +export function resolveGroupingMetadata({ + operation, + channel, + topic +}: { + operation?: OperationInterface; + channel?: ChannelInterface; + topic: string; +}): {tags: string[]; pathSegments: string[]} { + const tags: string[] = []; + if (operation) { + tags.push( + ...operation + .tags() + .all() + .map((tag) => tag.name()) + ); + } + if ( + tags.length === 0 && + channel && + typeof (channel as {tags?: unknown}).tags === 'function' + ) { + const channelTags = ( + channel as unknown as { + tags: () => {all: () => {name: () => string}[]} | undefined; + } + ).tags(); + if (channelTags && typeof channelTags.all === 'function') { + tags.push(...channelTags.all().map((tag) => tag.name())); + } + } + return {tags, pathSegments: splitAddressSegments(topic)}; +} + +/** + * Attach grouping metadata onto every render in place. Only defined fields are + * written, so it is safe to call with a partial metadata object. + */ +export function attachGroupingToRenders({ + renders, + tags, + pathSegments, + method +}: { + renders: GroupableRender[]; + tags?: string[]; + pathSegments?: string[]; + method?: string; +}): void { + for (const render of renders) { + if (tags !== undefined) { + render.tags = tags; + } + if (pathSegments !== undefined) { + render.pathSegments = pathSegments; + } + if (method !== undefined) { + render.method = method; + } + } +} + +/** + * Resolve the grouping metadata for a set of renders (via + * {@link resolveGroupingMetadata}) and attach it to them in one step. Every + * AsyncAPI protocol calls this from its operation/channel loop so that the + * resolve-then-attach pair lives in a single place — a protocol can't forget to + * resolve the metadata before attaching it. + */ +export function attachGroupingMetadata({ + renders, + operation, + channel, + topic +}: { + renders: GroupableRender[]; + operation?: OperationInterface; + channel?: ChannelInterface; + topic: string; +}): void { + attachGroupingToRenders({ + renders, + ...resolveGroupingMetadata({operation, channel, topic}) + }); +} + +/** + * The minimal render shape {@link addRendersToExternal} consumes. Both + * `SingleFunctionRenderType` and `HttpRenderType` are structurally assignable. + */ +type RenderForExternal = Pick< + SingleFunctionRenderType, + | 'functionName' + | 'code' + | 'dependencies' + | 'functionType' + | 'tags' + | 'pathSegments' + | 'method' +> & {messageType?: string; replyType?: string; parameterType?: string}; + +/** + * Push a protocol's renders into the shared output maps: the raw function code, + * the external function information (including the `organization` grouping + * metadata), and the de-duplicated dependency imports. + * + * Centralised so every protocol forwards the exact same field set from one + * place — a new protocol gets `organization` support for free, and the grouping + * metadata (`tags` / `pathSegments` / `method`) can never be dropped by a + * hand-rolled per-protocol copy. + */ +export function addRendersToExternal({ + protocol, + renders, + protocolCodeFunctions, + externalProtocolFunctionInformation, + dependencies, + parameter +}: { + protocol: string; + renders: RenderForExternal[]; + protocolCodeFunctions: Record; + externalProtocolFunctionInformation: Record< + string, + TypeScriptChannelRenderedFunctionType[] + >; + dependencies: string[]; + parameter?: ConstrainedObjectModel; +}): void { + // eslint-disable-next-line security/detect-object-injection + protocolCodeFunctions[protocol].push(...renders.map((value) => value.code)); + // eslint-disable-next-line security/detect-object-injection + externalProtocolFunctionInformation[protocol].push( + ...renders.map((value) => ({ + functionType: value.functionType, + functionName: value.functionName, + messageType: value.messageType ?? '', + replyType: value.replyType, + parameterType: value.parameterType ?? parameter?.type, + tags: value.tags, + pathSegments: value.pathSegments, + method: value.method + })) + ); + const renderedDependencies = renders + .map((value) => value.dependencies) + .flat(Infinity); + dependencies.push(...(new Set(renderedDependencies) as unknown as string[])); +} + +/** + * A nested grouping tree. A string leaf is the bare function name to be + * referenced as `.`; a nested object is a further level. + */ +export interface GroupTree { + [key: string]: string | GroupTree; +} + +const VALID_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + +/** + * Format an object-literal key, quoting it when it is not a valid JS + * identifier (e.g. a tag or path segment containing a hyphen or space). + */ +function formatObjectKey(key: string): string { + return VALID_IDENTIFIER.test(key) ? key : `'${key.replace(/'/g, "\\'")}'`; +} + +/** + * Group rendered functions by their first tag (one level). Functions without a + * tag fall into the `untagged` bucket. Leaf key = function name (verbatim). + */ +export function groupByTag( + functions: TypeScriptChannelRenderedFunctionType[] +): GroupTree { + const tree: GroupTree = {}; + for (const fn of functions) { + const tag = fn.tags?.[0] ?? 'untagged'; + // eslint-disable-next-line security/detect-object-injection + const bucket = (tree[tag] as GroupTree | undefined) ?? {}; + bucket[fn.functionName] = fn.functionName; + // eslint-disable-next-line security/detect-object-injection + tree[tag] = bucket; + } + return tree; +} + +/** + * 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. + */ +const FUNCTION_TYPE_PATH_LEAF: Partial> = { + [ChannelFunctionTypes.NATS_PUBLISH]: 'publish', + [ChannelFunctionTypes.NATS_SUBSCRIBE]: 'subscribe', + [ChannelFunctionTypes.NATS_REQUEST]: 'request', + [ChannelFunctionTypes.NATS_REPLY]: 'reply', + [ChannelFunctionTypes.NATS_JETSTREAM_PUBLISH]: 'jetStreamPublish', + [ChannelFunctionTypes.NATS_JETSTREAM_PULL_SUBSCRIBE]: + 'jetStreamPullSubscribe', + [ChannelFunctionTypes.NATS_JETSTREAM_PUSH_SUBSCRIBE]: + 'jetStreamPushSubscribe', + [ChannelFunctionTypes.MQTT_PUBLISH]: 'publish', + [ChannelFunctionTypes.MQTT_SUBSCRIBE]: 'subscribe', + [ChannelFunctionTypes.KAFKA_PUBLISH]: 'publish', + [ChannelFunctionTypes.KAFKA_SUBSCRIBE]: 'subscribe', + [ChannelFunctionTypes.AMQP_QUEUE_PUBLISH]: 'publish', + [ChannelFunctionTypes.AMQP_QUEUE_SUBSCRIBE]: 'subscribe', + [ChannelFunctionTypes.AMQP_EXCHANGE_PUBLISH]: 'publishToExchange', + [ChannelFunctionTypes.EVENT_SOURCE_FETCH]: 'fetch', + [ChannelFunctionTypes.EVENT_SOURCE_EXPRESS]: 'express', + [ChannelFunctionTypes.WEBSOCKET_PUBLISH]: 'publish', + [ChannelFunctionTypes.WEBSOCKET_SUBSCRIBE]: 'subscribe', + [ChannelFunctionTypes.WEBSOCKET_REGISTER]: 'register' +}; + +/** + * Walk the tree along a function's static path segments, creating intermediate + * nodes as needed. Returns the target node, or `null` (with a warning) when a + * segment collides with an existing leaf — nesting into it would silently drop + * that leaf's function. + */ +function descendToPathNode( + tree: GroupTree, + fn: TypeScriptChannelRenderedFunctionType +): GroupTree | null { + let node = tree; + for (const segment of fn.pathSegments ?? []) { + // eslint-disable-next-line security/detect-object-injection + const existing = node[segment]; + if (typeof existing === 'string') { + Logger.warn( + `Channel organization 'path': segment '${segment}' collides with an existing leaf ('${existing}'); skipping ${fn.functionName}.` + ); + return null; + } + if (existing === undefined) { + // eslint-disable-next-line security/detect-object-injection + node[segment] = {}; + } + // eslint-disable-next-line security/detect-object-injection + node = node[segment] as GroupTree; + } + return node; +} + +/** + * Resolve the leaf key for a function at a node: the clean action/method leaf, + * falling back to the unique function name on collision so nothing is dropped. + * Returns `null` (with a warning) only if the function name itself collides. + */ +function resolvePathLeafKey( + node: GroupTree, + fn: TypeScriptChannelRenderedFunctionType +): string | null { + const preferredLeaf = + // eslint-disable-next-line security/detect-object-injection + fn.method ?? FUNCTION_TYPE_PATH_LEAF[fn.functionType] ?? fn.functionName; + // eslint-disable-next-line security/detect-object-injection + const preferredTaken = node[preferredLeaf] !== undefined; + const leafKey = + preferredTaken && preferredLeaf !== fn.functionName + ? fn.functionName + : preferredLeaf; + // eslint-disable-next-line security/detect-object-injection + const existingLeaf = node[leafKey]; + if (existingLeaf !== undefined) { + Logger.warn( + `Channel organization 'path': leaf key '${leafKey}' collides at the same node; keeping the first (${String( + existingLeaf + )}), skipping ${fn.functionName}.` + ); + return null; + } + return leafKey; +} + +/** + * Nest rendered functions through their static path segments. The leaf key is + * the HTTP method for OpenAPI, and a clean action verb (see + * {@link FUNCTION_TYPE_PATH_LEAF}) for AsyncAPI. On a leaf-key collision the + * function's (unique) name is used as the leaf instead, so no function is ever + * silently dropped. If even that collides, or a path segment clashes with an + * existing leaf, the function is skipped with a warning. + */ +export function groupByPath( + functions: TypeScriptChannelRenderedFunctionType[] +): GroupTree { + const tree: GroupTree = {}; + for (const fn of functions) { + const node = descendToPathNode(tree, fn); + if (node === null) { + continue; + } + const leafKey = resolvePathLeafKey(node, fn); + if (leafKey === null) { + continue; + } + // eslint-disable-next-line security/detect-object-injection + node[leafKey] = fn.functionName; + } + return tree; +} + +/** + * Render the channel barrel `index.ts` content for a given organization style. + * The per-protocol `.ts` files never change between styles — only + * this barrel does: `flat` re-exports each protocol namespace, while `tag`/ + * `path` emit a grouped `const` object literal per protocol. + */ +export function renderChannelIndex({ + generatedProtocols, + externalProtocolFunctionInformation, + organization, + importExtension +}: { + generatedProtocols: string[]; + externalProtocolFunctionInformation: Record< + string, + TypeScriptChannelRenderedFunctionType[] + >; + organization: 'flat' | 'tag' | 'path'; + importExtension: ImportExtension; +}): string { + if (generatedProtocols.length === 0) { + return '// No protocols generated\n'; + } + if (organization === 'flat') { + const imports = generatedProtocols + .map( + (protocol) => + `import * as ${protocol} from '${appendImportExtension( + `./${protocol}`, + importExtension + )}';` + ) + .join('\n'); + return `${imports}\n\nexport {${generatedProtocols.join(', ')}};\n`; + } + const imports: string[] = []; + const constDeclarations: string[] = []; + for (const protocol of generatedProtocols) { + const internalName = `internal_${protocol}`; + // eslint-disable-next-line security/detect-object-injection + const functions = externalProtocolFunctionInformation[protocol] || []; + const tree = + organization === 'tag' ? groupByTag(functions) : groupByPath(functions); + imports.push( + `import * as ${internalName} from '${appendImportExtension( + `./${protocol}`, + importExtension + )}';` + ); + constDeclarations.push( + `export const ${protocol} = ${renderObjectLiteral({ + tree, + internalName + })} as const;` + ); + } + return `${imports.join('\n')}\n\n${constDeclarations.join('\n\n')}\n`; +} + +/** + * Render a grouping tree as a TypeScript object literal whose leaves reference + * `.`. + */ +export function renderObjectLiteral({ + tree, + internalName, + indentLevel = 1 +}: { + tree: GroupTree; + internalName: string; + indentLevel?: number; +}): string { + const pad = ' '.repeat(indentLevel); + const closePad = ' '.repeat(indentLevel - 1); + const entries = Object.entries(tree).map(([key, value]) => { + const formattedKey = formatObjectKey(key); + if (typeof value === 'string') { + return `${pad}${formattedKey}: ${internalName}.${value}`; + } + return `${pad}${formattedKey}: ${renderObjectLiteral({ + tree: value, + internalName, + indentLevel: indentLevel + 1 + })}`; + }); + return `{\n${entries.join(',\n')}\n${closePad}}`; +} diff --git a/src/codegen/types.ts b/src/codegen/types.ts index bc6edbba..e20eb7eb 100644 --- a/src/codegen/types.ts +++ b/src/codegen/types.ts @@ -198,6 +198,13 @@ export interface SingleFunctionRenderType { functionType: ChannelFunctionTypes; messageType: string; replyType?: string; + /** + * Grouping metadata used by the channels generator's `organization` option. + * Populated at the render call sites and consumed only in `finalizeGeneration`. + */ + tags?: string[]; + pathSegments?: string[]; + method?: string; } export interface HttpRenderType { @@ -207,6 +214,13 @@ export interface HttpRenderType { functionType: ChannelFunctionTypes; messageType?: string; replyType: string; + /** + * Grouping metadata used by the channels generator's `organization` option. + * Populated at the render call sites and consumed only in `finalizeGeneration`. + */ + tags?: string[]; + pathSegments?: string[]; + method?: string; // Name of the generated path-parameter model, when the operation declares // path parameters. Consumers (e.g. README generation) rely on this to know an // operation requires a `parameters` argument. diff --git a/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap b/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap index 0dfef8d0..92a819e5 100644 --- a/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap +++ b/test/codegen/generators/typescript/__snapshots__/channels.spec.ts.snap @@ -1371,6 +1371,34 @@ export { addPet, updatePet, findPetsByStatusAndCategory }; " `; +exports[`channels typescript organization path nests OpenAPI operations by path segment with the HTTP method as the leaf: openapi-path-index 1`] = ` +"import * as internal_http_client from './http_client'; + +export const http_client = { + pet: { + post: internal_http_client.addPet, + put: internal_http_client.updatePet, + findByStatus: { + get: internal_http_client.findPetsByStatusAndCategory + } + } +} as const; +" +`; + +exports[`channels typescript organization tag groups OpenAPI operations under their tag with verbatim leaf names: openapi-tag-index 1`] = ` +"import * as internal_http_client from './http_client'; + +export const http_client = { + pet: { + addPet: internal_http_client.addPet, + updatePet: internal_http_client.updatePet, + findPetsByStatusAndCategory: internal_http_client.findPetsByStatusAndCategory + } +} as const; +" +`; + exports[`channels typescript protocol-specific code generation should generate AMQP protocol code with parameters and headers: amqp-index 1`] = ` "import * as amqp from './amqp'; diff --git a/test/codegen/generators/typescript/channels-organization-utils.spec.ts b/test/codegen/generators/typescript/channels-organization-utils.spec.ts new file mode 100644 index 00000000..324ed714 --- /dev/null +++ b/test/codegen/generators/typescript/channels-organization-utils.spec.ts @@ -0,0 +1,192 @@ +import { + splitAddressSegments, + groupByTag, + groupByPath, + renderObjectLiteral, + renderChannelIndex +} from '../../../../src/codegen/generators/typescript/channels/utils'; +import {ChannelFunctionTypes} from '../../../../src/codegen/generators/typescript/channels/types'; +import {TypeScriptChannelRenderedFunctionType} from '../../../../src/codegen/generators/typescript/channels/types'; + +const fn = ( + functionName: string, + extra: Partial = {} +): TypeScriptChannelRenderedFunctionType => ({ + functionType: ChannelFunctionTypes.HTTP_CLIENT, + functionName, + messageType: 'Message', + ...extra +}); + +describe('channels organization utils', () => { + describe('splitAddressSegments', () => { + it('drops empty segments and {param} placeholders', () => { + expect(splitAddressSegments('user/signedup/{my_parameter}/{enum}')).toEqual([ + 'user', + 'signedup' + ]); + expect(splitAddressSegments('/pet/findByStatus/{status}')).toEqual([ + 'pet', + 'findByStatus' + ]); + expect(splitAddressSegments('noparameters')).toEqual(['noparameters']); + expect(splitAddressSegments('{onlyParam}')).toEqual([]); + }); + }); + + describe('groupByTag', () => { + it('groups by first tag and routes untagged functions to the untagged bucket', () => { + const tree = groupByTag([ + fn('addPet', {tags: ['pet']}), + fn('updatePet', {tags: ['pet']}), + fn('ping', {tags: []}), + fn('pong') + ]); + expect(tree).toEqual({ + pet: {addPet: 'addPet', updatePet: 'updatePet'}, + untagged: {ping: 'ping', pong: 'pong'} + }); + }); + }); + + describe('groupByPath', () => { + it('nests by path segments with the HTTP method as the leaf when present', () => { + const tree = groupByPath([ + fn('addPet', {pathSegments: ['pet'], method: 'post'}), + fn('updatePet', {pathSegments: ['pet'], method: 'put'}), + fn('findPets', {pathSegments: ['pet', 'findByStatus'], method: 'get'}) + ]); + expect(tree).toEqual({ + pet: { + post: 'addPet', + put: 'updatePet', + findByStatus: {get: 'findPets'} + } + }); + }); + + it('uses a clean action verb as the leaf when no method is present (AsyncAPI)', () => { + const tree = groupByPath([ + fn('publishToSendUserSignedup', { + pathSegments: ['user', 'signedup'], + functionType: ChannelFunctionTypes.NATS_PUBLISH + }), + fn('jetStreamPublishToSendUserSignedup', { + pathSegments: ['user', 'signedup'], + functionType: ChannelFunctionTypes.NATS_JETSTREAM_PUBLISH + }), + fn('publishToNoParameter', { + pathSegments: ['noparameters'], + functionType: ChannelFunctionTypes.NATS_PUBLISH + }) + ]); + expect(tree).toEqual({ + user: { + signedup: { + publish: 'publishToSendUserSignedup', + jetStreamPublish: 'jetStreamPublishToSendUserSignedup' + } + }, + noparameters: {publish: 'publishToNoParameter'} + }); + }); + + it('falls back to the function name when the action leaf collides (no drop)', () => { + const tree = groupByPath([ + fn('publishToA', { + pathSegments: ['pet'], + functionType: ChannelFunctionTypes.NATS_PUBLISH + }), + fn('publishToB', { + pathSegments: ['pet'], + functionType: ChannelFunctionTypes.NATS_PUBLISH + }) + ]); + expect(tree).toEqual({ + pet: {publish: 'publishToA', publishToB: 'publishToB'} + }); + }); + + it('falls back to the function name on a method-leaf collision (OpenAPI)', () => { + const tree = groupByPath([ + fn('first', {pathSegments: ['pet'], method: 'get'}), + fn('second', {pathSegments: ['pet'], method: 'get'}) + ]); + expect(tree).toEqual({pet: {get: 'first', second: 'second'}}); + }); + + it('skips a function whose path segment collides with an existing leaf', () => { + const tree = groupByPath([ + fn('getPet', {pathSegments: ['pet'], method: 'get'}), + fn('getPetGet', {pathSegments: ['pet', 'get'], method: 'get'}) + ]); + expect(tree).toEqual({pet: {get: 'getPet'}}); + }); + }); + + describe('renderObjectLiteral', () => { + it('renders a nested literal referencing the internal namespace', () => { + const literal = renderObjectLiteral({ + tree: {pet: {post: 'addPet'}}, + internalName: 'internal_http_client' + }); + expect(literal).toBe( + `{\n pet: {\n post: internal_http_client.addPet\n }\n}` + ); + }); + + it('quotes keys that are not valid identifiers', () => { + const literal = renderObjectLiteral({ + tree: {'bank-accounts': {get: 'getBankAccounts'}}, + internalName: 'internal_http_client' + }); + expect(literal).toContain(`'bank-accounts': {`); + }); + }); + + describe('renderChannelIndex', () => { + const functions: Record = { + http_client: [ + fn('addPet', {tags: ['pet'], pathSegments: ['pet'], method: 'post'}) + ] + }; + + it('flat emits the namespace re-export barrel unchanged', () => { + const result = renderChannelIndex({ + generatedProtocols: ['http_client'], + externalProtocolFunctionInformation: functions, + organization: 'flat', + importExtension: 'none' + }); + expect(result).toBe( + `import * as http_client from './http_client';\n\nexport {http_client};\n` + ); + }); + + it('tag emits a grouped const object literal per protocol', () => { + const result = renderChannelIndex({ + generatedProtocols: ['http_client'], + externalProtocolFunctionInformation: functions, + organization: 'tag', + importExtension: 'none' + }); + expect(result).toContain( + `import * as internal_http_client from './http_client';` + ); + expect(result).toContain('export const http_client = {'); + expect(result).toContain('pet: {'); + expect(result).toContain('addPet: internal_http_client.addPet'); + expect(result).toContain('} as const;'); + }); + + it('returns the empty-protocols placeholder when nothing is generated', () => { + const result = renderChannelIndex({ + generatedProtocols: [], + externalProtocolFunctionInformation: {}, + organization: 'tag', + importExtension: 'none' + }); + expect(result).toBe('// No protocols generated\n'); + }); + }); +}); diff --git a/test/codegen/generators/typescript/channels.spec.ts b/test/codegen/generators/typescript/channels.spec.ts index d116f304..54fe1832 100644 --- a/test/codegen/generators/typescript/channels.spec.ts +++ b/test/codegen/generators/typescript/channels.spec.ts @@ -688,5 +688,131 @@ describe('channels', () => { expect(generatedChannels.protocolFiles['http_client']).toBeUndefined(); }); }); + + describe('organization', () => { + // Shared OpenAPI (openapi-3.json) setup: three `pet`-tagged operations, + // POST /pet -> addPet, PUT /pet -> updatePet, + // GET /pet/findByStatus/{status}/{categoryId} -> findPetsByStatusAndCategory. + const generateOpenApiChannels = async ( + organization: 'flat' | 'tag' | 'path' + ) => { + const parsedOpenAPIDocument = await loadOpenapiDocument( + path.resolve(__dirname, '../../../runtime/openapi-3.json') + ); + const statusProperty = new ConstrainedObjectPropertyModel( + 'status', + 'status', + true, + new ConstrainedStringModel('status', undefined, {}, 'string') + ); + const categoryIdProperty = new ConstrainedObjectPropertyModel( + 'categoryId', + 'categoryId', + true, + new ConstrainedIntegerModel('categoryId', undefined, {}, 'number') + ); + const findPetsByStatusAndCategoryParams = new OutputModel( + '', + new ConstrainedObjectModel('FindPetsByStatusAndCategoryParameters', undefined, {}, 'Parameter', { + status: statusProperty, + categoryId: categoryIdProperty + }), + 'FindPetsByStatusAndCategoryParameters', + {models: {}, originalInput: undefined}, + [] + ); + const parametersDependency: TypeScriptParameterRenderType = { + channelModels: { + findPetsByStatusAndCategory: findPetsByStatusAndCategoryParams + }, + generator: {outputPath: './parameters'} as any, + files: [] + }; + const petPayloadModel = new OutputModel('', new ConstrainedObjectModel('Pet', undefined, {}, 'object', {}), 'Pet', {models: {}, originalInput: undefined}, []); + const petArrayModel = new ConstrainedArrayModel('Pet[]', undefined, {}, 'Pet[]', petPayloadModel.model); + const petArrayPayloadModel = new OutputModel('', petArrayModel, 'FindPetsByStatusAndCategoryResponse', {models: {}, originalInput: undefined}, []); + const payloadsDependency: TypeScriptPayloadRenderType = { + channelModels: {}, + operationModels: { + addPet: {messageModel: petPayloadModel, messageType: 'Pet'}, + updatePet: {messageModel: petPayloadModel, messageType: 'Pet'}, + addPet_Response: {messageModel: petPayloadModel, messageType: 'Pet'}, + updatePet_Response: {messageModel: petPayloadModel, messageType: 'Pet'}, + findPetsByStatusAndCategory_Response: {messageModel: petArrayPayloadModel, messageType: 'Pet[]'} + }, + otherModels: [], + generator: {outputPath: './payloads'} as any, + files: [] + }; + return generateTypeScriptChannels({ + generator: { + ...defaultTypeScriptChannelsGenerator, + outputPath: path.resolve(__dirname, './output'), + id: 'test', + asyncapiGenerateForOperations: true, + protocols: ['http_client'], + organization + }, + inputType: 'openapi', + openapiDocument: parsedOpenAPIDocument, + dependencyOutputs: { + 'parameters-typescript': parametersDependency, + 'payloads-typescript': payloadsDependency, + 'headers-typescript': createHeadersDependency() + } + }); + }; + + it('flat keeps the current namespace barrel unchanged (regression guard)', async () => { + const generated = await generateOpenApiChannels('flat'); + expect(generated.result).toBe( + `import * as http_client from './http_client';\n\nexport {http_client};\n` + ); + }); + + it('tag groups OpenAPI operations under their tag with verbatim leaf names', async () => { + const generated = await generateOpenApiChannels('tag'); + expect(generated.result).toContain( + `import * as internal_http_client from './http_client';` + ); + expect(generated.result).toContain('export const http_client = {'); + expect(generated.result).toContain('pet: {'); + expect(generated.result).toContain('addPet: internal_http_client.addPet'); + expect(generated.result).toContain('updatePet: internal_http_client.updatePet'); + expect(generated.result).toContain( + 'findPetsByStatusAndCategory: internal_http_client.findPetsByStatusAndCategory' + ); + expect(generated.result).toContain('} as const;'); + expect(generated.result).toMatchSnapshot('openapi-tag-index'); + }); + + it('path nests OpenAPI operations by path segment with the HTTP method as the leaf', async () => { + const generated = await generateOpenApiChannels('path'); + expect(generated.result).toContain('export const http_client = {'); + expect(generated.result).toContain('pet: {'); + expect(generated.result).toContain('post: internal_http_client.addPet'); + expect(generated.result).toContain('put: internal_http_client.updatePet'); + expect(generated.result).toContain('findByStatus: {'); + expect(generated.result).toContain( + 'get: internal_http_client.findPetsByStatusAndCategory' + ); + expect(generated.result).toMatchSnapshot('openapi-path-index'); + }); + + it('populates grouping metadata on the rendered functions', async () => { + const generated = await generateOpenApiChannels('tag'); + const functions = generated.renderedFunctions['http_client']; + expect(functions.length).toBeGreaterThan(0); + const addPet = functions.find((fn) => fn.functionName === 'addPet'); + expect(addPet?.tags).toEqual(['pet']); + expect(addPet?.pathSegments).toEqual(['pet']); + expect(addPet?.method).toBe('post'); + const findPets = functions.find( + (fn) => fn.functionName === 'findPetsByStatusAndCategory' + ); + expect(findPets?.pathSegments).toEqual(['pet', 'findByStatus']); + expect(findPets?.method).toBe('get'); + }); + }); }); }); diff --git a/test/runtime/asyncapi-organization.json b/test/runtime/asyncapi-organization.json new file mode 100644 index 00000000..eba1059b --- /dev/null +++ b/test/runtime/asyncapi-organization.json @@ -0,0 +1,145 @@ +{ + "asyncapi": "3.0.0", + "info": { + "title": "Channel organization testing example", + "version": "1.0.0", + "description": "Tagged AsyncAPI v3 document used to exercise the channels generator `organization: 'tag'` option. It has two operations sharing the `user` tag, one `admin`-tagged operation, and one untagged operation (exercises the `untagged` bucket)." + }, + "channels": { + "userSignedup": { + "address": "user/signedup/{id}", + "parameters": { + "id": { + "description": "The id of the user" + } + }, + "messages": { + "UserSignedUp": { + "$ref": "#/components/messages/UserSignedUp" + } + } + }, + "adminAlert": { + "address": "admin/alert", + "messages": { + "AdminAlert": { + "$ref": "#/components/messages/AdminAlert" + } + } + }, + "systemPing": { + "address": "system/ping", + "messages": { + "SystemPing": { + "$ref": "#/components/messages/SystemPing" + } + } + } + }, + "operations": { + "sendUserSignedup": { + "action": "send", + "summary": "Publish a user signup event", + "tags": [{"name": "user"}], + "channel": { + "$ref": "#/channels/userSignedup" + }, + "messages": [ + { + "$ref": "#/channels/userSignedup/messages/UserSignedUp" + } + ] + }, + "receiveUserSignedup": { + "action": "receive", + "summary": "Consume a user signup event", + "tags": [{"name": "user"}], + "channel": { + "$ref": "#/channels/userSignedup" + }, + "messages": [ + { + "$ref": "#/channels/userSignedup/messages/UserSignedUp" + } + ] + }, + "sendAdminAlert": { + "action": "send", + "summary": "Publish an admin alert", + "tags": [{"name": "admin"}], + "channel": { + "$ref": "#/channels/adminAlert" + }, + "messages": [ + { + "$ref": "#/channels/adminAlert/messages/AdminAlert" + } + ] + }, + "sendSystemPing": { + "action": "send", + "summary": "Publish a system ping (intentionally untagged)", + "channel": { + "$ref": "#/channels/systemPing" + }, + "messages": [ + { + "$ref": "#/channels/systemPing/messages/SystemPing" + } + ] + } + }, + "components": { + "messages": { + "UserSignedUp": { + "payload": { + "$ref": "#/components/schemas/UserSignedUp" + } + }, + "AdminAlert": { + "payload": { + "$ref": "#/components/schemas/AdminAlert" + } + }, + "SystemPing": { + "payload": { + "$ref": "#/components/schemas/SystemPing" + } + } + }, + "schemas": { + "UserSignedUp": { + "type": "object", + "additionalProperties": false, + "properties": { + "displayName": { + "type": "string" + }, + "email": { + "type": "string", + "format": "email" + } + } + }, + "AdminAlert": { + "type": "object", + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + } + }, + "SystemPing": { + "type": "object", + "additionalProperties": false, + "properties": { + "timestamp": { + "type": "string", + "format": "date-time" + } + } + } + } + } +} diff --git a/test/runtime/typescript/codegen-asyncapi-path.mjs b/test/runtime/typescript/codegen-asyncapi-path.mjs new file mode 100644 index 00000000..502ecbdd --- /dev/null +++ b/test/runtime/typescript/codegen-asyncapi-path.mjs @@ -0,0 +1,14 @@ +/** @type {import("../../../dist").TheCodegenConfiguration} **/ +export default { + inputType: 'asyncapi', + inputPath: '../asyncapi-regular.json', + language: 'typescript', + generators: [ + { + preset: 'channels', + outputPath: './src/asyncapi-path-organization/channels', + protocols: ['nats'], + organization: 'path' + } + ] +}; diff --git a/test/runtime/typescript/codegen-asyncapi-tag.mjs b/test/runtime/typescript/codegen-asyncapi-tag.mjs new file mode 100644 index 00000000..8db8615f --- /dev/null +++ b/test/runtime/typescript/codegen-asyncapi-tag.mjs @@ -0,0 +1,14 @@ +/** @type {import("../../../dist").TheCodegenConfiguration} **/ +export default { + inputType: 'asyncapi', + inputPath: '../asyncapi-organization.json', + language: 'typescript', + generators: [ + { + preset: 'channels', + outputPath: './src/asyncapi-tag-organization/channels', + protocols: ['nats'], + organization: 'tag' + } + ] +}; diff --git a/test/runtime/typescript/codegen-openapi-path.mjs b/test/runtime/typescript/codegen-openapi-path.mjs new file mode 100644 index 00000000..06d15b40 --- /dev/null +++ b/test/runtime/typescript/codegen-openapi-path.mjs @@ -0,0 +1,14 @@ +/** @type {import("../../../dist").TheCodegenConfiguration} **/ +export default { + inputType: 'openapi', + inputPath: '../openapi-3.json', + language: 'typescript', + generators: [ + { + preset: 'channels', + outputPath: './src/openapi-path-organization/channels', + protocols: ['http_client'], + organization: 'path' + } + ] +}; diff --git a/test/runtime/typescript/codegen-openapi-tag.mjs b/test/runtime/typescript/codegen-openapi-tag.mjs new file mode 100644 index 00000000..5aa6fdf3 --- /dev/null +++ b/test/runtime/typescript/codegen-openapi-tag.mjs @@ -0,0 +1,14 @@ +/** @type {import("../../../dist").TheCodegenConfiguration} **/ +export default { + inputType: 'openapi', + inputPath: '../openapi-3.json', + language: 'typescript', + generators: [ + { + preset: 'channels', + outputPath: './src/openapi-tag-organization/channels', + protocols: ['http_client'], + organization: 'tag' + } + ] +}; diff --git a/test/runtime/typescript/package.json b/test/runtime/typescript/package.json index 891721c4..0a1f41ef 100644 --- a/test/runtime/typescript/package.json +++ b/test/runtime/typescript/package.json @@ -1,6 +1,7 @@ { "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", + "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", + "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:kafka": "jest -- ./test/channels/regular/kafka.spec.ts", "test:nats": "npm run test:nats:client && npm run test:nats:channels", @@ -12,7 +13,12 @@ "test:websocket": "jest -- ./test/channels/regular/websocket.spec.ts", "test:http": "jest -- ./test/channels/request_reply/http_client/", "test:payload-types": "jest -- ./test/payload-types/", - "generate": "npm run generate:regular && npm run generate:request:reply && npm run generate:openapi && npm run generate:openapi-primitive && npm run generate:payload-types", + "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", + "generate:organization": "npm run generate:organization:openapi-tag && npm run generate:organization:openapi-path && npm run generate:organization:asyncapi-tag && npm run generate:organization:asyncapi-path", + "generate:organization:openapi-tag": "cross-env CODEGEN_TELEMETRY_DISABLED=1 node ../../../bin/run.mjs generate ./codegen-openapi-tag.mjs", + "generate:organization:openapi-path": "cross-env CODEGEN_TELEMETRY_DISABLED=1 node ../../../bin/run.mjs generate ./codegen-openapi-path.mjs", + "generate:organization:asyncapi-tag": "cross-env CODEGEN_TELEMETRY_DISABLED=1 node ../../../bin/run.mjs generate ./codegen-asyncapi-tag.mjs", + "generate:organization:asyncapi-path": "cross-env CODEGEN_TELEMETRY_DISABLED=1 node ../../../bin/run.mjs generate ./codegen-asyncapi-path.mjs", "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", diff --git a/test/runtime/typescript/src/asyncapi-path-organization/channels/headers/UserSignedUpHeaders.ts b/test/runtime/typescript/src/asyncapi-path-organization/channels/headers/UserSignedUpHeaders.ts new file mode 100644 index 00000000..e15ff250 --- /dev/null +++ b/test/runtime/typescript/src/asyncapi-path-organization/channels/headers/UserSignedUpHeaders.ts @@ -0,0 +1,77 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +class UserSignedUpHeaders { + private _xTestHeader?: string; + private _additionalProperties?: Map; + + constructor(input: { + xTestHeader?: string, + additionalProperties?: Map, + }) { + this._xTestHeader = input.xTestHeader; + this._additionalProperties = input.additionalProperties; + } + + /** + * Test header + */ + get xTestHeader(): string | undefined { return this._xTestHeader; } + set xTestHeader(xTestHeader: string | undefined) { this._xTestHeader = xTestHeader; } + + get additionalProperties(): Map | undefined { return this._additionalProperties; } + set additionalProperties(additionalProperties: Map | undefined) { this._additionalProperties = additionalProperties; } + + public marshal() : string { + let json = '{' + if(this.xTestHeader !== undefined) { + json += `"x-test-header": ${typeof this.xTestHeader === 'number' || typeof this.xTestHeader === 'boolean' ? this.xTestHeader : JSON.stringify(this.xTestHeader)},`; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of this.additionalProperties.entries()) { + //Only unwrap those that are not already a property in the JSON object + if(["x-test-header","additionalProperties"].includes(String(key))) continue; + json += `"${key}": ${typeof value === 'number' || typeof value === 'boolean' ? value : JSON.stringify(value)},`; + } + } + //Remove potential last comma + return `${json.charAt(json.length-1) === ',' ? json.slice(0, json.length-1) : json}}`; + } + + public static unmarshal(json: string | object): UserSignedUpHeaders { + const obj = typeof json === "object" ? json : JSON.parse(json); + const instance = new UserSignedUpHeaders({} as any); + + if (obj["x-test-header"] !== undefined) { + instance.xTestHeader = obj["x-test-header"]; + } + + instance.additionalProperties = new Map(); + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["x-test-header","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties.set(key, value as any); + } + return instance; + } + public static theCodeGenSchema = {"type":"object","properties":{"x-test-header":{"type":"string","description":"Test header"}},"$id":"UserSignedUpHeaders","$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 ?? {})}; + addFormats(ajvInstance); + + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { UserSignedUpHeaders }; \ No newline at end of file diff --git a/test/runtime/typescript/src/asyncapi-path-organization/channels/index.ts b/test/runtime/typescript/src/asyncapi-path-organization/channels/index.ts new file mode 100644 index 00000000..8e0c07f2 --- /dev/null +++ b/test/runtime/typescript/src/asyncapi-path-organization/channels/index.ts @@ -0,0 +1,56 @@ +import * as internal_nats from './nats'; + +export const nats = { + user: { + signedup: { + publish: internal_nats.publishToSendUserSignedup, + jetStreamPublish: internal_nats.jetStreamPublishToSendUserSignedup, + subscribe: internal_nats.subscribeToReceiveUserSignedup, + jetStreamPullSubscribe: internal_nats.jetStreamPullSubscribeToReceiveUserSignedup, + jetStreamPushSubscribe: internal_nats.jetStreamPushSubscriptionFromReceiveUserSignedup + } + }, + noparameters: { + publish: internal_nats.publishToNoParameter, + subscribe: internal_nats.subscribeToNoParameter, + jetStreamPullSubscribe: internal_nats.jetStreamPullSubscribeToNoParameter, + jetStreamPushSubscribe: internal_nats.jetStreamPushSubscriptionFromNoParameter, + jetStreamPublish: internal_nats.jetStreamPublishToNoParameter + }, + string: { + payload: { + publish: internal_nats.publishToSendStringPayload, + jetStreamPublish: internal_nats.jetStreamPublishToSendStringPayload, + subscribe: internal_nats.subscribeToReceiveStringPayload, + jetStreamPullSubscribe: internal_nats.jetStreamPullSubscribeToReceiveStringPayload, + jetStreamPushSubscribe: internal_nats.jetStreamPushSubscriptionFromReceiveStringPayload + } + }, + array: { + payload: { + publish: internal_nats.publishToSendArrayPayload, + jetStreamPublish: internal_nats.jetStreamPublishToSendArrayPayload, + subscribe: internal_nats.subscribeToReceiveArrayPayload, + jetStreamPullSubscribe: internal_nats.jetStreamPullSubscribeToReceiveArrayPayload, + jetStreamPushSubscribe: internal_nats.jetStreamPushSubscriptionFromReceiveArrayPayload + } + }, + union: { + payload: { + publish: internal_nats.publishToSendUnionPayload, + jetStreamPublish: internal_nats.jetStreamPublishToSendUnionPayload, + subscribe: internal_nats.subscribeToReceiveUnionPayload, + jetStreamPullSubscribe: internal_nats.jetStreamPullSubscribeToReceiveUnionPayload, + jetStreamPushSubscribe: internal_nats.jetStreamPushSubscriptionFromReceiveUnionPayload + } + }, + legacy: { + notification: { + publish: internal_nats.publishToSendLegacyNotification, + jetStreamPublish: internal_nats.jetStreamPublishToSendLegacyNotification, + subscribe: internal_nats.subscribeToReceiveLegacyNotification, + jetStreamPullSubscribe: internal_nats.jetStreamPullSubscribeToReceiveLegacyNotification, + jetStreamPushSubscribe: internal_nats.jetStreamPushSubscriptionFromReceiveLegacyNotification + } + } +} as const; diff --git a/test/runtime/typescript/src/asyncapi-path-organization/channels/nats.ts b/test/runtime/typescript/src/asyncapi-path-organization/channels/nats.ts new file mode 100644 index 00000000..620d6923 --- /dev/null +++ b/test/runtime/typescript/src/asyncapi-path-organization/channels/nats.ts @@ -0,0 +1,1584 @@ +import {UserSignedUp} from './payload/UserSignedUp'; +import * as StringMessageModule from './payload/StringMessage'; +import * as ArrayMessageModule from './payload/ArrayMessage'; +import * as UnionMessageModule from './payload/UnionMessage'; +import {LegacyNotification} from './payload/LegacyNotification'; +import {UnionPayloadOneOfOption2} from './payload/UnionPayloadOneOfOption2'; +import {LegacyNotificationPayloadLevelEnum} from './payload/LegacyNotificationPayloadLevelEnum'; +import {UserSignedupParameters} from './parameter/UserSignedupParameters'; +import {UserSignedUpHeaders} from './headers/UserSignedUpHeaders'; +import * as Nats from 'nats'; + +/** + * Publishes a user signup event to notify other services that a new user has registered in the system. + * + * @param message to publish + * @param parameters for topic substitution + * @param headers optional headers to include with the message + * @param nc the NATS client to publish from + * @param codec the serialization codec to use while transmitting the message + * @param options to use while publishing the message + */ +function publishToSendUserSignedup({ + message, + parameters, + headers, + nc, + codec = Nats.JSONCodec(), + options +}: { + message: UserSignedUp, + parameters: UserSignedupParameters, + headers?: UserSignedUpHeaders, + nc: Nats.NatsConnection, + codec?: Nats.Codec, + options?: Nats.PublishOptions +}): Promise { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = message.marshal(); + // Set up headers if provided + if (headers) { + const natsHeaders = Nats.headers(); + const headerData = headers.marshal(); + const parsedHeaders = typeof headerData === 'string' ? JSON.parse(headerData) : headerData; + for (const [key, value] of Object.entries(parsedHeaders)) { + if (value !== undefined) { + natsHeaders.append(key, String(value)); + } + } + options = { ...options, headers: natsHeaders }; + } +dataToSend = codec.encode(dataToSend); +nc.publish(parameters.getChannelWithParameters('user.signedup.{my_parameter}.{enum_parameter}'), dataToSend, options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * Publishes a user signup event to notify other services that a new user has registered in the system. + * + * @param message to publish over jetstream + * @param parameters for topic substitution + * @param headers optional headers to include with the message + * @param js the JetStream client to publish from + * @param codec the serialization codec to use while transmitting the message + * @param options to use while publishing the message + */ +function jetStreamPublishToSendUserSignedup({ + message, + parameters, + headers, + js, + codec = Nats.JSONCodec(), + options = {} +}: { + message: UserSignedUp, + parameters: UserSignedupParameters, + headers?: UserSignedUpHeaders, + js: Nats.JetStreamClient, + codec?: Nats.Codec, + options?: Partial +}): Promise { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = message.marshal(); + // Set up headers if provided + if (headers) { + const natsHeaders = Nats.headers(); + const headerData = headers.marshal(); + const parsedHeaders = typeof headerData === 'string' ? JSON.parse(headerData) : headerData; + for (const [key, value] of Object.entries(parsedHeaders)) { + if (value !== undefined) { + natsHeaders.append(key, String(value)); + } + } + options = { ...options, headers: natsHeaders }; + } +dataToSend = codec.encode(dataToSend); +await js.publish(parameters.getChannelWithParameters('user.signedup.{my_parameter}.{enum_parameter}'), dataToSend, options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * Callback for when receiving messages + * + * @callback subscribeToReceiveUserSignedupCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param parameters that was received in the topic + * @param headers that were received with the message + * @param natsMsg + */ + +/** + * Receives user signup events to process new user registrations. + * + * @param {subscribeToReceiveUserSignedupCallback} onDataCallback to call when messages are received + * @param parameters for topic substitution + * @param nc the nats client to setup the subscribe for + * @param codec the serialization codec to use while receiving the message + * @param options when setting up the subscription + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +function subscribeToReceiveUserSignedup({ + onDataCallback, + parameters, + nc, + codec = Nats.JSONCodec(), + options, + skipMessageValidation = false +}: { + onDataCallback: (err?: Error, msg?: UserSignedUp, parameters?: UserSignedupParameters, headers?: UserSignedUpHeaders, natsMsg?: Nats.Msg) => void, + parameters: UserSignedupParameters, + nc: Nats.NatsConnection, + codec?: Nats.Codec, + options?: Nats.SubscriptionOptions, + skipMessageValidation?: boolean +}): Promise { + return new Promise(async (resolve, reject) => { + try { + const subscription = nc.subscribe(parameters.getChannelWithParameters('user.signedup.{my_parameter}.{enum_parameter}'), options); + const validator = UserSignedUp.createValidator(); + (async () => { + for await (const msg of subscription) { + const parameters = UserSignedupParameters.createFromChannel(msg.subject, 'user.signedup.{my_parameter}.{enum_parameter}', /^user.signedup.([^.]*).([^.]*)$/) + let receivedData: any = codec.decode(msg.data); +// Extract headers if present + let extractedHeaders: UserSignedUpHeaders | undefined = undefined; + if (msg.headers) { + const headerObj: Record = {}; + // NATS headers support both iteration and get() method + if (typeof msg.headers.keys === 'function') { + // Use keys() method if available (NATS MsgHdrs) + for (const key of msg.headers.keys()) { + headerObj[key] = msg.headers.get(key); + } + } else { + // Fallback to Object.entries for plain objects + for (const [key, value] of Object.entries(msg.headers)) { + headerObj[key] = value; + } + } + extractedHeaders = UserSignedUpHeaders.unmarshal(headerObj); + } +if(!skipMessageValidation) { + const {valid, errors} = UserSignedUp.validate({data: receivedData, ajvValidatorFunction: validator}); + if(!valid) { + onDataCallback(new Error(`Invalid message payload received; ${JSON.stringify({cause: errors})}`), undefined,parameters, extractedHeaders, msg); continue; + } + } +onDataCallback(undefined, UserSignedUp.unmarshal(receivedData), parameters, extractedHeaders, msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * Callback for when receiving messages + * + * @callback jetStreamPullSubscribeToReceiveUserSignedupCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param parameters that was received in the topic + * @param headers that was received with the message + * @param jetstreamMsg + */ + +/** + * Receives user signup events to process new user registrations. + * + * @param {jetStreamPullSubscribeToReceiveUserSignedupCallback} onDataCallback to call when messages are received + * @param parameters for topic substitution + * @param js the JetStream client to pull subscribe through + * @param options when setting up the subscription + * @param codec the serialization codec to use while transmitting the message + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +function jetStreamPullSubscribeToReceiveUserSignedup({ + onDataCallback, + parameters, + js, + options, + codec = Nats.JSONCodec(), + skipMessageValidation = false +}: { + onDataCallback: (err?: Error, msg?: UserSignedUp, parameters?: UserSignedupParameters, headers?: UserSignedUpHeaders, jetstreamMsg?: Nats.JsMsg) => void, + parameters: UserSignedupParameters, + js: Nats.JetStreamClient, + options: Nats.ConsumerOptsBuilder | Partial, + codec?: Nats.Codec, + skipMessageValidation?: boolean +}): Promise { + return new Promise(async (resolve, reject) => { + try { + const subscription = await js.pullSubscribe(parameters.getChannelWithParameters('user.signedup.{my_parameter}.{enum_parameter}'), options); + const validator = UserSignedUp.createValidator(); + (async () => { + for await (const msg of subscription) { + const parameters = UserSignedupParameters.createFromChannel(msg.subject, 'user.signedup.{my_parameter}.{enum_parameter}', /^user.signedup.([^.]*).([^.]*)$/) + let receivedData: any = codec.decode(msg.data); +// Extract headers if present + let extractedHeaders: UserSignedUpHeaders | undefined = undefined; + if (msg.headers) { + const headerObj: Record = {}; + // NATS headers support both iteration and get() method + if (typeof msg.headers.keys === 'function') { + // Use keys() method if available (NATS MsgHdrs) + for (const key of msg.headers.keys()) { + headerObj[key] = msg.headers.get(key); + } + } else { + // Fallback to Object.entries for plain objects + for (const [key, value] of Object.entries(msg.headers)) { + headerObj[key] = value; + } + } + extractedHeaders = UserSignedUpHeaders.unmarshal(headerObj); + } +if(!skipMessageValidation) { + const {valid, errors} = UserSignedUp.validate({data: receivedData, ajvValidatorFunction: validator}); + if(!valid) { + onDataCallback(new Error(`Invalid message payload received; ${JSON.stringify({cause: errors})}`), undefined,parameters, extractedHeaders, msg); continue; + } + } +onDataCallback(undefined, UserSignedUp.unmarshal(receivedData), parameters, extractedHeaders, msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * Callback for when receiving messages + * + * @callback jetStreamPushSubscriptionFromReceiveUserSignedupCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param parameters that was received in the topic + * @param headers that was received with the message + * @param jetstreamMsg + */ + +/** + * Receives user signup events to process new user registrations. + * + * @param {jetStreamPushSubscriptionFromReceiveUserSignedupCallback} onDataCallback to call when messages are received + * @param parameters for topic substitution + * @param js the JetStream client to pull subscribe through + * @param options when setting up the subscription + * @param codec the serialization codec to use while transmitting the message + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +function jetStreamPushSubscriptionFromReceiveUserSignedup({ + onDataCallback, + parameters, + js, + options, + codec = Nats.JSONCodec(), + skipMessageValidation = false +}: { + onDataCallback: (err?: Error, msg?: UserSignedUp, parameters?: UserSignedupParameters, headers?: UserSignedUpHeaders, jetstreamMsg?: Nats.JsMsg) => void, + parameters: UserSignedupParameters, + js: Nats.JetStreamClient, + options: Nats.ConsumerOptsBuilder | Partial, + codec?: Nats.Codec, + skipMessageValidation?: boolean +}): Promise { + return new Promise(async (resolve, reject) => { + try { + const subscription = await js.subscribe(parameters.getChannelWithParameters('user.signedup.{my_parameter}.{enum_parameter}'), options); + const validator = UserSignedUp.createValidator(); + (async () => { + for await (const msg of subscription) { + const parameters = UserSignedupParameters.createFromChannel(msg.subject, 'user.signedup.{my_parameter}.{enum_parameter}', /^user.signedup.([^.]*).([^.]*)$/) + let receivedData: any = codec.decode(msg.data); +// Extract headers if present + let extractedHeaders: UserSignedUpHeaders | undefined = undefined; + if (msg.headers) { + const headerObj: Record = {}; + // NATS headers support both iteration and get() method + if (typeof msg.headers.keys === 'function') { + // Use keys() method if available (NATS MsgHdrs) + for (const key of msg.headers.keys()) { + headerObj[key] = msg.headers.get(key); + } + } else { + // Fallback to Object.entries for plain objects + for (const [key, value] of Object.entries(msg.headers)) { + headerObj[key] = value; + } + } + extractedHeaders = UserSignedUpHeaders.unmarshal(headerObj); + } +if(!skipMessageValidation) { + const {valid, errors} = UserSignedUp.validate({data: receivedData, ajvValidatorFunction: validator}); + if(!valid) { + onDataCallback(new Error(`Invalid message payload received; ${JSON.stringify({cause: errors})}`), undefined,parameters, extractedHeaders, msg); continue; + } + } +onDataCallback(undefined, UserSignedUp.unmarshal(receivedData), parameters, extractedHeaders, msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * NATS publish operation for `noparameters` + * + * @param message to publish + * @param headers optional headers to include with the message + * @param nc the NATS client to publish from + * @param codec the serialization codec to use while transmitting the message + * @param options to use while publishing the message + */ +function publishToNoParameter({ + message, + headers, + nc, + codec = Nats.JSONCodec(), + options +}: { + message: UserSignedUp, + headers?: UserSignedUpHeaders, + nc: Nats.NatsConnection, + codec?: Nats.Codec, + options?: Nats.PublishOptions +}): Promise { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = message.marshal(); + // Set up headers if provided + if (headers) { + const natsHeaders = Nats.headers(); + const headerData = headers.marshal(); + const parsedHeaders = typeof headerData === 'string' ? JSON.parse(headerData) : headerData; + for (const [key, value] of Object.entries(parsedHeaders)) { + if (value !== undefined) { + natsHeaders.append(key, String(value)); + } + } + options = { ...options, headers: natsHeaders }; + } +dataToSend = codec.encode(dataToSend); +nc.publish('noparameters', dataToSend, options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * Callback for when receiving messages + * + * @callback subscribeToNoParameterCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param headers that were received with the message + * @param natsMsg + */ + +/** + * Core subscription for `noparameters` + * + * @param {subscribeToNoParameterCallback} onDataCallback to call when messages are received + * @param nc the nats client to setup the subscribe for + * @param codec the serialization codec to use while receiving the message + * @param options when setting up the subscription + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +function subscribeToNoParameter({ + onDataCallback, + nc, + codec = Nats.JSONCodec(), + options, + skipMessageValidation = false +}: { + onDataCallback: (err?: Error, msg?: UserSignedUp, headers?: UserSignedUpHeaders, natsMsg?: Nats.Msg) => void, + nc: Nats.NatsConnection, + codec?: Nats.Codec, + options?: Nats.SubscriptionOptions, + skipMessageValidation?: boolean +}): Promise { + return new Promise(async (resolve, reject) => { + try { + const subscription = nc.subscribe('noparameters', options); + const validator = UserSignedUp.createValidator(); + (async () => { + for await (const msg of subscription) { + + let receivedData: any = codec.decode(msg.data); +// Extract headers if present + let extractedHeaders: UserSignedUpHeaders | undefined = undefined; + if (msg.headers) { + const headerObj: Record = {}; + // NATS headers support both iteration and get() method + if (typeof msg.headers.keys === 'function') { + // Use keys() method if available (NATS MsgHdrs) + for (const key of msg.headers.keys()) { + headerObj[key] = msg.headers.get(key); + } + } else { + // Fallback to Object.entries for plain objects + for (const [key, value] of Object.entries(msg.headers)) { + headerObj[key] = value; + } + } + extractedHeaders = UserSignedUpHeaders.unmarshal(headerObj); + } +if(!skipMessageValidation) { + const {valid, errors} = UserSignedUp.validate({data: receivedData, ajvValidatorFunction: validator}); + if(!valid) { + onDataCallback(new Error(`Invalid message payload received; ${JSON.stringify({cause: errors})}`), undefined,extractedHeaders, msg); continue; + } + } +onDataCallback(undefined, UserSignedUp.unmarshal(receivedData), extractedHeaders, msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * Callback for when receiving messages + * + * @callback jetStreamPullSubscribeToNoParameterCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param headers that was received with the message + * @param jetstreamMsg + */ + +/** + * JetStream pull subscription for `noparameters` + * + * @param {jetStreamPullSubscribeToNoParameterCallback} onDataCallback to call when messages are received + * @param js the JetStream client to pull subscribe through + * @param options when setting up the subscription + * @param codec the serialization codec to use while transmitting the message + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +function jetStreamPullSubscribeToNoParameter({ + onDataCallback, + js, + options, + codec = Nats.JSONCodec(), + skipMessageValidation = false +}: { + onDataCallback: (err?: Error, msg?: UserSignedUp, headers?: UserSignedUpHeaders, jetstreamMsg?: Nats.JsMsg) => void, + js: Nats.JetStreamClient, + options: Nats.ConsumerOptsBuilder | Partial, + codec?: Nats.Codec, + skipMessageValidation?: boolean +}): Promise { + return new Promise(async (resolve, reject) => { + try { + const subscription = await js.pullSubscribe('noparameters', options); + const validator = UserSignedUp.createValidator(); + (async () => { + for await (const msg of subscription) { + + let receivedData: any = codec.decode(msg.data); +// Extract headers if present + let extractedHeaders: UserSignedUpHeaders | undefined = undefined; + if (msg.headers) { + const headerObj: Record = {}; + // NATS headers support both iteration and get() method + if (typeof msg.headers.keys === 'function') { + // Use keys() method if available (NATS MsgHdrs) + for (const key of msg.headers.keys()) { + headerObj[key] = msg.headers.get(key); + } + } else { + // Fallback to Object.entries for plain objects + for (const [key, value] of Object.entries(msg.headers)) { + headerObj[key] = value; + } + } + extractedHeaders = UserSignedUpHeaders.unmarshal(headerObj); + } +if(!skipMessageValidation) { + const {valid, errors} = UserSignedUp.validate({data: receivedData, ajvValidatorFunction: validator}); + if(!valid) { + onDataCallback(new Error(`Invalid message payload received; ${JSON.stringify({cause: errors})}`), undefined,extractedHeaders, msg); continue; + } + } +onDataCallback(undefined, UserSignedUp.unmarshal(receivedData), extractedHeaders, msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * Callback for when receiving messages + * + * @callback jetStreamPushSubscriptionFromNoParameterCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param headers that was received with the message + * @param jetstreamMsg + */ + +/** + * JetStream push subscription for `noparameters` + * + * @param {jetStreamPushSubscriptionFromNoParameterCallback} onDataCallback to call when messages are received + * @param js the JetStream client to pull subscribe through + * @param options when setting up the subscription + * @param codec the serialization codec to use while transmitting the message + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +function jetStreamPushSubscriptionFromNoParameter({ + onDataCallback, + js, + options, + codec = Nats.JSONCodec(), + skipMessageValidation = false +}: { + onDataCallback: (err?: Error, msg?: UserSignedUp, headers?: UserSignedUpHeaders, jetstreamMsg?: Nats.JsMsg) => void, + js: Nats.JetStreamClient, + options: Nats.ConsumerOptsBuilder | Partial, + codec?: Nats.Codec, + skipMessageValidation?: boolean +}): Promise { + return new Promise(async (resolve, reject) => { + try { + const subscription = await js.subscribe('noparameters', options); + const validator = UserSignedUp.createValidator(); + (async () => { + for await (const msg of subscription) { + + let receivedData: any = codec.decode(msg.data); +// Extract headers if present + let extractedHeaders: UserSignedUpHeaders | undefined = undefined; + if (msg.headers) { + const headerObj: Record = {}; + // NATS headers support both iteration and get() method + if (typeof msg.headers.keys === 'function') { + // Use keys() method if available (NATS MsgHdrs) + for (const key of msg.headers.keys()) { + headerObj[key] = msg.headers.get(key); + } + } else { + // Fallback to Object.entries for plain objects + for (const [key, value] of Object.entries(msg.headers)) { + headerObj[key] = value; + } + } + extractedHeaders = UserSignedUpHeaders.unmarshal(headerObj); + } +if(!skipMessageValidation) { + const {valid, errors} = UserSignedUp.validate({data: receivedData, ajvValidatorFunction: validator}); + if(!valid) { + onDataCallback(new Error(`Invalid message payload received; ${JSON.stringify({cause: errors})}`), undefined,extractedHeaders, msg); continue; + } + } +onDataCallback(undefined, UserSignedUp.unmarshal(receivedData), extractedHeaders, msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * JetStream publish operation for `noparameters` + * + * @param message to publish over jetstream + * @param headers optional headers to include with the message + * @param js the JetStream client to publish from + * @param codec the serialization codec to use while transmitting the message + * @param options to use while publishing the message + */ +function jetStreamPublishToNoParameter({ + message, + headers, + js, + codec = Nats.JSONCodec(), + options = {} +}: { + message: UserSignedUp, + headers?: UserSignedUpHeaders, + js: Nats.JetStreamClient, + codec?: Nats.Codec, + options?: Partial +}): Promise { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = message.marshal(); + // Set up headers if provided + if (headers) { + const natsHeaders = Nats.headers(); + const headerData = headers.marshal(); + const parsedHeaders = typeof headerData === 'string' ? JSON.parse(headerData) : headerData; + for (const [key, value] of Object.entries(parsedHeaders)) { + if (value !== undefined) { + natsHeaders.append(key, String(value)); + } + } + options = { ...options, headers: natsHeaders }; + } +dataToSend = codec.encode(dataToSend); +await js.publish('noparameters', dataToSend, options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * NATS publish operation for `string.payload` + * + * @param message to publish + * @param nc the NATS client to publish from + * @param codec the serialization codec to use while transmitting the message + * @param options to use while publishing the message + */ +function publishToSendStringPayload({ + message, + nc, + codec = Nats.JSONCodec(), + options +}: { + message: StringMessageModule.StringMessage, + nc: Nats.NatsConnection, + codec?: Nats.Codec, + options?: Nats.PublishOptions +}): Promise { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = StringMessageModule.marshal(message); + +dataToSend = codec.encode(dataToSend); +nc.publish('string.payload', dataToSend, options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * JetStream publish operation for `string.payload` + * + * @param message to publish over jetstream + * @param js the JetStream client to publish from + * @param codec the serialization codec to use while transmitting the message + * @param options to use while publishing the message + */ +function jetStreamPublishToSendStringPayload({ + message, + js, + codec = Nats.JSONCodec(), + options = {} +}: { + message: StringMessageModule.StringMessage, + js: Nats.JetStreamClient, + codec?: Nats.Codec, + options?: Partial +}): Promise { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = StringMessageModule.marshal(message); + +dataToSend = codec.encode(dataToSend); +await js.publish('string.payload', dataToSend, options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * Callback for when receiving messages + * + * @callback subscribeToReceiveStringPayloadCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param natsMsg + */ + +/** + * Core subscription for `string.payload` + * + * @param {subscribeToReceiveStringPayloadCallback} onDataCallback to call when messages are received + * @param nc the nats client to setup the subscribe for + * @param codec the serialization codec to use while receiving the message + * @param options when setting up the subscription + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +function subscribeToReceiveStringPayload({ + onDataCallback, + nc, + codec = Nats.JSONCodec(), + options, + skipMessageValidation = false +}: { + onDataCallback: (err?: Error, msg?: StringMessageModule.StringMessage, natsMsg?: Nats.Msg) => void, + nc: Nats.NatsConnection, + codec?: Nats.Codec, + options?: Nats.SubscriptionOptions, + skipMessageValidation?: boolean +}): Promise { + return new Promise(async (resolve, reject) => { + try { + const subscription = nc.subscribe('string.payload', options); + const validator = StringMessageModule.createValidator(); + (async () => { + for await (const msg of subscription) { + + let receivedData: any = codec.decode(msg.data); +if(!skipMessageValidation) { + const {valid, errors} = StringMessageModule.validate({data: receivedData, ajvValidatorFunction: validator}); + if(!valid) { + onDataCallback(new Error(`Invalid message payload received; ${JSON.stringify({cause: errors})}`), undefined, msg); continue; + } + } +onDataCallback(undefined, StringMessageModule.unmarshal(receivedData), msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * Callback for when receiving messages + * + * @callback jetStreamPullSubscribeToReceiveStringPayloadCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param jetstreamMsg + */ + +/** + * JetStream pull subscription for `string.payload` + * + * @param {jetStreamPullSubscribeToReceiveStringPayloadCallback} onDataCallback to call when messages are received + * @param js the JetStream client to pull subscribe through + * @param options when setting up the subscription + * @param codec the serialization codec to use while transmitting the message + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +function jetStreamPullSubscribeToReceiveStringPayload({ + onDataCallback, + js, + options, + codec = Nats.JSONCodec(), + skipMessageValidation = false +}: { + onDataCallback: (err?: Error, msg?: StringMessageModule.StringMessage, jetstreamMsg?: Nats.JsMsg) => void, + js: Nats.JetStreamClient, + options: Nats.ConsumerOptsBuilder | Partial, + codec?: Nats.Codec, + skipMessageValidation?: boolean +}): Promise { + return new Promise(async (resolve, reject) => { + try { + const subscription = await js.pullSubscribe('string.payload', options); + const validator = StringMessageModule.createValidator(); + (async () => { + for await (const msg of subscription) { + + let receivedData: any = codec.decode(msg.data); +if(!skipMessageValidation) { + const {valid, errors} = StringMessageModule.validate({data: receivedData, ajvValidatorFunction: validator}); + if(!valid) { + onDataCallback(new Error(`Invalid message payload received; ${JSON.stringify({cause: errors})}`), undefined, msg); continue; + } + } +onDataCallback(undefined, StringMessageModule.unmarshal(receivedData), msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * Callback for when receiving messages + * + * @callback jetStreamPushSubscriptionFromReceiveStringPayloadCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param jetstreamMsg + */ + +/** + * JetStream push subscription for `string.payload` + * + * @param {jetStreamPushSubscriptionFromReceiveStringPayloadCallback} onDataCallback to call when messages are received + * @param js the JetStream client to pull subscribe through + * @param options when setting up the subscription + * @param codec the serialization codec to use while transmitting the message + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +function jetStreamPushSubscriptionFromReceiveStringPayload({ + onDataCallback, + js, + options, + codec = Nats.JSONCodec(), + skipMessageValidation = false +}: { + onDataCallback: (err?: Error, msg?: StringMessageModule.StringMessage, jetstreamMsg?: Nats.JsMsg) => void, + js: Nats.JetStreamClient, + options: Nats.ConsumerOptsBuilder | Partial, + codec?: Nats.Codec, + skipMessageValidation?: boolean +}): Promise { + return new Promise(async (resolve, reject) => { + try { + const subscription = await js.subscribe('string.payload', options); + const validator = StringMessageModule.createValidator(); + (async () => { + for await (const msg of subscription) { + + let receivedData: any = codec.decode(msg.data); +if(!skipMessageValidation) { + const {valid, errors} = StringMessageModule.validate({data: receivedData, ajvValidatorFunction: validator}); + if(!valid) { + onDataCallback(new Error(`Invalid message payload received; ${JSON.stringify({cause: errors})}`), undefined, msg); continue; + } + } +onDataCallback(undefined, StringMessageModule.unmarshal(receivedData), msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * NATS publish operation for `array.payload` + * + * @param message to publish + * @param nc the NATS client to publish from + * @param codec the serialization codec to use while transmitting the message + * @param options to use while publishing the message + */ +function publishToSendArrayPayload({ + message, + nc, + codec = Nats.JSONCodec(), + options +}: { + message: ArrayMessageModule.ArrayMessage, + nc: Nats.NatsConnection, + codec?: Nats.Codec, + options?: Nats.PublishOptions +}): Promise { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = ArrayMessageModule.marshal(message); + +dataToSend = codec.encode(dataToSend); +nc.publish('array.payload', dataToSend, options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * JetStream publish operation for `array.payload` + * + * @param message to publish over jetstream + * @param js the JetStream client to publish from + * @param codec the serialization codec to use while transmitting the message + * @param options to use while publishing the message + */ +function jetStreamPublishToSendArrayPayload({ + message, + js, + codec = Nats.JSONCodec(), + options = {} +}: { + message: ArrayMessageModule.ArrayMessage, + js: Nats.JetStreamClient, + codec?: Nats.Codec, + options?: Partial +}): Promise { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = ArrayMessageModule.marshal(message); + +dataToSend = codec.encode(dataToSend); +await js.publish('array.payload', dataToSend, options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * Callback for when receiving messages + * + * @callback subscribeToReceiveArrayPayloadCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param natsMsg + */ + +/** + * Core subscription for `array.payload` + * + * @param {subscribeToReceiveArrayPayloadCallback} onDataCallback to call when messages are received + * @param nc the nats client to setup the subscribe for + * @param codec the serialization codec to use while receiving the message + * @param options when setting up the subscription + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +function subscribeToReceiveArrayPayload({ + onDataCallback, + nc, + codec = Nats.JSONCodec(), + options, + skipMessageValidation = false +}: { + onDataCallback: (err?: Error, msg?: ArrayMessageModule.ArrayMessage, natsMsg?: Nats.Msg) => void, + nc: Nats.NatsConnection, + codec?: Nats.Codec, + options?: Nats.SubscriptionOptions, + skipMessageValidation?: boolean +}): Promise { + return new Promise(async (resolve, reject) => { + try { + const subscription = nc.subscribe('array.payload', options); + const validator = ArrayMessageModule.createValidator(); + (async () => { + for await (const msg of subscription) { + + let receivedData: any = codec.decode(msg.data); +if(!skipMessageValidation) { + const {valid, errors} = ArrayMessageModule.validate({data: receivedData, ajvValidatorFunction: validator}); + if(!valid) { + onDataCallback(new Error(`Invalid message payload received; ${JSON.stringify({cause: errors})}`), undefined, msg); continue; + } + } +onDataCallback(undefined, ArrayMessageModule.unmarshal(receivedData), msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * Callback for when receiving messages + * + * @callback jetStreamPullSubscribeToReceiveArrayPayloadCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param jetstreamMsg + */ + +/** + * JetStream pull subscription for `array.payload` + * + * @param {jetStreamPullSubscribeToReceiveArrayPayloadCallback} onDataCallback to call when messages are received + * @param js the JetStream client to pull subscribe through + * @param options when setting up the subscription + * @param codec the serialization codec to use while transmitting the message + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +function jetStreamPullSubscribeToReceiveArrayPayload({ + onDataCallback, + js, + options, + codec = Nats.JSONCodec(), + skipMessageValidation = false +}: { + onDataCallback: (err?: Error, msg?: ArrayMessageModule.ArrayMessage, jetstreamMsg?: Nats.JsMsg) => void, + js: Nats.JetStreamClient, + options: Nats.ConsumerOptsBuilder | Partial, + codec?: Nats.Codec, + skipMessageValidation?: boolean +}): Promise { + return new Promise(async (resolve, reject) => { + try { + const subscription = await js.pullSubscribe('array.payload', options); + const validator = ArrayMessageModule.createValidator(); + (async () => { + for await (const msg of subscription) { + + let receivedData: any = codec.decode(msg.data); +if(!skipMessageValidation) { + const {valid, errors} = ArrayMessageModule.validate({data: receivedData, ajvValidatorFunction: validator}); + if(!valid) { + onDataCallback(new Error(`Invalid message payload received; ${JSON.stringify({cause: errors})}`), undefined, msg); continue; + } + } +onDataCallback(undefined, ArrayMessageModule.unmarshal(receivedData), msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * Callback for when receiving messages + * + * @callback jetStreamPushSubscriptionFromReceiveArrayPayloadCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param jetstreamMsg + */ + +/** + * JetStream push subscription for `array.payload` + * + * @param {jetStreamPushSubscriptionFromReceiveArrayPayloadCallback} onDataCallback to call when messages are received + * @param js the JetStream client to pull subscribe through + * @param options when setting up the subscription + * @param codec the serialization codec to use while transmitting the message + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +function jetStreamPushSubscriptionFromReceiveArrayPayload({ + onDataCallback, + js, + options, + codec = Nats.JSONCodec(), + skipMessageValidation = false +}: { + onDataCallback: (err?: Error, msg?: ArrayMessageModule.ArrayMessage, jetstreamMsg?: Nats.JsMsg) => void, + js: Nats.JetStreamClient, + options: Nats.ConsumerOptsBuilder | Partial, + codec?: Nats.Codec, + skipMessageValidation?: boolean +}): Promise { + return new Promise(async (resolve, reject) => { + try { + const subscription = await js.subscribe('array.payload', options); + const validator = ArrayMessageModule.createValidator(); + (async () => { + for await (const msg of subscription) { + + let receivedData: any = codec.decode(msg.data); +if(!skipMessageValidation) { + const {valid, errors} = ArrayMessageModule.validate({data: receivedData, ajvValidatorFunction: validator}); + if(!valid) { + onDataCallback(new Error(`Invalid message payload received; ${JSON.stringify({cause: errors})}`), undefined, msg); continue; + } + } +onDataCallback(undefined, ArrayMessageModule.unmarshal(receivedData), msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * NATS publish operation for `union.payload` + * + * @param message to publish + * @param nc the NATS client to publish from + * @param codec the serialization codec to use while transmitting the message + * @param options to use while publishing the message + */ +function publishToSendUnionPayload({ + message, + nc, + codec = Nats.JSONCodec(), + options +}: { + message: UnionMessageModule.UnionMessage, + nc: Nats.NatsConnection, + codec?: Nats.Codec, + options?: Nats.PublishOptions +}): Promise { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = UnionMessageModule.marshal(message); + +dataToSend = codec.encode(dataToSend); +nc.publish('union.payload', dataToSend, options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * JetStream publish operation for `union.payload` + * + * @param message to publish over jetstream + * @param js the JetStream client to publish from + * @param codec the serialization codec to use while transmitting the message + * @param options to use while publishing the message + */ +function jetStreamPublishToSendUnionPayload({ + message, + js, + codec = Nats.JSONCodec(), + options = {} +}: { + message: UnionMessageModule.UnionMessage, + js: Nats.JetStreamClient, + codec?: Nats.Codec, + options?: Partial +}): Promise { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = UnionMessageModule.marshal(message); + +dataToSend = codec.encode(dataToSend); +await js.publish('union.payload', dataToSend, options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * Callback for when receiving messages + * + * @callback subscribeToReceiveUnionPayloadCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param natsMsg + */ + +/** + * Core subscription for `union.payload` + * + * @param {subscribeToReceiveUnionPayloadCallback} onDataCallback to call when messages are received + * @param nc the nats client to setup the subscribe for + * @param codec the serialization codec to use while receiving the message + * @param options when setting up the subscription + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +function subscribeToReceiveUnionPayload({ + onDataCallback, + nc, + codec = Nats.JSONCodec(), + options, + skipMessageValidation = false +}: { + onDataCallback: (err?: Error, msg?: UnionMessageModule.UnionMessage, natsMsg?: Nats.Msg) => void, + nc: Nats.NatsConnection, + codec?: Nats.Codec, + options?: Nats.SubscriptionOptions, + skipMessageValidation?: boolean +}): Promise { + return new Promise(async (resolve, reject) => { + try { + const subscription = nc.subscribe('union.payload', options); + const validator = UnionMessageModule.createValidator(); + (async () => { + for await (const msg of subscription) { + + let receivedData: any = codec.decode(msg.data); +if(!skipMessageValidation) { + const {valid, errors} = UnionMessageModule.validate({data: receivedData, ajvValidatorFunction: validator}); + if(!valid) { + onDataCallback(new Error(`Invalid message payload received; ${JSON.stringify({cause: errors})}`), undefined, msg); continue; + } + } +onDataCallback(undefined, UnionMessageModule.unmarshal(receivedData), msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * Callback for when receiving messages + * + * @callback jetStreamPullSubscribeToReceiveUnionPayloadCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param jetstreamMsg + */ + +/** + * JetStream pull subscription for `union.payload` + * + * @param {jetStreamPullSubscribeToReceiveUnionPayloadCallback} onDataCallback to call when messages are received + * @param js the JetStream client to pull subscribe through + * @param options when setting up the subscription + * @param codec the serialization codec to use while transmitting the message + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +function jetStreamPullSubscribeToReceiveUnionPayload({ + onDataCallback, + js, + options, + codec = Nats.JSONCodec(), + skipMessageValidation = false +}: { + onDataCallback: (err?: Error, msg?: UnionMessageModule.UnionMessage, jetstreamMsg?: Nats.JsMsg) => void, + js: Nats.JetStreamClient, + options: Nats.ConsumerOptsBuilder | Partial, + codec?: Nats.Codec, + skipMessageValidation?: boolean +}): Promise { + return new Promise(async (resolve, reject) => { + try { + const subscription = await js.pullSubscribe('union.payload', options); + const validator = UnionMessageModule.createValidator(); + (async () => { + for await (const msg of subscription) { + + let receivedData: any = codec.decode(msg.data); +if(!skipMessageValidation) { + const {valid, errors} = UnionMessageModule.validate({data: receivedData, ajvValidatorFunction: validator}); + if(!valid) { + onDataCallback(new Error(`Invalid message payload received; ${JSON.stringify({cause: errors})}`), undefined, msg); continue; + } + } +onDataCallback(undefined, UnionMessageModule.unmarshal(receivedData), msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * Callback for when receiving messages + * + * @callback jetStreamPushSubscriptionFromReceiveUnionPayloadCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param jetstreamMsg + */ + +/** + * JetStream push subscription for `union.payload` + * + * @param {jetStreamPushSubscriptionFromReceiveUnionPayloadCallback} onDataCallback to call when messages are received + * @param js the JetStream client to pull subscribe through + * @param options when setting up the subscription + * @param codec the serialization codec to use while transmitting the message + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +function jetStreamPushSubscriptionFromReceiveUnionPayload({ + onDataCallback, + js, + options, + codec = Nats.JSONCodec(), + skipMessageValidation = false +}: { + onDataCallback: (err?: Error, msg?: UnionMessageModule.UnionMessage, jetstreamMsg?: Nats.JsMsg) => void, + js: Nats.JetStreamClient, + options: Nats.ConsumerOptsBuilder | Partial, + codec?: Nats.Codec, + skipMessageValidation?: boolean +}): Promise { + return new Promise(async (resolve, reject) => { + try { + const subscription = await js.subscribe('union.payload', options); + const validator = UnionMessageModule.createValidator(); + (async () => { + for await (const msg of subscription) { + + let receivedData: any = codec.decode(msg.data); +if(!skipMessageValidation) { + const {valid, errors} = UnionMessageModule.validate({data: receivedData, ajvValidatorFunction: validator}); + if(!valid) { + onDataCallback(new Error(`Invalid message payload received; ${JSON.stringify({cause: errors})}`), undefined, msg); continue; + } + } +onDataCallback(undefined, UnionMessageModule.unmarshal(receivedData), msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * Sends a notification using the legacy notification system. Use the new notification service instead. + * + * @deprecated + * + * @param message to publish + * @param nc the NATS client to publish from + * @param codec the serialization codec to use while transmitting the message + * @param options to use while publishing the message + */ +function publishToSendLegacyNotification({ + message, + nc, + codec = Nats.JSONCodec(), + options +}: { + message: LegacyNotification, + nc: Nats.NatsConnection, + codec?: Nats.Codec, + options?: Nats.PublishOptions +}): Promise { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = message.marshal(); + +dataToSend = codec.encode(dataToSend); +nc.publish('legacy.notification', dataToSend, options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * Sends a notification using the legacy notification system. Use the new notification service instead. + * + * @deprecated + * + * @param message to publish over jetstream + * @param js the JetStream client to publish from + * @param codec the serialization codec to use while transmitting the message + * @param options to use while publishing the message + */ +function jetStreamPublishToSendLegacyNotification({ + message, + js, + codec = Nats.JSONCodec(), + options = {} +}: { + message: LegacyNotification, + js: Nats.JetStreamClient, + codec?: Nats.Codec, + options?: Partial +}): Promise { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = message.marshal(); + +dataToSend = codec.encode(dataToSend); +await js.publish('legacy.notification', dataToSend, options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * Callback for when receiving messages + * + * @callback subscribeToReceiveLegacyNotificationCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param natsMsg + */ + +/** + * Receives notifications from the legacy notification system. Use the new notification service instead. + * + * @deprecated + * + * @param {subscribeToReceiveLegacyNotificationCallback} onDataCallback to call when messages are received + * @param nc the nats client to setup the subscribe for + * @param codec the serialization codec to use while receiving the message + * @param options when setting up the subscription + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +function subscribeToReceiveLegacyNotification({ + onDataCallback, + nc, + codec = Nats.JSONCodec(), + options, + skipMessageValidation = false +}: { + onDataCallback: (err?: Error, msg?: LegacyNotification, natsMsg?: Nats.Msg) => void, + nc: Nats.NatsConnection, + codec?: Nats.Codec, + options?: Nats.SubscriptionOptions, + skipMessageValidation?: boolean +}): Promise { + return new Promise(async (resolve, reject) => { + try { + const subscription = nc.subscribe('legacy.notification', options); + const validator = LegacyNotification.createValidator(); + (async () => { + for await (const msg of subscription) { + + let receivedData: any = codec.decode(msg.data); +if(!skipMessageValidation) { + const {valid, errors} = LegacyNotification.validate({data: receivedData, ajvValidatorFunction: validator}); + if(!valid) { + onDataCallback(new Error(`Invalid message payload received; ${JSON.stringify({cause: errors})}`), undefined, msg); continue; + } + } +onDataCallback(undefined, LegacyNotification.unmarshal(receivedData), msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * Callback for when receiving messages + * + * @callback jetStreamPullSubscribeToReceiveLegacyNotificationCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param jetstreamMsg + */ + +/** + * Receives notifications from the legacy notification system. Use the new notification service instead. + * + * @deprecated + * + * @param {jetStreamPullSubscribeToReceiveLegacyNotificationCallback} onDataCallback to call when messages are received + * @param js the JetStream client to pull subscribe through + * @param options when setting up the subscription + * @param codec the serialization codec to use while transmitting the message + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +function jetStreamPullSubscribeToReceiveLegacyNotification({ + onDataCallback, + js, + options, + codec = Nats.JSONCodec(), + skipMessageValidation = false +}: { + onDataCallback: (err?: Error, msg?: LegacyNotification, jetstreamMsg?: Nats.JsMsg) => void, + js: Nats.JetStreamClient, + options: Nats.ConsumerOptsBuilder | Partial, + codec?: Nats.Codec, + skipMessageValidation?: boolean +}): Promise { + return new Promise(async (resolve, reject) => { + try { + const subscription = await js.pullSubscribe('legacy.notification', options); + const validator = LegacyNotification.createValidator(); + (async () => { + for await (const msg of subscription) { + + let receivedData: any = codec.decode(msg.data); +if(!skipMessageValidation) { + const {valid, errors} = LegacyNotification.validate({data: receivedData, ajvValidatorFunction: validator}); + if(!valid) { + onDataCallback(new Error(`Invalid message payload received; ${JSON.stringify({cause: errors})}`), undefined, msg); continue; + } + } +onDataCallback(undefined, LegacyNotification.unmarshal(receivedData), msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * Callback for when receiving messages + * + * @callback jetStreamPushSubscriptionFromReceiveLegacyNotificationCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param jetstreamMsg + */ + +/** + * Receives notifications from the legacy notification system. Use the new notification service instead. + * + * @deprecated + * + * @param {jetStreamPushSubscriptionFromReceiveLegacyNotificationCallback} onDataCallback to call when messages are received + * @param js the JetStream client to pull subscribe through + * @param options when setting up the subscription + * @param codec the serialization codec to use while transmitting the message + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +function jetStreamPushSubscriptionFromReceiveLegacyNotification({ + onDataCallback, + js, + options, + codec = Nats.JSONCodec(), + skipMessageValidation = false +}: { + onDataCallback: (err?: Error, msg?: LegacyNotification, jetstreamMsg?: Nats.JsMsg) => void, + js: Nats.JetStreamClient, + options: Nats.ConsumerOptsBuilder | Partial, + codec?: Nats.Codec, + skipMessageValidation?: boolean +}): Promise { + return new Promise(async (resolve, reject) => { + try { + const subscription = await js.subscribe('legacy.notification', options); + const validator = LegacyNotification.createValidator(); + (async () => { + for await (const msg of subscription) { + + let receivedData: any = codec.decode(msg.data); +if(!skipMessageValidation) { + const {valid, errors} = LegacyNotification.validate({data: receivedData, ajvValidatorFunction: validator}); + if(!valid) { + onDataCallback(new Error(`Invalid message payload received; ${JSON.stringify({cause: errors})}`), undefined, msg); continue; + } + } +onDataCallback(undefined, LegacyNotification.unmarshal(receivedData), msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +} + +export { publishToSendUserSignedup, jetStreamPublishToSendUserSignedup, subscribeToReceiveUserSignedup, jetStreamPullSubscribeToReceiveUserSignedup, jetStreamPushSubscriptionFromReceiveUserSignedup, publishToNoParameter, subscribeToNoParameter, jetStreamPullSubscribeToNoParameter, jetStreamPushSubscriptionFromNoParameter, jetStreamPublishToNoParameter, publishToSendStringPayload, jetStreamPublishToSendStringPayload, subscribeToReceiveStringPayload, jetStreamPullSubscribeToReceiveStringPayload, jetStreamPushSubscriptionFromReceiveStringPayload, publishToSendArrayPayload, jetStreamPublishToSendArrayPayload, subscribeToReceiveArrayPayload, jetStreamPullSubscribeToReceiveArrayPayload, jetStreamPushSubscriptionFromReceiveArrayPayload, publishToSendUnionPayload, jetStreamPublishToSendUnionPayload, subscribeToReceiveUnionPayload, jetStreamPullSubscribeToReceiveUnionPayload, jetStreamPushSubscriptionFromReceiveUnionPayload, publishToSendLegacyNotification, jetStreamPublishToSendLegacyNotification, subscribeToReceiveLegacyNotification, jetStreamPullSubscribeToReceiveLegacyNotification, jetStreamPushSubscriptionFromReceiveLegacyNotification }; diff --git a/test/runtime/typescript/src/asyncapi-path-organization/channels/parameter/EnumParameter.ts b/test/runtime/typescript/src/asyncapi-path-organization/channels/parameter/EnumParameter.ts new file mode 100644 index 00000000..94ab76db --- /dev/null +++ b/test/runtime/typescript/src/asyncapi-path-organization/channels/parameter/EnumParameter.ts @@ -0,0 +1,6 @@ + +/** + * enum parameter + */ +type EnumParameter = "openapi" | "asyncapi"; +export { EnumParameter }; \ No newline at end of file diff --git a/test/runtime/typescript/src/asyncapi-path-organization/channels/parameter/UserSignedupParameters.ts b/test/runtime/typescript/src/asyncapi-path-organization/channels/parameter/UserSignedupParameters.ts new file mode 100644 index 00000000..cf4660d5 --- /dev/null +++ b/test/runtime/typescript/src/asyncapi-path-organization/channels/parameter/UserSignedupParameters.ts @@ -0,0 +1,60 @@ +import {EnumParameter} from './EnumParameter'; +class UserSignedupParameters { + private _myParameter: string; + private _enumParameter: EnumParameter; + + constructor(input: { + myParameter: string, + enumParameter: EnumParameter, + }) { + this._myParameter = input.myParameter; + this._enumParameter = input.enumParameter; + } + + /** + * parameter description + */ + get myParameter(): string { return this._myParameter; } + set myParameter(myParameter: string) { this._myParameter = myParameter; } + + /** + * enum parameter + */ + get enumParameter(): EnumParameter { return this._enumParameter; } + set enumParameter(enumParameter: EnumParameter) { this._enumParameter = enumParameter; } + + + /** + * Realize the channel/topic with the parameters added to this class. + */ + public getChannelWithParameters(channel: string) { + channel = channel.replace(/\{my_parameter\}/g, this.myParameter); + channel = channel.replace(/\{enum_parameter\}/g, this.enumParameter); + return channel; + } + + public static createFromChannel(msgSubject: string, channel: string, regex: RegExp): UserSignedupParameters { + const parameters = new UserSignedupParameters({myParameter: '', enumParameter: "openapi"}); + const match = msgSubject.match(regex); + const sequentialParameters: string[] = channel.match(/\{(\w+)\}/g) || []; + + if (match) { + const myParameterMatch = match[sequentialParameters.indexOf('{my_parameter}')+1]; + if(myParameterMatch && myParameterMatch !== '') { + parameters.myParameter = myParameterMatch as any + } else { + throw new Error(`Parameter: 'myParameter' is not valid in UserSignedupParameters. Aborting parameter extracting! `) + } + const enumParameterMatch = match[sequentialParameters.indexOf('{enum_parameter}')+1]; + if(enumParameterMatch && enumParameterMatch !== '') { + parameters.enumParameter = enumParameterMatch as any + } else { + throw new Error(`Parameter: 'enumParameter' is not valid in UserSignedupParameters. Aborting parameter extracting! `) + } + } else { + throw new Error(`Unable to find parameters in channel/topic, topic was ${channel}`) + } + return parameters; + } +} +export { UserSignedupParameters }; \ No newline at end of file diff --git a/test/runtime/typescript/src/asyncapi-path-organization/channels/payload/ArrayMessage.ts b/test/runtime/typescript/src/asyncapi-path-organization/channels/payload/ArrayMessage.ts new file mode 100644 index 00000000..e3d8a41c --- /dev/null +++ b/test/runtime/typescript/src/asyncapi-path-organization/channels/payload/ArrayMessage.ts @@ -0,0 +1,39 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +/** + * An array of strings payload + */ +type ArrayMessage = string[]; + +export function unmarshal(json: string | any[]): ArrayMessage { + if (typeof json === 'string') { + return JSON.parse(json) as ArrayMessage; + } + return json as ArrayMessage; +} +export function marshal(payload: ArrayMessage): string { + return JSON.stringify(payload); +} +export const theCodeGenSchema = {"type":"array","$schema":"http://json-schema.org/draft-07/schema","items":{"type":"string"},"description":"An array of strings payload","$id":"ArrayMessage"}; +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 ?? {})}; + addFormats(ajvInstance); + + const validate = ajvInstance.compile(theCodeGenSchema); + return validate; +} + + +export { ArrayMessage }; \ No newline at end of file diff --git a/test/runtime/typescript/src/asyncapi-path-organization/channels/payload/LegacyNotification.ts b/test/runtime/typescript/src/asyncapi-path-organization/channels/payload/LegacyNotification.ts new file mode 100644 index 00000000..7a50dcf7 --- /dev/null +++ b/test/runtime/typescript/src/asyncapi-path-organization/channels/payload/LegacyNotification.ts @@ -0,0 +1,96 @@ +import {LegacyNotificationPayloadLevelEnum} from './LegacyNotificationPayloadLevelEnum'; +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +/** + * Legacy notification payload - use NewNotificationPayload instead + */ +class LegacyNotification { + private _message?: string; + private _level?: LegacyNotificationPayloadLevelEnum; + private _additionalProperties?: Record; + + constructor(input: { + message?: string, + level?: LegacyNotificationPayloadLevelEnum, + additionalProperties?: Record, + }) { + this._message = input.message; + this._level = input.level; + this._additionalProperties = input.additionalProperties; + } + + /** + * The notification message + */ + get message(): string | undefined { return this._message; } + set message(message: string | undefined) { this._message = message; } + + /** + * Notification severity level + */ + get level(): LegacyNotificationPayloadLevelEnum | undefined { return this._level; } + set level(level: LegacyNotificationPayloadLevelEnum | undefined) { this._level = level; } + + get additionalProperties(): Record | undefined { return this._additionalProperties; } + set additionalProperties(additionalProperties: Record | undefined) { this._additionalProperties = additionalProperties; } + + public marshal() : string { + let json = '{' + if(this.message !== undefined) { + json += `"message": ${typeof this.message === 'number' || typeof this.message === 'boolean' ? this.message : JSON.stringify(this.message)},`; + } + if(this.level !== undefined) { + json += `"level": ${typeof this.level === 'number' || typeof this.level === 'boolean' ? this.level : JSON.stringify(this.level)},`; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of this.additionalProperties.entries()) { + //Only unwrap those that are not already a property in the JSON object + if(["message","level","additionalProperties"].includes(String(key))) continue; + json += `"${key}": ${typeof value === 'number' || typeof value === 'boolean' ? value : JSON.stringify(value)},`; + } + } + //Remove potential last comma + return `${json.charAt(json.length-1) === ',' ? json.slice(0, json.length-1) : json}}`; + } + + public static unmarshal(json: string | object): LegacyNotification { + const obj = typeof json === "object" ? json : JSON.parse(json); + const instance = new LegacyNotification({} as any); + + if (obj["message"] !== undefined) { + instance.message = obj["message"]; + } + if (obj["level"] !== undefined) { + instance.level = obj["level"]; + } + + instance.additionalProperties = new Map(); + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["message","level","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties.set(key, value as any); + } + return instance; + } + public static theCodeGenSchema = {"type":"object","$schema":"http://json-schema.org/draft-07/schema","description":"Legacy notification payload - use NewNotificationPayload instead","deprecated":true,"properties":{"message":{"type":"string","description":"The notification message"},"level":{"type":"string","enum":["info","warning","error"],"description":"Notification severity level"}},"$id":"LegacyNotification"}; + 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 ?? {})}; + addFormats(ajvInstance); + + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { LegacyNotification }; \ No newline at end of file diff --git a/test/runtime/typescript/src/asyncapi-path-organization/channels/payload/LegacyNotificationPayloadLevelEnum.ts b/test/runtime/typescript/src/asyncapi-path-organization/channels/payload/LegacyNotificationPayloadLevelEnum.ts new file mode 100644 index 00000000..b7e9ab91 --- /dev/null +++ b/test/runtime/typescript/src/asyncapi-path-organization/channels/payload/LegacyNotificationPayloadLevelEnum.ts @@ -0,0 +1,10 @@ + +/** + * Notification severity level + */ +enum LegacyNotificationPayloadLevelEnum { + INFO = "info", + WARNING = "warning", + ERROR = "error", +} +export { LegacyNotificationPayloadLevelEnum }; \ No newline at end of file diff --git a/test/runtime/typescript/src/asyncapi-path-organization/channels/payload/StringMessage.ts b/test/runtime/typescript/src/asyncapi-path-organization/channels/payload/StringMessage.ts new file mode 100644 index 00000000..b4efe734 --- /dev/null +++ b/test/runtime/typescript/src/asyncapi-path-organization/channels/payload/StringMessage.ts @@ -0,0 +1,36 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +/** + * A simple string payload + */ +type StringMessage = string; + +export function unmarshal(json: string): StringMessage { + return JSON.parse(json) as StringMessage; +} +export function marshal(payload: StringMessage): string { + return JSON.stringify(payload); +} +export const theCodeGenSchema = {"type":"string","$schema":"http://json-schema.org/draft-07/schema","description":"A simple string payload","$id":"StringMessage"}; +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 ?? {})}; + addFormats(ajvInstance); + + const validate = ajvInstance.compile(theCodeGenSchema); + return validate; +} + + +export { StringMessage }; \ No newline at end of file diff --git a/test/runtime/typescript/src/asyncapi-path-organization/channels/payload/UnionMessage.ts b/test/runtime/typescript/src/asyncapi-path-organization/channels/payload/UnionMessage.ts new file mode 100644 index 00000000..f2545d83 --- /dev/null +++ b/test/runtime/typescript/src/asyncapi-path-organization/channels/payload/UnionMessage.ts @@ -0,0 +1,44 @@ +import {UnionPayloadOneOfOption2} from './UnionPayloadOneOfOption2'; +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +/** + * A union type payload + */ +type UnionMessage = string | number | UnionPayloadOneOfOption2; + +export function unmarshal(json: any): UnionMessage { + + return JSON.parse(json); +} +export function marshal(payload: UnionMessage) { + + +if(payload instanceof UnionPayloadOneOfOption2) { +return payload.marshal(); +} + return JSON.stringify(payload); +} + +export const theCodeGenSchema = {"$schema":"http://json-schema.org/draft-07/schema","oneOf":[{"type":"string"},{"type":"number"},{"type":"object","properties":{"name":{"type":"string"}}}],"description":"A union type payload","$id":"UnionMessage"}; +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 ?? {})}; + addFormats(ajvInstance); + + const validate = ajvInstance.compile(theCodeGenSchema); + return validate; +} + + +export { UnionMessage }; \ No newline at end of file diff --git a/test/runtime/typescript/src/asyncapi-path-organization/channels/payload/UnionPayloadOneOfOption2.ts b/test/runtime/typescript/src/asyncapi-path-organization/channels/payload/UnionPayloadOneOfOption2.ts new file mode 100644 index 00000000..d166bc77 --- /dev/null +++ b/test/runtime/typescript/src/asyncapi-path-organization/channels/payload/UnionPayloadOneOfOption2.ts @@ -0,0 +1,74 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +class UnionPayloadOneOfOption2 { + private _name?: string; + private _additionalProperties?: Record; + + constructor(input: { + name?: string, + additionalProperties?: Record, + }) { + this._name = input.name; + this._additionalProperties = input.additionalProperties; + } + + 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 marshal() : string { + let json = '{' + if(this.name !== undefined) { + json += `"name": ${typeof this.name === 'number' || typeof this.name === 'boolean' ? this.name : JSON.stringify(this.name)},`; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of this.additionalProperties.entries()) { + //Only unwrap those that are not already a property in the JSON object + if(["name","additionalProperties"].includes(String(key))) continue; + json += `"${key}": ${typeof value === 'number' || typeof value === 'boolean' ? value : JSON.stringify(value)},`; + } + } + //Remove potential last comma + return `${json.charAt(json.length-1) === ',' ? json.slice(0, json.length-1) : json}}`; + } + + public static unmarshal(json: string | object): UnionPayloadOneOfOption2 { + const obj = typeof json === "object" ? json : JSON.parse(json); + const instance = new UnionPayloadOneOfOption2({} as any); + + if (obj["name"] !== undefined) { + instance.name = obj["name"]; + } + + instance.additionalProperties = new Map(); + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["name","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties.set(key, value as any); + } + return instance; + } + public static theCodeGenSchema = {"type":"object","properties":{"name":{"type":"string"}}}; + 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 ?? {})}; + addFormats(ajvInstance); + + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { UnionPayloadOneOfOption2 }; \ No newline at end of file diff --git a/test/runtime/typescript/src/asyncapi-path-organization/channels/payload/UserSignedUp.ts b/test/runtime/typescript/src/asyncapi-path-organization/channels/payload/UserSignedUp.ts new file mode 100644 index 00000000..13f2c67a --- /dev/null +++ b/test/runtime/typescript/src/asyncapi-path-organization/channels/payload/UserSignedUp.ts @@ -0,0 +1,95 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +/** + * Payload for user signup events containing user registration details + */ +class UserSignedUp { + private _displayName?: string; + private _email?: string; + private _additionalProperties?: Record; + + constructor(input: { + displayName?: string, + email?: string, + additionalProperties?: Record, + }) { + this._displayName = input.displayName; + this._email = input.email; + this._additionalProperties = input.additionalProperties; + } + + /** + * Name of the user + */ + get displayName(): string | undefined { return this._displayName; } + set displayName(displayName: string | undefined) { this._displayName = displayName; } + + /** + * Email of the user + */ + get email(): string | undefined { return this._email; } + set email(email: string | undefined) { this._email = email; } + + get additionalProperties(): Record | undefined { return this._additionalProperties; } + set additionalProperties(additionalProperties: Record | undefined) { this._additionalProperties = additionalProperties; } + + public marshal() : string { + let json = '{' + if(this.displayName !== undefined) { + json += `"display_name": ${typeof this.displayName === 'number' || typeof this.displayName === 'boolean' ? this.displayName : JSON.stringify(this.displayName)},`; + } + if(this.email !== undefined) { + json += `"email": ${typeof this.email === 'number' || typeof this.email === 'boolean' ? this.email : JSON.stringify(this.email)},`; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of this.additionalProperties.entries()) { + //Only unwrap those that are not already a property in the JSON object + if(["display_name","email","additionalProperties"].includes(String(key))) continue; + json += `"${key}": ${typeof value === 'number' || typeof value === 'boolean' ? value : JSON.stringify(value)},`; + } + } + //Remove potential last comma + return `${json.charAt(json.length-1) === ',' ? json.slice(0, json.length-1) : json}}`; + } + + public static unmarshal(json: string | object): UserSignedUp { + const obj = typeof json === "object" ? json : JSON.parse(json); + const instance = new UserSignedUp({} as any); + + if (obj["display_name"] !== undefined) { + instance.displayName = obj["display_name"]; + } + if (obj["email"] !== undefined) { + instance.email = obj["email"]; + } + + instance.additionalProperties = new Map(); + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["display_name","email","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties.set(key, value as any); + } + return instance; + } + public static theCodeGenSchema = {"type":"object","$schema":"http://json-schema.org/draft-07/schema","description":"Payload for user signup events containing user registration details","properties":{"display_name":{"type":"string","description":"Name of the user"},"email":{"type":"string","format":"email","description":"Email of the user"}},"$id":"UserSignedUp"}; + 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 ?? {})}; + addFormats(ajvInstance); + + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { UserSignedUp }; \ No newline at end of file diff --git a/test/runtime/typescript/src/asyncapi-tag-organization/channels/index.ts b/test/runtime/typescript/src/asyncapi-tag-organization/channels/index.ts new file mode 100644 index 00000000..4570badf --- /dev/null +++ b/test/runtime/typescript/src/asyncapi-tag-organization/channels/index.ts @@ -0,0 +1,19 @@ +import * as internal_nats from './nats'; + +export const nats = { + user: { + publishToSendUserSignedup: internal_nats.publishToSendUserSignedup, + jetStreamPublishToSendUserSignedup: internal_nats.jetStreamPublishToSendUserSignedup, + subscribeToReceiveUserSignedup: internal_nats.subscribeToReceiveUserSignedup, + jetStreamPullSubscribeToReceiveUserSignedup: internal_nats.jetStreamPullSubscribeToReceiveUserSignedup, + jetStreamPushSubscriptionFromReceiveUserSignedup: internal_nats.jetStreamPushSubscriptionFromReceiveUserSignedup + }, + admin: { + publishToSendAdminAlert: internal_nats.publishToSendAdminAlert, + jetStreamPublishToSendAdminAlert: internal_nats.jetStreamPublishToSendAdminAlert + }, + untagged: { + publishToSendSystemPing: internal_nats.publishToSendSystemPing, + jetStreamPublishToSendSystemPing: internal_nats.jetStreamPublishToSendSystemPing + } +} as const; diff --git a/test/runtime/typescript/src/asyncapi-tag-organization/channels/nats.ts b/test/runtime/typescript/src/asyncapi-tag-organization/channels/nats.ts new file mode 100644 index 00000000..ba5b09e6 --- /dev/null +++ b/test/runtime/typescript/src/asyncapi-tag-organization/channels/nats.ts @@ -0,0 +1,382 @@ +import {UserSignedUp} from './payload/UserSignedUp'; +import {AdminAlert} from './payload/AdminAlert'; +import {SystemPing} from './payload/SystemPing'; +import {UserSignedupParameters} from './parameter/UserSignedupParameters'; +import * as Nats from 'nats'; + +/** + * Publish a user signup event + * + * @param message to publish + * @param parameters for topic substitution + * @param nc the NATS client to publish from + * @param codec the serialization codec to use while transmitting the message + * @param options to use while publishing the message + */ +function publishToSendUserSignedup({ + message, + parameters, + nc, + codec = Nats.JSONCodec(), + options +}: { + message: UserSignedUp, + parameters: UserSignedupParameters, + nc: Nats.NatsConnection, + codec?: Nats.Codec, + options?: Nats.PublishOptions +}): Promise { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = message.marshal(); + +dataToSend = codec.encode(dataToSend); +nc.publish(parameters.getChannelWithParameters('user.signedup.{id}'), dataToSend, options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * Publish a user signup event + * + * @param message to publish over jetstream + * @param parameters for topic substitution + * @param js the JetStream client to publish from + * @param codec the serialization codec to use while transmitting the message + * @param options to use while publishing the message + */ +function jetStreamPublishToSendUserSignedup({ + message, + parameters, + js, + codec = Nats.JSONCodec(), + options = {} +}: { + message: UserSignedUp, + parameters: UserSignedupParameters, + js: Nats.JetStreamClient, + codec?: Nats.Codec, + options?: Partial +}): Promise { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = message.marshal(); + +dataToSend = codec.encode(dataToSend); +await js.publish(parameters.getChannelWithParameters('user.signedup.{id}'), dataToSend, options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * Callback for when receiving messages + * + * @callback subscribeToReceiveUserSignedupCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param parameters that was received in the topic + * @param natsMsg + */ + +/** + * Consume a user signup event + * + * @param {subscribeToReceiveUserSignedupCallback} onDataCallback to call when messages are received + * @param parameters for topic substitution + * @param nc the nats client to setup the subscribe for + * @param codec the serialization codec to use while receiving the message + * @param options when setting up the subscription + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +function subscribeToReceiveUserSignedup({ + onDataCallback, + parameters, + nc, + codec = Nats.JSONCodec(), + options, + skipMessageValidation = false +}: { + onDataCallback: (err?: Error, msg?: UserSignedUp, parameters?: UserSignedupParameters, natsMsg?: Nats.Msg) => void, + parameters: UserSignedupParameters, + nc: Nats.NatsConnection, + codec?: Nats.Codec, + options?: Nats.SubscriptionOptions, + skipMessageValidation?: boolean +}): Promise { + return new Promise(async (resolve, reject) => { + try { + const subscription = nc.subscribe(parameters.getChannelWithParameters('user.signedup.{id}'), options); + const validator = UserSignedUp.createValidator(); + (async () => { + for await (const msg of subscription) { + const parameters = UserSignedupParameters.createFromChannel(msg.subject, 'user.signedup.{id}', /^user.signedup.([^.]*)$/) + let receivedData: any = codec.decode(msg.data); +if(!skipMessageValidation) { + const {valid, errors} = UserSignedUp.validate({data: receivedData, ajvValidatorFunction: validator}); + if(!valid) { + onDataCallback(new Error(`Invalid message payload received; ${JSON.stringify({cause: errors})}`), undefined,parameters, msg); continue; + } + } +onDataCallback(undefined, UserSignedUp.unmarshal(receivedData), parameters, msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * Callback for when receiving messages + * + * @callback jetStreamPullSubscribeToReceiveUserSignedupCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param parameters that was received in the topic + * @param jetstreamMsg + */ + +/** + * Consume a user signup event + * + * @param {jetStreamPullSubscribeToReceiveUserSignedupCallback} onDataCallback to call when messages are received + * @param parameters for topic substitution + * @param js the JetStream client to pull subscribe through + * @param options when setting up the subscription + * @param codec the serialization codec to use while transmitting the message + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +function jetStreamPullSubscribeToReceiveUserSignedup({ + onDataCallback, + parameters, + js, + options, + codec = Nats.JSONCodec(), + skipMessageValidation = false +}: { + onDataCallback: (err?: Error, msg?: UserSignedUp, parameters?: UserSignedupParameters, jetstreamMsg?: Nats.JsMsg) => void, + parameters: UserSignedupParameters, + js: Nats.JetStreamClient, + options: Nats.ConsumerOptsBuilder | Partial, + codec?: Nats.Codec, + skipMessageValidation?: boolean +}): Promise { + return new Promise(async (resolve, reject) => { + try { + const subscription = await js.pullSubscribe(parameters.getChannelWithParameters('user.signedup.{id}'), options); + const validator = UserSignedUp.createValidator(); + (async () => { + for await (const msg of subscription) { + const parameters = UserSignedupParameters.createFromChannel(msg.subject, 'user.signedup.{id}', /^user.signedup.([^.]*)$/) + let receivedData: any = codec.decode(msg.data); +if(!skipMessageValidation) { + const {valid, errors} = UserSignedUp.validate({data: receivedData, ajvValidatorFunction: validator}); + if(!valid) { + onDataCallback(new Error(`Invalid message payload received; ${JSON.stringify({cause: errors})}`), undefined,parameters, msg); continue; + } + } +onDataCallback(undefined, UserSignedUp.unmarshal(receivedData), parameters, msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * Callback for when receiving messages + * + * @callback jetStreamPushSubscriptionFromReceiveUserSignedupCallback + * @param err if any error occurred this will be sat + * @param msg that was received + * @param parameters that was received in the topic + * @param jetstreamMsg + */ + +/** + * Consume a user signup event + * + * @param {jetStreamPushSubscriptionFromReceiveUserSignedupCallback} onDataCallback to call when messages are received + * @param parameters for topic substitution + * @param js the JetStream client to pull subscribe through + * @param options when setting up the subscription + * @param codec the serialization codec to use while transmitting the message + * @param skipMessageValidation turn off runtime validation of incoming messages + */ +function jetStreamPushSubscriptionFromReceiveUserSignedup({ + onDataCallback, + parameters, + js, + options, + codec = Nats.JSONCodec(), + skipMessageValidation = false +}: { + onDataCallback: (err?: Error, msg?: UserSignedUp, parameters?: UserSignedupParameters, jetstreamMsg?: Nats.JsMsg) => void, + parameters: UserSignedupParameters, + js: Nats.JetStreamClient, + options: Nats.ConsumerOptsBuilder | Partial, + codec?: Nats.Codec, + skipMessageValidation?: boolean +}): Promise { + return new Promise(async (resolve, reject) => { + try { + const subscription = await js.subscribe(parameters.getChannelWithParameters('user.signedup.{id}'), options); + const validator = UserSignedUp.createValidator(); + (async () => { + for await (const msg of subscription) { + const parameters = UserSignedupParameters.createFromChannel(msg.subject, 'user.signedup.{id}', /^user.signedup.([^.]*)$/) + let receivedData: any = codec.decode(msg.data); +if(!skipMessageValidation) { + const {valid, errors} = UserSignedUp.validate({data: receivedData, ajvValidatorFunction: validator}); + if(!valid) { + onDataCallback(new Error(`Invalid message payload received; ${JSON.stringify({cause: errors})}`), undefined,parameters, msg); continue; + } + } +onDataCallback(undefined, UserSignedUp.unmarshal(receivedData), parameters, msg); + } + })(); + resolve(subscription); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * Publish an admin alert + * + * @param message to publish + * @param nc the NATS client to publish from + * @param codec the serialization codec to use while transmitting the message + * @param options to use while publishing the message + */ +function publishToSendAdminAlert({ + message, + nc, + codec = Nats.JSONCodec(), + options +}: { + message: AdminAlert, + nc: Nats.NatsConnection, + codec?: Nats.Codec, + options?: Nats.PublishOptions +}): Promise { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = message.marshal(); + +dataToSend = codec.encode(dataToSend); +nc.publish('admin.alert', dataToSend, options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * Publish an admin alert + * + * @param message to publish over jetstream + * @param js the JetStream client to publish from + * @param codec the serialization codec to use while transmitting the message + * @param options to use while publishing the message + */ +function jetStreamPublishToSendAdminAlert({ + message, + js, + codec = Nats.JSONCodec(), + options = {} +}: { + message: AdminAlert, + js: Nats.JetStreamClient, + codec?: Nats.Codec, + options?: Partial +}): Promise { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = message.marshal(); + +dataToSend = codec.encode(dataToSend); +await js.publish('admin.alert', dataToSend, options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * Publish a system ping (intentionally untagged) + * + * @param message to publish + * @param nc the NATS client to publish from + * @param codec the serialization codec to use while transmitting the message + * @param options to use while publishing the message + */ +function publishToSendSystemPing({ + message, + nc, + codec = Nats.JSONCodec(), + options +}: { + message: SystemPing, + nc: Nats.NatsConnection, + codec?: Nats.Codec, + options?: Nats.PublishOptions +}): Promise { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = message.marshal(); + +dataToSend = codec.encode(dataToSend); +nc.publish('system.ping', dataToSend, options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} + +/** + * Publish a system ping (intentionally untagged) + * + * @param message to publish over jetstream + * @param js the JetStream client to publish from + * @param codec the serialization codec to use while transmitting the message + * @param options to use while publishing the message + */ +function jetStreamPublishToSendSystemPing({ + message, + js, + codec = Nats.JSONCodec(), + options = {} +}: { + message: SystemPing, + js: Nats.JetStreamClient, + codec?: Nats.Codec, + options?: Partial +}): Promise { + return new Promise(async (resolve, reject) => { + try { + let dataToSend: any = message.marshal(); + +dataToSend = codec.encode(dataToSend); +await js.publish('system.ping', dataToSend, options); + resolve(); + } catch (e: any) { + reject(e); + } + }); +} + +export { publishToSendUserSignedup, jetStreamPublishToSendUserSignedup, subscribeToReceiveUserSignedup, jetStreamPullSubscribeToReceiveUserSignedup, jetStreamPushSubscriptionFromReceiveUserSignedup, publishToSendAdminAlert, jetStreamPublishToSendAdminAlert, publishToSendSystemPing, jetStreamPublishToSendSystemPing }; diff --git a/test/runtime/typescript/src/asyncapi-tag-organization/channels/parameter/UserSignedupParameters.ts b/test/runtime/typescript/src/asyncapi-tag-organization/channels/parameter/UserSignedupParameters.ts new file mode 100644 index 00000000..1582c932 --- /dev/null +++ b/test/runtime/typescript/src/asyncapi-tag-organization/channels/parameter/UserSignedupParameters.ts @@ -0,0 +1,44 @@ + +class UserSignedupParameters { + private _id: string; + + constructor(input: { + id: string, + }) { + this._id = input.id; + } + + /** + * The id of the user + */ + get id(): string { return this._id; } + set id(id: string) { this._id = id; } + + + /** + * Realize the channel/topic with the parameters added to this class. + */ + public getChannelWithParameters(channel: string) { + channel = channel.replace(/\{id\}/g, this.id); + return channel; + } + + public static createFromChannel(msgSubject: string, channel: string, regex: RegExp): UserSignedupParameters { + const parameters = new UserSignedupParameters({id: ''}); + const match = msgSubject.match(regex); + const sequentialParameters: string[] = channel.match(/\{(\w+)\}/g) || []; + + if (match) { + const idMatch = match[sequentialParameters.indexOf('{id}')+1]; + if(idMatch && idMatch !== '') { + parameters.id = idMatch as any + } else { + throw new Error(`Parameter: 'id' is not valid in UserSignedupParameters. Aborting parameter extracting! `) + } + } else { + throw new Error(`Unable to find parameters in channel/topic, topic was ${channel}`) + } + return parameters; + } +} +export { UserSignedupParameters }; \ No newline at end of file diff --git a/test/runtime/typescript/src/asyncapi-tag-organization/channels/payload/AdminAlert.ts b/test/runtime/typescript/src/asyncapi-tag-organization/channels/payload/AdminAlert.ts new file mode 100644 index 00000000..bf909d79 --- /dev/null +++ b/test/runtime/typescript/src/asyncapi-tag-organization/channels/payload/AdminAlert.ts @@ -0,0 +1,58 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +class AdminAlert { + private _message?: string; + + constructor(input: { + message?: string, + }) { + this._message = input.message; + } + + get message(): string | undefined { return this._message; } + set message(message: string | undefined) { this._message = message; } + + public marshal() : string { + let json = '{' + if(this.message !== undefined) { + json += `"message": ${typeof this.message === 'number' || typeof this.message === 'boolean' ? this.message : JSON.stringify(this.message)},`; + } + + //Remove potential last comma + return `${json.charAt(json.length-1) === ',' ? json.slice(0, json.length-1) : json}}`; + } + + public static unmarshal(json: string | object): AdminAlert { + const obj = typeof json === "object" ? json : JSON.parse(json); + const instance = new AdminAlert({} as any); + + if (obj["message"] !== undefined) { + instance.message = obj["message"]; + } + + + return instance; + } + public static theCodeGenSchema = {"type":"object","$schema":"http://json-schema.org/draft-07/schema","additionalProperties":false,"properties":{"message":{"type":"string"}},"$id":"AdminAlert"}; + 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 ?? {})}; + addFormats(ajvInstance); + + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { AdminAlert }; \ No newline at end of file diff --git a/test/runtime/typescript/src/asyncapi-tag-organization/channels/payload/SystemPing.ts b/test/runtime/typescript/src/asyncapi-tag-organization/channels/payload/SystemPing.ts new file mode 100644 index 00000000..308e232f --- /dev/null +++ b/test/runtime/typescript/src/asyncapi-tag-organization/channels/payload/SystemPing.ts @@ -0,0 +1,58 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +class SystemPing { + private _timestamp?: Date; + + constructor(input: { + timestamp?: Date, + }) { + this._timestamp = input.timestamp; + } + + get timestamp(): Date | undefined { return this._timestamp; } + set timestamp(timestamp: Date | undefined) { this._timestamp = timestamp; } + + public marshal() : string { + let json = '{' + if(this.timestamp !== undefined) { + json += `"timestamp": ${typeof this.timestamp === 'number' || typeof this.timestamp === 'boolean' ? this.timestamp : JSON.stringify(this.timestamp)},`; + } + + //Remove potential last comma + return `${json.charAt(json.length-1) === ',' ? json.slice(0, json.length-1) : json}}`; + } + + public static unmarshal(json: string | object): SystemPing { + const obj = typeof json === "object" ? json : JSON.parse(json); + const instance = new SystemPing({} as any); + + if (obj["timestamp"] !== undefined) { + instance.timestamp = obj["timestamp"] == null ? undefined : new Date(obj["timestamp"]); + } + + + return instance; + } + public static theCodeGenSchema = {"type":"object","$schema":"http://json-schema.org/draft-07/schema","additionalProperties":false,"properties":{"timestamp":{"type":"string","format":"date-time"}},"$id":"SystemPing"}; + 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 ?? {})}; + addFormats(ajvInstance); + + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { SystemPing }; \ No newline at end of file diff --git a/test/runtime/typescript/src/asyncapi-tag-organization/channels/payload/UserSignedUp.ts b/test/runtime/typescript/src/asyncapi-tag-organization/channels/payload/UserSignedUp.ts new file mode 100644 index 00000000..e698c1e0 --- /dev/null +++ b/test/runtime/typescript/src/asyncapi-tag-organization/channels/payload/UserSignedUp.ts @@ -0,0 +1,70 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +class UserSignedUp { + private _displayName?: string; + private _email?: string; + + constructor(input: { + displayName?: string, + email?: string, + }) { + this._displayName = input.displayName; + this._email = input.email; + } + + get displayName(): string | undefined { return this._displayName; } + set displayName(displayName: string | undefined) { this._displayName = displayName; } + + get email(): string | undefined { return this._email; } + set email(email: string | undefined) { this._email = email; } + + public marshal() : string { + let json = '{' + if(this.displayName !== undefined) { + json += `"displayName": ${typeof this.displayName === 'number' || typeof this.displayName === 'boolean' ? this.displayName : JSON.stringify(this.displayName)},`; + } + if(this.email !== undefined) { + json += `"email": ${typeof this.email === 'number' || typeof this.email === 'boolean' ? this.email : JSON.stringify(this.email)},`; + } + + //Remove potential last comma + return `${json.charAt(json.length-1) === ',' ? json.slice(0, json.length-1) : json}}`; + } + + public static unmarshal(json: string | object): UserSignedUp { + const obj = typeof json === "object" ? json : JSON.parse(json); + const instance = new UserSignedUp({} as any); + + if (obj["displayName"] !== undefined) { + instance.displayName = obj["displayName"]; + } + if (obj["email"] !== undefined) { + instance.email = obj["email"]; + } + + + return instance; + } + public static theCodeGenSchema = {"type":"object","$schema":"http://json-schema.org/draft-07/schema","additionalProperties":false,"properties":{"displayName":{"type":"string"},"email":{"type":"string","format":"email"}},"$id":"UserSignedUp"}; + 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 ?? {})}; + addFormats(ajvInstance); + + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { UserSignedUp }; \ No newline at end of file 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 new file mode 100644 index 00000000..9ab9e878 --- /dev/null +++ b/test/runtime/typescript/src/openapi-path-organization/channels/headers/FindPetsByStatusAndCategoryHeaders.ts @@ -0,0 +1,76 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +class FindPetsByStatusAndCategoryHeaders { + private _xRequestId?: string; + private _acceptLanguage?: string; + + constructor(input: { + xRequestId?: string, + acceptLanguage?: string, + }) { + this._xRequestId = input.xRequestId; + this._acceptLanguage = input.acceptLanguage; + } + + /** + * Unique request identifier for tracing + */ + get xRequestId(): string | undefined { return this._xRequestId; } + set xRequestId(xRequestId: string | undefined) { this._xRequestId = xRequestId; } + + /** + * Preferred language for response messages + */ + get acceptLanguage(): string | undefined { return this._acceptLanguage; } + set acceptLanguage(acceptLanguage: string | undefined) { this._acceptLanguage = acceptLanguage; } + + public marshal() : string { + let json = '{' + if(this.xRequestId !== undefined) { + json += `"X-Request-ID": ${typeof this.xRequestId === 'number' || typeof this.xRequestId === 'boolean' ? this.xRequestId : JSON.stringify(this.xRequestId)},`; + } + if(this.acceptLanguage !== undefined) { + json += `"Accept-Language": ${typeof this.acceptLanguage === 'number' || typeof this.acceptLanguage === 'boolean' ? this.acceptLanguage : JSON.stringify(this.acceptLanguage)},`; + } + + //Remove potential last comma + return `${json.charAt(json.length-1) === ',' ? json.slice(0, json.length-1) : json}}`; + } + + public static unmarshal(json: string | object): FindPetsByStatusAndCategoryHeaders { + const obj = typeof json === "object" ? json : JSON.parse(json); + const instance = new FindPetsByStatusAndCategoryHeaders({} as any); + + if (obj["X-Request-ID"] !== undefined) { + instance.xRequestId = obj["X-Request-ID"]; + } + if (obj["Accept-Language"] !== undefined) { + instance.acceptLanguage = obj["Accept-Language"]; + } + + + return instance; + } + public static theCodeGenSchema = {"type":"object","additionalProperties":false,"properties":{"X-Request-ID":{"type":"string","format":"uuid","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","description":"Unique request identifier for tracing"},"Accept-Language":{"type":"string","pattern":"^[a-z]{2}(-[A-Z]{2})?$","default":"en-US","description":"Preferred language for response messages"}},"$id":"FindPetsByStatusAndCategoryHeaders","$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 ?? {})}; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { FindPetsByStatusAndCategoryHeaders }; \ 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 new file mode 100644 index 00000000..6dcb861d --- /dev/null +++ b/test/runtime/typescript/src/openapi-path-organization/channels/http_client.ts @@ -0,0 +1,1368 @@ +import {APet} from './payload/APet'; +import * as FindPetsByStatusAndCategoryResponse_200Module from './payload/FindPetsByStatusAndCategoryResponse_200'; +import {PetCategory} from './payload/PetCategory'; +import {PetTag} from './payload/PetTag'; +import {Status} from './payload/Status'; +import {ItemStatus} from './payload/ItemStatus'; +import {PetOrder} from './payload/PetOrder'; +import {AUser} from './payload/AUser'; +import {AnUploadedResponse} from './payload/AnUploadedResponse'; +import {FindPetsByStatusAndCategoryParameters} from './parameter/FindPetsByStatusAndCategoryParameters'; +import {FindPetsByStatusAndCategoryHeaders} from './headers/FindPetsByStatusAndCategoryHeaders'; + +// ============================================================================ +// Common Types - Shared across all HTTP client functions +// ============================================================================ + +/** + * Standard HTTP response interface that wraps fetch-like responses + */ +export interface HttpResponse { + ok: boolean; + status: number; + statusText: string; + headers?: Headers | Record; + json: () => Record | Promise>; +} + +/** + * Pagination info extracted from response + */ +export interface PaginationInfo { + /** Total number of items (if available from headers like X-Total-Count) */ + totalCount?: number; + /** Total number of pages (if available) */ + totalPages?: number; + /** Current page/offset */ + currentOffset?: number; + /** Items per page */ + limit?: number; + /** Next cursor (for cursor-based pagination) */ + nextCursor?: string; + /** Previous cursor */ + prevCursor?: string; + /** Whether there are more items */ + hasMore?: boolean; +} + +/** + * 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; + /** Pagination info extracted from response (if applicable) */ + pagination?: PaginationInfo; + /** Fetch the next page (if pagination is configured and more data exists) */ + getNextPage?: () => Promise>; + /** Fetch the previous page (if pagination is configured) */ + getPrevPage?: () => Promise>; + /** Check if there's a next page */ + hasNextPage?: () => boolean; + /** Check if there's a previous page */ + hasPrevPage?: () => boolean; +} + +/** + * 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; + +// ============================================================================ +// Pagination Types +// ============================================================================ + +/** + * Where to place pagination parameters + */ +export type PaginationLocation = 'query' | 'header'; + +/** + * Offset-based pagination configuration + */ +export interface OffsetPagination { + type: 'offset'; + in?: PaginationLocation; // Where to place params (default: 'query') + offset: number; + limit: number; + offsetParam?: string; // Param name for offset (default: 'offset' for query, 'X-Offset' for header) + limitParam?: string; // Param name for limit (default: 'limit' for query, 'X-Limit' for header) +} + +/** + * Cursor-based pagination configuration + */ +export interface CursorPagination { + type: 'cursor'; + in?: PaginationLocation; // Where to place params (default: 'query') + cursor?: string; + limit?: number; + cursorParam?: string; // Param name for cursor (default: 'cursor' for query, 'X-Cursor' for header) + limitParam?: string; // Param name for limit (default: 'limit' for query, 'X-Limit' for header) +} + +/** + * Page-based pagination configuration + */ +export interface PagePagination { + type: 'page'; + in?: PaginationLocation; // Where to place params (default: 'query') + page: number; + pageSize: number; + pageParam?: string; // Param name for page (default: 'page' for query, 'X-Page' for header) + pageSizeParam?: string; // Param name for page size (default: 'pageSize' for query, 'X-Page-Size' for header) +} + +/** + * Range-based pagination (typically used with headers) + * Follows RFC 7233 style: Range: items=0-24 + */ +export interface RangePagination { + type: 'range'; + in?: 'header'; // Range pagination is typically header-only + start: number; + end: number; + unit?: string; // Range unit (default: 'items') + rangeHeader?: string; // Header name (default: 'Range') +} + +/** + * Union type for all pagination methods + */ +export type PaginationConfig = OffsetPagination | CursorPagination | PagePagination | RangePagination; + +// ============================================================================ +// 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: Error) => 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: Error, params: HttpRequestParams) => Error | Promise; +} + +// ============================================================================ +// Common Request Context +// ============================================================================ + +/** + * Base context shared by all HTTP client functions + */ +export interface HttpClientContext { + server?: string; + path?: string; + + // Authentication - grouped for better autocomplete + auth?: AuthConfig; + + // Pagination configuration + pagination?: PaginationConfig; + + // Retry configuration + retry?: RetryConfig; + + // Hooks for extensibility + hooks?: HttpHooks; + + // Additional options + additionalHeaders?: Record; + + // Query parameters + queryParams?: 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 pagination parameters to URL and/or headers based on configuration + */ +function applyPagination( + pagination: PaginationConfig | undefined, + url: string, + headers: Record +): { url: string; headers: Record } { + if (!pagination) return { url, headers }; + + const location = pagination.in ?? 'query'; + const isHeader = location === 'header'; + + // Helper to get default param names based on location + const getDefaultName = (queryName: string, headerName: string) => + isHeader ? headerName : queryName; + + const queryParams = new URLSearchParams(); + const headerParams: Record = {}; + + const addParam = (name: string, value: string) => { + if (isHeader) { + headerParams[name] = value; + } else { + queryParams.append(name, value); + } + }; + + switch (pagination.type) { + case 'offset': + addParam( + pagination.offsetParam ?? getDefaultName('offset', 'X-Offset'), + String(pagination.offset) + ); + addParam( + pagination.limitParam ?? getDefaultName('limit', 'X-Limit'), + String(pagination.limit) + ); + break; + + case 'cursor': + if (pagination.cursor) { + addParam( + pagination.cursorParam ?? getDefaultName('cursor', 'X-Cursor'), + pagination.cursor + ); + } + if (pagination.limit !== undefined) { + addParam( + pagination.limitParam ?? getDefaultName('limit', 'X-Limit'), + String(pagination.limit) + ); + } + break; + + case 'page': + addParam( + pagination.pageParam ?? getDefaultName('page', 'X-Page'), + String(pagination.page) + ); + addParam( + pagination.pageSizeParam ?? getDefaultName('pageSize', 'X-Page-Size'), + String(pagination.pageSize) + ); + break; + + case 'range': { + // Range pagination is always header-based (RFC 7233 style) + const unit = pagination.unit ?? 'items'; + const headerName = pagination.rangeHeader ?? 'Range'; + headerParams[headerName] = `${unit}=${pagination.start}-${pagination.end}`; + break; + } + } + + // Apply query params to URL + const queryString = queryParams.toString(); + if (queryString) { + const separator = url.includes('?') ? '&' : '?'; + url = `${url}${separator}${queryString}`; + } + + // Merge header params + const updatedHeaders = { ...headers, ...headerParams }; + + return { url, headers: updatedHeaders }; +} + +/** + * 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: Error | 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: Error | null = null; + let lastResponse: HttpResponse | null = null; + + for (let attempt = 0; attempt <= config.maxRetries; attempt++) { + try { + if (attempt > 0) { + const delay = calculateBackoffDelay(attempt, config); + config.onRetry(attempt, delay, lastError ?? new Error('Retry attempt')); + 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 Error(`HTTP Error: ${response.status} ${response.statusText}`); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + if (!shouldRetry(lastError, null, config, attempt + 1)) { + throw lastError; + } + } + } + + // All retries exhausted + if (lastResponse) { + return lastResponse; + } + throw lastError ?? new Error('Request failed after retries'); +} + +/** + * Handle HTTP error status codes with standardized messages + */ +function handleHttpError(status: number, statusText: string): never { + switch (status) { + case 401: + throw new Error('Unauthorized'); + case 403: + throw new Error('Forbidden'); + case 404: + throw new Error('Not Found'); + case 500: + throw new Error('Internal Server Error'); + default: + throw new Error(`HTTP Error: ${status} ${statusText}`); + } +} + +/** + * 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; +} + +/** + * Extract pagination info from response headers + */ +function extractPaginationInfo( + headers: Record, + currentPagination?: PaginationConfig +): PaginationInfo | undefined { + const info: PaginationInfo = {}; + let hasPaginationInfo = false; + + // Common total count headers + const totalCount = headers['x-total-count'] || headers['x-total'] || headers['total-count']; + if (totalCount) { + info.totalCount = parseInt(totalCount, 10); + hasPaginationInfo = true; + } + + // Total pages + const totalPages = headers['x-total-pages'] || headers['x-page-count']; + if (totalPages) { + info.totalPages = parseInt(totalPages, 10); + hasPaginationInfo = true; + } + + // Next cursor + const nextCursor = headers['x-next-cursor'] || headers['x-cursor-next']; + if (nextCursor) { + info.nextCursor = nextCursor; + info.hasMore = true; + hasPaginationInfo = true; + } + + // Previous cursor + const prevCursor = headers['x-prev-cursor'] || headers['x-cursor-prev']; + if (prevCursor) { + info.prevCursor = prevCursor; + hasPaginationInfo = true; + } + + // Has more indicator + const hasMore = headers['x-has-more'] || headers['x-has-next']; + if (hasMore) { + info.hasMore = hasMore.toLowerCase() === 'true' || hasMore === '1'; + hasPaginationInfo = true; + } + + // Parse Link header (RFC 5988) + const linkHeader = headers['link']; + if (linkHeader) { + const links = parseLinkHeader(linkHeader); + if (links.next) { + info.hasMore = true; + hasPaginationInfo = true; + } + } + + // Include current pagination state + if (currentPagination) { + switch (currentPagination.type) { + case 'offset': + info.currentOffset = currentPagination.offset; + info.limit = currentPagination.limit; + break; + case 'cursor': + info.limit = currentPagination.limit; + break; + case 'page': + info.currentOffset = (currentPagination.page - 1) * currentPagination.pageSize; + info.limit = currentPagination.pageSize; + break; + case 'range': + info.currentOffset = currentPagination.start; + info.limit = currentPagination.end - currentPagination.start + 1; + break; + } + hasPaginationInfo = true; + } + + // Calculate hasMore based on total count + if (info.hasMore === undefined && info.totalCount !== undefined && + info.currentOffset !== undefined && info.limit !== undefined) { + info.hasMore = info.currentOffset + info.limit < info.totalCount; + } + + return hasPaginationInfo ? info : undefined; +} + +/** + * Parse RFC 5988 Link header + */ +function parseLinkHeader(header: string): Record { + const links: Record = {}; + const parts = header.split(','); + + for (const part of parts) { + const match = part.match(/<([^>]+)>;\s*rel="?([^";\s]+)"?/); + if (match) { + links[match[2]] = match[1]; + } + } + + return links; +} + +/** + * Create pagination helper functions for the response + */ +function createPaginationHelpers( + currentConfig: TContext, + paginationInfo: PaginationInfo | undefined, + requestFn: (config: TContext) => Promise> +): Pick, 'getNextPage' | 'getPrevPage' | 'hasNextPage' | 'hasPrevPage'> { + const helpers: Pick, 'getNextPage' | 'getPrevPage' | 'hasNextPage' | 'hasPrevPage'> = {}; + + if (!currentConfig.pagination) { + return helpers; + } + + const pagination = currentConfig.pagination; + + helpers.hasNextPage = () => { + if (paginationInfo?.hasMore !== undefined) return paginationInfo.hasMore; + if (paginationInfo?.nextCursor) return true; + if (paginationInfo?.totalCount !== undefined && + paginationInfo.currentOffset !== undefined && + paginationInfo.limit !== undefined) { + return paginationInfo.currentOffset + paginationInfo.limit < paginationInfo.totalCount; + } + return false; + }; + + helpers.hasPrevPage = () => { + if (paginationInfo?.prevCursor) return true; + if (paginationInfo?.currentOffset !== undefined) { + return paginationInfo.currentOffset > 0; + } + return false; + }; + + helpers.getNextPage = async () => { + let nextPagination: PaginationConfig; + + switch (pagination.type) { + case 'offset': + nextPagination = { ...pagination, offset: pagination.offset + pagination.limit }; + break; + case 'cursor': + if (!paginationInfo?.nextCursor) throw new Error('No next cursor available'); + nextPagination = { ...pagination, cursor: paginationInfo.nextCursor }; + break; + case 'page': + nextPagination = { ...pagination, page: pagination.page + 1 }; + break; + case 'range': + const rangeSize = pagination.end - pagination.start + 1; + nextPagination = { ...pagination, start: pagination.end + 1, end: pagination.end + rangeSize }; + break; + default: + throw new Error('Unsupported pagination type'); + } + + return requestFn({ ...currentConfig, pagination: nextPagination }); + }; + + helpers.getPrevPage = async () => { + let prevPagination: PaginationConfig; + + switch (pagination.type) { + case 'offset': + prevPagination = { ...pagination, offset: Math.max(0, pagination.offset - pagination.limit) }; + break; + case 'cursor': + if (!paginationInfo?.prevCursor) throw new Error('No previous cursor available'); + prevPagination = { ...pagination, cursor: paginationInfo.prevCursor }; + break; + case 'page': + prevPagination = { ...pagination, page: Math.max(1, pagination.page - 1) }; + break; + case 'range': + const size = pagination.end - pagination.start + 1; + const newStart = Math.max(0, pagination.start - size); + prevPagination = { ...pagination, start: newStart, end: newStart + size - 1 }; + break; + default: + throw new Error('Unsupported pagination type'); + } + + return requestFn({ ...currentConfig, pagination: prevPagination }); + }; + + return helpers; +} + +/** + * 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 Error('OAuth2 Client Credentials flow requires tokenUrl'); + if (!auth.clientId) throw new Error('OAuth2 Client Credentials flow requires clientId'); + break; + + case 'password': + if (!auth.tokenUrl) throw new Error('OAuth2 Password flow requires tokenUrl'); + if (!auth.clientId) throw new Error('OAuth2 Password flow requires clientId'); + if (!auth.username) throw new Error('OAuth2 Password flow requires username'); + if (!auth.password) throw new Error('OAuth2 Password flow requires password'); + 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 Error(`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 Error('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: APet; + requestHeaders?: { marshal: () => string }; +} + +/** + * HTTP POST request to /pet + */ +async function addPet(context: AddPetContext): Promise> { + // Apply defaults + const config = { + path: '/pet', + server: 'localhost:3000', + ...context, + }; + + // Validate OAuth2 config if present + if (config.auth?.type === 'oauth2' && AUTH_FEATURES.oauth2) { + validateOAuth2Config(config.auth); + } + + // Build headers + let headers = context.requestHeaders + ? applyTypedHeaders(context.requestHeaders, config.additionalHeaders) + : { 'Content-Type': 'application/json', ...config.additionalHeaders } as Record; + + // Build URL + let url = `${config.server}${config.path}`; + url = applyQueryParams(config.queryParams, url); + + // Apply pagination (can affect URL and/or headers) + const paginationResult = applyPagination(config.pagination, url, headers); + url = paginationResult.url; + headers = paginationResult.headers; + + // Apply authentication + const authResult = applyAuth(config.auth, headers, url); + headers = authResult.headers; + url = authResult.url; + + // Prepare body + const body = context.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 Error('Unauthorized'); + } + } + + // Handle error responses + if (!response.ok) { + handleHttpError(response.status, response.statusText); + } + + // Parse response + const rawData = await response.json(); + const responseData = APet.unmarshal(JSON.stringify(rawData)); + + // Extract response metadata + const responseHeaders = extractHeaders(response); + const paginationInfo = extractPaginationInfo(responseHeaders, config.pagination); + + // Build response wrapper with pagination helpers + const result: HttpClientResponse = { + data: responseData, + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + rawData, + pagination: paginationInfo, + ...createPaginationHelpers(config, paginationInfo, addPet), + }; + + return result; + + } catch (error) { + // Apply onError hook if present + if (config.hooks?.onError && error instanceof Error) { + throw await config.hooks.onError(error, requestParams); + } + throw error; + } +} + +export interface UpdatePetContext extends HttpClientContext { + payload: APet; + requestHeaders?: { marshal: () => string }; +} + +/** + * HTTP PUT request to /pet + */ +async function updatePet(context: UpdatePetContext): Promise> { + // Apply defaults + const config = { + path: '/pet', + server: 'localhost:3000', + ...context, + }; + + // Validate OAuth2 config if present + if (config.auth?.type === 'oauth2' && AUTH_FEATURES.oauth2) { + validateOAuth2Config(config.auth); + } + + // Build headers + let headers = context.requestHeaders + ? applyTypedHeaders(context.requestHeaders, config.additionalHeaders) + : { 'Content-Type': 'application/json', ...config.additionalHeaders } as Record; + + // Build URL + let url = `${config.server}${config.path}`; + url = applyQueryParams(config.queryParams, url); + + // Apply pagination (can affect URL and/or headers) + const paginationResult = applyPagination(config.pagination, url, headers); + url = paginationResult.url; + headers = paginationResult.headers; + + // Apply authentication + const authResult = applyAuth(config.auth, headers, url); + headers = authResult.headers; + url = authResult.url; + + // Prepare body + const body = context.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 Error('Unauthorized'); + } + } + + // Handle error responses + if (!response.ok) { + handleHttpError(response.status, response.statusText); + } + + // Parse response + const rawData = await response.json(); + const responseData = APet.unmarshal(JSON.stringify(rawData)); + + // Extract response metadata + const responseHeaders = extractHeaders(response); + const paginationInfo = extractPaginationInfo(responseHeaders, config.pagination); + + // Build response wrapper with pagination helpers + const result: HttpClientResponse = { + data: responseData, + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + rawData, + pagination: paginationInfo, + ...createPaginationHelpers(config, paginationInfo, updatePet), + }; + + return result; + + } catch (error) { + // Apply onError hook if present + if (config.hooks?.onError && error instanceof Error) { + throw await config.hooks.onError(error, requestParams); + } + throw error; + } +} + +export interface FindPetsByStatusAndCategoryContext extends HttpClientContext { + parameters: { getChannelWithParameters: (path: string) => string }; + requestHeaders?: { marshal: () => string }; +} + +/** + * Find pets by status and category with additional filtering options + */ +async function findPetsByStatusAndCategory(context: FindPetsByStatusAndCategoryContext): Promise> { + // Apply defaults + const config = { + path: '/pet/findByStatus/{status}/{categoryId}', + server: 'localhost:3000', + ...context, + }; + + // Validate OAuth2 config if present + if (config.auth?.type === 'oauth2' && AUTH_FEATURES.oauth2) { + validateOAuth2Config(config.auth); + } + + // Build headers + let headers = context.requestHeaders + ? applyTypedHeaders(context.requestHeaders, config.additionalHeaders) + : { 'Content-Type': 'application/json', ...config.additionalHeaders } as Record; + + // Build URL + let url = buildUrlWithParameters(config.server, '/pet/findByStatus/{status}/{categoryId}', context.parameters); + url = applyQueryParams(config.queryParams, url); + + // Apply pagination (can affect URL and/or headers) + const paginationResult = applyPagination(config.pagination, url, headers); + url = paginationResult.url; + headers = paginationResult.headers; + + // 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 Error('Unauthorized'); + } + } + + // Handle error responses + if (!response.ok) { + handleHttpError(response.status, response.statusText); + } + + // Parse response + const rawData = await response.json(); + const responseData = FindPetsByStatusAndCategoryResponse_200Module.unmarshal(JSON.stringify(rawData)); + + // Extract response metadata + const responseHeaders = extractHeaders(response); + const paginationInfo = extractPaginationInfo(responseHeaders, config.pagination); + + // Build response wrapper with pagination helpers + const result: HttpClientResponse = { + data: responseData, + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + rawData, + pagination: paginationInfo, + ...createPaginationHelpers(config, paginationInfo, findPetsByStatusAndCategory), + }; + + return result; + + } catch (error) { + // Apply onError hook if present + if (config.hooks?.onError && error instanceof Error) { + throw await config.hooks.onError(error, requestParams); + } + throw error; + } +} + +export { addPet, updatePet, findPetsByStatusAndCategory }; diff --git a/test/runtime/typescript/src/openapi-path-organization/channels/index.ts b/test/runtime/typescript/src/openapi-path-organization/channels/index.ts new file mode 100644 index 00000000..5a010983 --- /dev/null +++ b/test/runtime/typescript/src/openapi-path-organization/channels/index.ts @@ -0,0 +1,11 @@ +import * as internal_http_client from './http_client'; + +export const http_client = { + pet: { + post: internal_http_client.addPet, + put: internal_http_client.updatePet, + findByStatus: { + get: internal_http_client.findPetsByStatusAndCategory + } + } +} as const; diff --git a/test/runtime/typescript/src/openapi-path-organization/channels/parameter/FindPetsByStatusAndCategoryParameters.ts b/test/runtime/typescript/src/openapi-path-organization/channels/parameter/FindPetsByStatusAndCategoryParameters.ts new file mode 100644 index 00000000..f90e6da9 --- /dev/null +++ b/test/runtime/typescript/src/openapi-path-organization/channels/parameter/FindPetsByStatusAndCategoryParameters.ts @@ -0,0 +1,391 @@ +import {Status} from './Status'; +import {SortBy} from './SortBy'; +import {SortOrder} from './SortOrder'; +import {Format} from './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: { + status: Status, + categoryId: number, + limit?: number, + offset?: number, + sortBy?: SortBy, + sortOrder?: SortOrder, + tags?: string[], + includePetDetails?: boolean, + format?: Format, + }) { + 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 }; \ No newline at end of file diff --git a/test/runtime/typescript/src/openapi-path-organization/channels/parameter/Format.ts b/test/runtime/typescript/src/openapi-path-organization/channels/parameter/Format.ts new file mode 100644 index 00000000..53f45e9c --- /dev/null +++ b/test/runtime/typescript/src/openapi-path-organization/channels/parameter/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-path-organization/channels/parameter/SortBy.ts b/test/runtime/typescript/src/openapi-path-organization/channels/parameter/SortBy.ts new file mode 100644 index 00000000..b267a9d1 --- /dev/null +++ b/test/runtime/typescript/src/openapi-path-organization/channels/parameter/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-path-organization/channels/parameter/SortOrder.ts b/test/runtime/typescript/src/openapi-path-organization/channels/parameter/SortOrder.ts new file mode 100644 index 00000000..efb5a9d9 --- /dev/null +++ b/test/runtime/typescript/src/openapi-path-organization/channels/parameter/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-path-organization/channels/parameter/Status.ts b/test/runtime/typescript/src/openapi-path-organization/channels/parameter/Status.ts new file mode 100644 index 00000000..c82a0529 --- /dev/null +++ b/test/runtime/typescript/src/openapi-path-organization/channels/parameter/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-path-organization/channels/payload/APet.ts b/test/runtime/typescript/src/openapi-path-organization/channels/payload/APet.ts new file mode 100644 index 00000000..7983585d --- /dev/null +++ b/test/runtime/typescript/src/openapi-path-organization/channels/payload/APet.ts @@ -0,0 +1,156 @@ +import {PetCategory} from './PetCategory'; +import {PetTag} from './PetTag'; +import {Status} from './Status'; +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +/** + * 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: { + id?: number, + category?: PetCategory, + name: string, + photoUrls: string[], + tags?: PetTag[], + status?: Status, + additionalProperties?: Record, + }) { + 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 marshal() : string { + let json = '{' + if(this.id !== undefined) { + json += `"id": ${typeof this.id === 'number' || typeof this.id === 'boolean' ? this.id : JSON.stringify(this.id)},`; + } + if(this.category !== undefined) { + json += `"category": ${this.category && typeof this.category === 'object' && 'marshal' in this.category && typeof this.category.marshal === 'function' ? this.category.marshal() : JSON.stringify(this.category)},`; + } + if(this.name !== undefined) { + json += `"name": ${typeof this.name === 'number' || typeof this.name === 'boolean' ? this.name : JSON.stringify(this.name)},`; + } + if(this.photoUrls !== undefined) { + let photoUrlsJsonValues: any[] = []; + for (const unionItem of this.photoUrls) { + photoUrlsJsonValues.push(`${typeof unionItem === 'number' || typeof unionItem === 'boolean' ? unionItem : JSON.stringify(unionItem)}`); + } + json += `"photoUrls": [${photoUrlsJsonValues.join(',')}],`; + } + if(this.tags !== undefined) { + let tagsJsonValues: any[] = []; + for (const unionItem of this.tags) { + tagsJsonValues.push(`${unionItem && typeof unionItem === 'object' && 'marshal' in unionItem && typeof unionItem.marshal === 'function' ? unionItem.marshal() : JSON.stringify(unionItem)}`); + } + json += `"tags": [${tagsJsonValues.join(',')}],`; + } + if(this.status !== undefined) { + json += `"status": ${typeof this.status === 'number' || typeof this.status === 'boolean' ? this.status : JSON.stringify(this.status)},`; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of this.additionalProperties.entries()) { + //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}": ${typeof value === 'number' || typeof value === 'boolean' ? value : JSON.stringify(value)},`; + } + } + //Remove potential last comma + return `${json.charAt(json.length-1) === ',' ? json.slice(0, json.length-1) : json}}`; + } + + public static unmarshal(json: string | object): APet { + const obj = typeof json === "object" ? json : JSON.parse(json); + const instance = new APet({} as any); + + if (obj["id"] !== undefined) { + instance.id = obj["id"]; + } + if (obj["category"] !== undefined) { + instance.category = PetCategory.unmarshal(obj["category"]); + } + if (obj["name"] !== undefined) { + instance.name = obj["name"]; + } + if (obj["photoUrls"] !== undefined) { + instance.photoUrls = obj["photoUrls"]; + } + if (obj["tags"] !== undefined) { + instance.tags = obj["tags"] == null + ? undefined + : obj["tags"].map((item: any) => PetTag.unmarshal(item)); + } + if (obj["status"] !== undefined) { + instance.status = obj["status"]; + } + + instance.additionalProperties = new Map(); + 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.set(key, value as any); + } + return instance; + } + 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 ?? {})}; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { APet }; \ No newline at end of file diff --git a/test/runtime/typescript/src/openapi-path-organization/channels/payload/AUser.ts b/test/runtime/typescript/src/openapi-path-organization/channels/payload/AUser.ts new file mode 100644 index 00000000..939e139a --- /dev/null +++ b/test/runtime/typescript/src/openapi-path-organization/channels/payload/AUser.ts @@ -0,0 +1,164 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +/** + * 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: { + id?: number, + username?: string, + firstName?: string, + lastName?: string, + email?: string, + password?: string, + phone?: string, + userStatus?: number, + additionalProperties?: Record, + }) { + 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 marshal() : string { + let json = '{' + if(this.id !== undefined) { + json += `"id": ${typeof this.id === 'number' || typeof this.id === 'boolean' ? this.id : JSON.stringify(this.id)},`; + } + if(this.username !== undefined) { + json += `"username": ${typeof this.username === 'number' || typeof this.username === 'boolean' ? this.username : JSON.stringify(this.username)},`; + } + if(this.firstName !== undefined) { + json += `"firstName": ${typeof this.firstName === 'number' || typeof this.firstName === 'boolean' ? this.firstName : JSON.stringify(this.firstName)},`; + } + if(this.lastName !== undefined) { + json += `"lastName": ${typeof this.lastName === 'number' || typeof this.lastName === 'boolean' ? this.lastName : JSON.stringify(this.lastName)},`; + } + if(this.email !== undefined) { + json += `"email": ${typeof this.email === 'number' || typeof this.email === 'boolean' ? this.email : JSON.stringify(this.email)},`; + } + if(this.password !== undefined) { + json += `"password": ${typeof this.password === 'number' || typeof this.password === 'boolean' ? this.password : JSON.stringify(this.password)},`; + } + if(this.phone !== undefined) { + json += `"phone": ${typeof this.phone === 'number' || typeof this.phone === 'boolean' ? this.phone : JSON.stringify(this.phone)},`; + } + if(this.userStatus !== undefined) { + json += `"userStatus": ${typeof this.userStatus === 'number' || typeof this.userStatus === 'boolean' ? this.userStatus : JSON.stringify(this.userStatus)},`; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of this.additionalProperties.entries()) { + //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}": ${typeof value === 'number' || typeof value === 'boolean' ? value : JSON.stringify(value)},`; + } + } + //Remove potential last comma + return `${json.charAt(json.length-1) === ',' ? json.slice(0, json.length-1) : json}}`; + } + + public static unmarshal(json: string | object): AUser { + const obj = typeof json === "object" ? json : JSON.parse(json); + const instance = new AUser({} as any); + + if (obj["id"] !== undefined) { + instance.id = obj["id"]; + } + if (obj["username"] !== undefined) { + instance.username = obj["username"]; + } + if (obj["firstName"] !== undefined) { + instance.firstName = obj["firstName"]; + } + if (obj["lastName"] !== undefined) { + instance.lastName = obj["lastName"]; + } + if (obj["email"] !== undefined) { + instance.email = obj["email"]; + } + if (obj["password"] !== undefined) { + instance.password = obj["password"]; + } + if (obj["phone"] !== undefined) { + instance.phone = obj["phone"]; + } + if (obj["userStatus"] !== undefined) { + instance.userStatus = obj["userStatus"]; + } + + instance.additionalProperties = new Map(); + 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.set(key, value as any); + } + return instance; + } + 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 ?? {})}; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { AUser }; \ No newline at end of file diff --git a/test/runtime/typescript/src/openapi-path-organization/channels/payload/AnUploadedResponse.ts b/test/runtime/typescript/src/openapi-path-organization/channels/payload/AnUploadedResponse.ts new file mode 100644 index 00000000..eaf68676 --- /dev/null +++ b/test/runtime/typescript/src/openapi-path-organization/channels/payload/AnUploadedResponse.ts @@ -0,0 +1,101 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +/** + * Describes the result of uploading an image resource + */ +class AnUploadedResponse { + private _code?: number; + private _type?: string; + private _message?: string; + private _additionalProperties?: Record; + + constructor(input: { + code?: number, + type?: string, + message?: string, + additionalProperties?: Record, + }) { + 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 marshal() : string { + let json = '{' + if(this.code !== undefined) { + json += `"code": ${typeof this.code === 'number' || typeof this.code === 'boolean' ? this.code : JSON.stringify(this.code)},`; + } + if(this.type !== undefined) { + json += `"type": ${typeof this.type === 'number' || typeof this.type === 'boolean' ? this.type : JSON.stringify(this.type)},`; + } + if(this.message !== undefined) { + json += `"message": ${typeof this.message === 'number' || typeof this.message === 'boolean' ? this.message : JSON.stringify(this.message)},`; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of this.additionalProperties.entries()) { + //Only unwrap those that are not already a property in the JSON object + if(["code","type","message","additionalProperties"].includes(String(key))) continue; + json += `"${key}": ${typeof value === 'number' || typeof value === 'boolean' ? value : JSON.stringify(value)},`; + } + } + //Remove potential last comma + return `${json.charAt(json.length-1) === ',' ? json.slice(0, json.length-1) : json}}`; + } + + public static unmarshal(json: string | object): AnUploadedResponse { + const obj = typeof json === "object" ? json : JSON.parse(json); + const instance = new AnUploadedResponse({} as any); + + if (obj["code"] !== undefined) { + instance.code = obj["code"]; + } + if (obj["type"] !== undefined) { + instance.type = obj["type"]; + } + if (obj["message"] !== undefined) { + instance.message = obj["message"]; + } + + instance.additionalProperties = new Map(); + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["code","type","message","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties.set(key, value as any); + } + return instance; + } + 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 ?? {})}; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { AnUploadedResponse }; \ No newline at end of file diff --git a/test/runtime/typescript/src/openapi-path-organization/channels/payload/FindPetsByStatusAndCategoryResponse_200.ts b/test/runtime/typescript/src/openapi-path-organization/channels/payload/FindPetsByStatusAndCategoryResponse_200.ts new file mode 100644 index 00000000..316729c0 --- /dev/null +++ b/test/runtime/typescript/src/openapi-path-organization/channels/payload/FindPetsByStatusAndCategoryResponse_200.ts @@ -0,0 +1,45 @@ +import {APet} from './APet'; +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} 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 ?? {})}; + 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-path-organization/channels/payload/ItemStatus.ts b/test/runtime/typescript/src/openapi-path-organization/channels/payload/ItemStatus.ts new file mode 100644 index 00000000..706c5beb --- /dev/null +++ b/test/runtime/typescript/src/openapi-path-organization/channels/payload/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-path-organization/channels/payload/PetCategory.ts b/test/runtime/typescript/src/openapi-path-organization/channels/payload/PetCategory.ts new file mode 100644 index 00000000..56f53566 --- /dev/null +++ b/test/runtime/typescript/src/openapi-path-organization/channels/payload/PetCategory.ts @@ -0,0 +1,89 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +/** + * A category for a pet + */ +class PetCategory { + private _id?: number; + private _name?: string; + private _additionalProperties?: Record; + + constructor(input: { + id?: number, + name?: string, + additionalProperties?: Record, + }) { + 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 marshal() : string { + let json = '{' + if(this.id !== undefined) { + json += `"id": ${typeof this.id === 'number' || typeof this.id === 'boolean' ? this.id : JSON.stringify(this.id)},`; + } + if(this.name !== undefined) { + json += `"name": ${typeof this.name === 'number' || typeof this.name === 'boolean' ? this.name : JSON.stringify(this.name)},`; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of this.additionalProperties.entries()) { + //Only unwrap those that are not already a property in the JSON object + if(["id","name","additionalProperties"].includes(String(key))) continue; + json += `"${key}": ${typeof value === 'number' || typeof value === 'boolean' ? value : JSON.stringify(value)},`; + } + } + //Remove potential last comma + return `${json.charAt(json.length-1) === ',' ? json.slice(0, json.length-1) : json}}`; + } + + public static unmarshal(json: string | object): PetCategory { + const obj = typeof json === "object" ? json : JSON.parse(json); + const instance = new PetCategory({} as any); + + if (obj["id"] !== undefined) { + instance.id = obj["id"]; + } + if (obj["name"] !== undefined) { + instance.name = obj["name"]; + } + + instance.additionalProperties = new Map(); + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["id","name","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties.set(key, value as any); + } + return instance; + } + 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 ?? {})}; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { PetCategory }; \ No newline at end of file diff --git a/test/runtime/typescript/src/openapi-path-organization/channels/payload/PetOrder.ts b/test/runtime/typescript/src/openapi-path-organization/channels/payload/PetOrder.ts new file mode 100644 index 00000000..ec225b6b --- /dev/null +++ b/test/runtime/typescript/src/openapi-path-organization/channels/payload/PetOrder.ts @@ -0,0 +1,141 @@ +import {Status} from './Status'; +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +/** + * 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: { + id?: number, + petId?: number, + quantity?: number, + shipDate?: Date, + status?: Status, + complete?: boolean, + additionalProperties?: Record, + }) { + 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 marshal() : string { + let json = '{' + if(this.id !== undefined) { + json += `"id": ${typeof this.id === 'number' || typeof this.id === 'boolean' ? this.id : JSON.stringify(this.id)},`; + } + if(this.petId !== undefined) { + json += `"petId": ${typeof this.petId === 'number' || typeof this.petId === 'boolean' ? this.petId : JSON.stringify(this.petId)},`; + } + if(this.quantity !== undefined) { + json += `"quantity": ${typeof this.quantity === 'number' || typeof this.quantity === 'boolean' ? this.quantity : JSON.stringify(this.quantity)},`; + } + if(this.shipDate !== undefined) { + json += `"shipDate": ${typeof this.shipDate === 'number' || typeof this.shipDate === 'boolean' ? this.shipDate : JSON.stringify(this.shipDate)},`; + } + if(this.status !== undefined) { + json += `"status": ${typeof this.status === 'number' || typeof this.status === 'boolean' ? this.status : JSON.stringify(this.status)},`; + } + if(this.complete !== undefined) { + json += `"complete": ${typeof this.complete === 'number' || typeof this.complete === 'boolean' ? this.complete : JSON.stringify(this.complete)},`; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of this.additionalProperties.entries()) { + //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}": ${typeof value === 'number' || typeof value === 'boolean' ? value : JSON.stringify(value)},`; + } + } + //Remove potential last comma + return `${json.charAt(json.length-1) === ',' ? json.slice(0, json.length-1) : json}}`; + } + + public static unmarshal(json: string | object): PetOrder { + const obj = typeof json === "object" ? json : JSON.parse(json); + const instance = new PetOrder({} as any); + + if (obj["id"] !== undefined) { + instance.id = obj["id"]; + } + if (obj["petId"] !== undefined) { + instance.petId = obj["petId"]; + } + if (obj["quantity"] !== undefined) { + instance.quantity = obj["quantity"]; + } + if (obj["shipDate"] !== undefined) { + instance.shipDate = obj["shipDate"] == null ? undefined : new Date(obj["shipDate"]); + } + if (obj["status"] !== undefined) { + instance.status = obj["status"]; + } + if (obj["complete"] !== undefined) { + instance.complete = obj["complete"]; + } + + instance.additionalProperties = new Map(); + 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.set(key, value as any); + } + return instance; + } + 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 ?? {})}; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { PetOrder }; \ No newline at end of file diff --git a/test/runtime/typescript/src/openapi-path-organization/channels/payload/PetTag.ts b/test/runtime/typescript/src/openapi-path-organization/channels/payload/PetTag.ts new file mode 100644 index 00000000..6a7dab71 --- /dev/null +++ b/test/runtime/typescript/src/openapi-path-organization/channels/payload/PetTag.ts @@ -0,0 +1,89 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +/** + * A tag for a pet + */ +class PetTag { + private _id?: number; + private _name?: string; + private _additionalProperties?: Record; + + constructor(input: { + id?: number, + name?: string, + additionalProperties?: Record, + }) { + 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 marshal() : string { + let json = '{' + if(this.id !== undefined) { + json += `"id": ${typeof this.id === 'number' || typeof this.id === 'boolean' ? this.id : JSON.stringify(this.id)},`; + } + if(this.name !== undefined) { + json += `"name": ${typeof this.name === 'number' || typeof this.name === 'boolean' ? this.name : JSON.stringify(this.name)},`; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of this.additionalProperties.entries()) { + //Only unwrap those that are not already a property in the JSON object + if(["id","name","additionalProperties"].includes(String(key))) continue; + json += `"${key}": ${typeof value === 'number' || typeof value === 'boolean' ? value : JSON.stringify(value)},`; + } + } + //Remove potential last comma + return `${json.charAt(json.length-1) === ',' ? json.slice(0, json.length-1) : json}}`; + } + + public static unmarshal(json: string | object): PetTag { + const obj = typeof json === "object" ? json : JSON.parse(json); + const instance = new PetTag({} as any); + + if (obj["id"] !== undefined) { + instance.id = obj["id"]; + } + if (obj["name"] !== undefined) { + instance.name = obj["name"]; + } + + instance.additionalProperties = new Map(); + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["id","name","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties.set(key, value as any); + } + return instance; + } + 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 ?? {})}; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { PetTag }; \ No newline at end of file diff --git a/test/runtime/typescript/src/openapi-path-organization/channels/payload/Status.ts b/test/runtime/typescript/src/openapi-path-organization/channels/payload/Status.ts new file mode 100644 index 00000000..28dc3f54 --- /dev/null +++ b/test/runtime/typescript/src/openapi-path-organization/channels/payload/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/src/openapi-tag-organization/channels/headers/FindPetsByStatusAndCategoryHeaders.ts b/test/runtime/typescript/src/openapi-tag-organization/channels/headers/FindPetsByStatusAndCategoryHeaders.ts new file mode 100644 index 00000000..9ab9e878 --- /dev/null +++ b/test/runtime/typescript/src/openapi-tag-organization/channels/headers/FindPetsByStatusAndCategoryHeaders.ts @@ -0,0 +1,76 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +class FindPetsByStatusAndCategoryHeaders { + private _xRequestId?: string; + private _acceptLanguage?: string; + + constructor(input: { + xRequestId?: string, + acceptLanguage?: string, + }) { + this._xRequestId = input.xRequestId; + this._acceptLanguage = input.acceptLanguage; + } + + /** + * Unique request identifier for tracing + */ + get xRequestId(): string | undefined { return this._xRequestId; } + set xRequestId(xRequestId: string | undefined) { this._xRequestId = xRequestId; } + + /** + * Preferred language for response messages + */ + get acceptLanguage(): string | undefined { return this._acceptLanguage; } + set acceptLanguage(acceptLanguage: string | undefined) { this._acceptLanguage = acceptLanguage; } + + public marshal() : string { + let json = '{' + if(this.xRequestId !== undefined) { + json += `"X-Request-ID": ${typeof this.xRequestId === 'number' || typeof this.xRequestId === 'boolean' ? this.xRequestId : JSON.stringify(this.xRequestId)},`; + } + if(this.acceptLanguage !== undefined) { + json += `"Accept-Language": ${typeof this.acceptLanguage === 'number' || typeof this.acceptLanguage === 'boolean' ? this.acceptLanguage : JSON.stringify(this.acceptLanguage)},`; + } + + //Remove potential last comma + return `${json.charAt(json.length-1) === ',' ? json.slice(0, json.length-1) : json}}`; + } + + public static unmarshal(json: string | object): FindPetsByStatusAndCategoryHeaders { + const obj = typeof json === "object" ? json : JSON.parse(json); + const instance = new FindPetsByStatusAndCategoryHeaders({} as any); + + if (obj["X-Request-ID"] !== undefined) { + instance.xRequestId = obj["X-Request-ID"]; + } + if (obj["Accept-Language"] !== undefined) { + instance.acceptLanguage = obj["Accept-Language"]; + } + + + return instance; + } + public static theCodeGenSchema = {"type":"object","additionalProperties":false,"properties":{"X-Request-ID":{"type":"string","format":"uuid","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$","description":"Unique request identifier for tracing"},"Accept-Language":{"type":"string","pattern":"^[a-z]{2}(-[A-Z]{2})?$","default":"en-US","description":"Preferred language for response messages"}},"$id":"FindPetsByStatusAndCategoryHeaders","$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 ?? {})}; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { FindPetsByStatusAndCategoryHeaders }; \ 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 new file mode 100644 index 00000000..6dcb861d --- /dev/null +++ b/test/runtime/typescript/src/openapi-tag-organization/channels/http_client.ts @@ -0,0 +1,1368 @@ +import {APet} from './payload/APet'; +import * as FindPetsByStatusAndCategoryResponse_200Module from './payload/FindPetsByStatusAndCategoryResponse_200'; +import {PetCategory} from './payload/PetCategory'; +import {PetTag} from './payload/PetTag'; +import {Status} from './payload/Status'; +import {ItemStatus} from './payload/ItemStatus'; +import {PetOrder} from './payload/PetOrder'; +import {AUser} from './payload/AUser'; +import {AnUploadedResponse} from './payload/AnUploadedResponse'; +import {FindPetsByStatusAndCategoryParameters} from './parameter/FindPetsByStatusAndCategoryParameters'; +import {FindPetsByStatusAndCategoryHeaders} from './headers/FindPetsByStatusAndCategoryHeaders'; + +// ============================================================================ +// Common Types - Shared across all HTTP client functions +// ============================================================================ + +/** + * Standard HTTP response interface that wraps fetch-like responses + */ +export interface HttpResponse { + ok: boolean; + status: number; + statusText: string; + headers?: Headers | Record; + json: () => Record | Promise>; +} + +/** + * Pagination info extracted from response + */ +export interface PaginationInfo { + /** Total number of items (if available from headers like X-Total-Count) */ + totalCount?: number; + /** Total number of pages (if available) */ + totalPages?: number; + /** Current page/offset */ + currentOffset?: number; + /** Items per page */ + limit?: number; + /** Next cursor (for cursor-based pagination) */ + nextCursor?: string; + /** Previous cursor */ + prevCursor?: string; + /** Whether there are more items */ + hasMore?: boolean; +} + +/** + * 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; + /** Pagination info extracted from response (if applicable) */ + pagination?: PaginationInfo; + /** Fetch the next page (if pagination is configured and more data exists) */ + getNextPage?: () => Promise>; + /** Fetch the previous page (if pagination is configured) */ + getPrevPage?: () => Promise>; + /** Check if there's a next page */ + hasNextPage?: () => boolean; + /** Check if there's a previous page */ + hasPrevPage?: () => boolean; +} + +/** + * 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; + +// ============================================================================ +// Pagination Types +// ============================================================================ + +/** + * Where to place pagination parameters + */ +export type PaginationLocation = 'query' | 'header'; + +/** + * Offset-based pagination configuration + */ +export interface OffsetPagination { + type: 'offset'; + in?: PaginationLocation; // Where to place params (default: 'query') + offset: number; + limit: number; + offsetParam?: string; // Param name for offset (default: 'offset' for query, 'X-Offset' for header) + limitParam?: string; // Param name for limit (default: 'limit' for query, 'X-Limit' for header) +} + +/** + * Cursor-based pagination configuration + */ +export interface CursorPagination { + type: 'cursor'; + in?: PaginationLocation; // Where to place params (default: 'query') + cursor?: string; + limit?: number; + cursorParam?: string; // Param name for cursor (default: 'cursor' for query, 'X-Cursor' for header) + limitParam?: string; // Param name for limit (default: 'limit' for query, 'X-Limit' for header) +} + +/** + * Page-based pagination configuration + */ +export interface PagePagination { + type: 'page'; + in?: PaginationLocation; // Where to place params (default: 'query') + page: number; + pageSize: number; + pageParam?: string; // Param name for page (default: 'page' for query, 'X-Page' for header) + pageSizeParam?: string; // Param name for page size (default: 'pageSize' for query, 'X-Page-Size' for header) +} + +/** + * Range-based pagination (typically used with headers) + * Follows RFC 7233 style: Range: items=0-24 + */ +export interface RangePagination { + type: 'range'; + in?: 'header'; // Range pagination is typically header-only + start: number; + end: number; + unit?: string; // Range unit (default: 'items') + rangeHeader?: string; // Header name (default: 'Range') +} + +/** + * Union type for all pagination methods + */ +export type PaginationConfig = OffsetPagination | CursorPagination | PagePagination | RangePagination; + +// ============================================================================ +// 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: Error) => 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: Error, params: HttpRequestParams) => Error | Promise; +} + +// ============================================================================ +// Common Request Context +// ============================================================================ + +/** + * Base context shared by all HTTP client functions + */ +export interface HttpClientContext { + server?: string; + path?: string; + + // Authentication - grouped for better autocomplete + auth?: AuthConfig; + + // Pagination configuration + pagination?: PaginationConfig; + + // Retry configuration + retry?: RetryConfig; + + // Hooks for extensibility + hooks?: HttpHooks; + + // Additional options + additionalHeaders?: Record; + + // Query parameters + queryParams?: 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 pagination parameters to URL and/or headers based on configuration + */ +function applyPagination( + pagination: PaginationConfig | undefined, + url: string, + headers: Record +): { url: string; headers: Record } { + if (!pagination) return { url, headers }; + + const location = pagination.in ?? 'query'; + const isHeader = location === 'header'; + + // Helper to get default param names based on location + const getDefaultName = (queryName: string, headerName: string) => + isHeader ? headerName : queryName; + + const queryParams = new URLSearchParams(); + const headerParams: Record = {}; + + const addParam = (name: string, value: string) => { + if (isHeader) { + headerParams[name] = value; + } else { + queryParams.append(name, value); + } + }; + + switch (pagination.type) { + case 'offset': + addParam( + pagination.offsetParam ?? getDefaultName('offset', 'X-Offset'), + String(pagination.offset) + ); + addParam( + pagination.limitParam ?? getDefaultName('limit', 'X-Limit'), + String(pagination.limit) + ); + break; + + case 'cursor': + if (pagination.cursor) { + addParam( + pagination.cursorParam ?? getDefaultName('cursor', 'X-Cursor'), + pagination.cursor + ); + } + if (pagination.limit !== undefined) { + addParam( + pagination.limitParam ?? getDefaultName('limit', 'X-Limit'), + String(pagination.limit) + ); + } + break; + + case 'page': + addParam( + pagination.pageParam ?? getDefaultName('page', 'X-Page'), + String(pagination.page) + ); + addParam( + pagination.pageSizeParam ?? getDefaultName('pageSize', 'X-Page-Size'), + String(pagination.pageSize) + ); + break; + + case 'range': { + // Range pagination is always header-based (RFC 7233 style) + const unit = pagination.unit ?? 'items'; + const headerName = pagination.rangeHeader ?? 'Range'; + headerParams[headerName] = `${unit}=${pagination.start}-${pagination.end}`; + break; + } + } + + // Apply query params to URL + const queryString = queryParams.toString(); + if (queryString) { + const separator = url.includes('?') ? '&' : '?'; + url = `${url}${separator}${queryString}`; + } + + // Merge header params + const updatedHeaders = { ...headers, ...headerParams }; + + return { url, headers: updatedHeaders }; +} + +/** + * 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: Error | 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: Error | null = null; + let lastResponse: HttpResponse | null = null; + + for (let attempt = 0; attempt <= config.maxRetries; attempt++) { + try { + if (attempt > 0) { + const delay = calculateBackoffDelay(attempt, config); + config.onRetry(attempt, delay, lastError ?? new Error('Retry attempt')); + 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 Error(`HTTP Error: ${response.status} ${response.statusText}`); + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + + if (!shouldRetry(lastError, null, config, attempt + 1)) { + throw lastError; + } + } + } + + // All retries exhausted + if (lastResponse) { + return lastResponse; + } + throw lastError ?? new Error('Request failed after retries'); +} + +/** + * Handle HTTP error status codes with standardized messages + */ +function handleHttpError(status: number, statusText: string): never { + switch (status) { + case 401: + throw new Error('Unauthorized'); + case 403: + throw new Error('Forbidden'); + case 404: + throw new Error('Not Found'); + case 500: + throw new Error('Internal Server Error'); + default: + throw new Error(`HTTP Error: ${status} ${statusText}`); + } +} + +/** + * 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; +} + +/** + * Extract pagination info from response headers + */ +function extractPaginationInfo( + headers: Record, + currentPagination?: PaginationConfig +): PaginationInfo | undefined { + const info: PaginationInfo = {}; + let hasPaginationInfo = false; + + // Common total count headers + const totalCount = headers['x-total-count'] || headers['x-total'] || headers['total-count']; + if (totalCount) { + info.totalCount = parseInt(totalCount, 10); + hasPaginationInfo = true; + } + + // Total pages + const totalPages = headers['x-total-pages'] || headers['x-page-count']; + if (totalPages) { + info.totalPages = parseInt(totalPages, 10); + hasPaginationInfo = true; + } + + // Next cursor + const nextCursor = headers['x-next-cursor'] || headers['x-cursor-next']; + if (nextCursor) { + info.nextCursor = nextCursor; + info.hasMore = true; + hasPaginationInfo = true; + } + + // Previous cursor + const prevCursor = headers['x-prev-cursor'] || headers['x-cursor-prev']; + if (prevCursor) { + info.prevCursor = prevCursor; + hasPaginationInfo = true; + } + + // Has more indicator + const hasMore = headers['x-has-more'] || headers['x-has-next']; + if (hasMore) { + info.hasMore = hasMore.toLowerCase() === 'true' || hasMore === '1'; + hasPaginationInfo = true; + } + + // Parse Link header (RFC 5988) + const linkHeader = headers['link']; + if (linkHeader) { + const links = parseLinkHeader(linkHeader); + if (links.next) { + info.hasMore = true; + hasPaginationInfo = true; + } + } + + // Include current pagination state + if (currentPagination) { + switch (currentPagination.type) { + case 'offset': + info.currentOffset = currentPagination.offset; + info.limit = currentPagination.limit; + break; + case 'cursor': + info.limit = currentPagination.limit; + break; + case 'page': + info.currentOffset = (currentPagination.page - 1) * currentPagination.pageSize; + info.limit = currentPagination.pageSize; + break; + case 'range': + info.currentOffset = currentPagination.start; + info.limit = currentPagination.end - currentPagination.start + 1; + break; + } + hasPaginationInfo = true; + } + + // Calculate hasMore based on total count + if (info.hasMore === undefined && info.totalCount !== undefined && + info.currentOffset !== undefined && info.limit !== undefined) { + info.hasMore = info.currentOffset + info.limit < info.totalCount; + } + + return hasPaginationInfo ? info : undefined; +} + +/** + * Parse RFC 5988 Link header + */ +function parseLinkHeader(header: string): Record { + const links: Record = {}; + const parts = header.split(','); + + for (const part of parts) { + const match = part.match(/<([^>]+)>;\s*rel="?([^";\s]+)"?/); + if (match) { + links[match[2]] = match[1]; + } + } + + return links; +} + +/** + * Create pagination helper functions for the response + */ +function createPaginationHelpers( + currentConfig: TContext, + paginationInfo: PaginationInfo | undefined, + requestFn: (config: TContext) => Promise> +): Pick, 'getNextPage' | 'getPrevPage' | 'hasNextPage' | 'hasPrevPage'> { + const helpers: Pick, 'getNextPage' | 'getPrevPage' | 'hasNextPage' | 'hasPrevPage'> = {}; + + if (!currentConfig.pagination) { + return helpers; + } + + const pagination = currentConfig.pagination; + + helpers.hasNextPage = () => { + if (paginationInfo?.hasMore !== undefined) return paginationInfo.hasMore; + if (paginationInfo?.nextCursor) return true; + if (paginationInfo?.totalCount !== undefined && + paginationInfo.currentOffset !== undefined && + paginationInfo.limit !== undefined) { + return paginationInfo.currentOffset + paginationInfo.limit < paginationInfo.totalCount; + } + return false; + }; + + helpers.hasPrevPage = () => { + if (paginationInfo?.prevCursor) return true; + if (paginationInfo?.currentOffset !== undefined) { + return paginationInfo.currentOffset > 0; + } + return false; + }; + + helpers.getNextPage = async () => { + let nextPagination: PaginationConfig; + + switch (pagination.type) { + case 'offset': + nextPagination = { ...pagination, offset: pagination.offset + pagination.limit }; + break; + case 'cursor': + if (!paginationInfo?.nextCursor) throw new Error('No next cursor available'); + nextPagination = { ...pagination, cursor: paginationInfo.nextCursor }; + break; + case 'page': + nextPagination = { ...pagination, page: pagination.page + 1 }; + break; + case 'range': + const rangeSize = pagination.end - pagination.start + 1; + nextPagination = { ...pagination, start: pagination.end + 1, end: pagination.end + rangeSize }; + break; + default: + throw new Error('Unsupported pagination type'); + } + + return requestFn({ ...currentConfig, pagination: nextPagination }); + }; + + helpers.getPrevPage = async () => { + let prevPagination: PaginationConfig; + + switch (pagination.type) { + case 'offset': + prevPagination = { ...pagination, offset: Math.max(0, pagination.offset - pagination.limit) }; + break; + case 'cursor': + if (!paginationInfo?.prevCursor) throw new Error('No previous cursor available'); + prevPagination = { ...pagination, cursor: paginationInfo.prevCursor }; + break; + case 'page': + prevPagination = { ...pagination, page: Math.max(1, pagination.page - 1) }; + break; + case 'range': + const size = pagination.end - pagination.start + 1; + const newStart = Math.max(0, pagination.start - size); + prevPagination = { ...pagination, start: newStart, end: newStart + size - 1 }; + break; + default: + throw new Error('Unsupported pagination type'); + } + + return requestFn({ ...currentConfig, pagination: prevPagination }); + }; + + return helpers; +} + +/** + * 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 Error('OAuth2 Client Credentials flow requires tokenUrl'); + if (!auth.clientId) throw new Error('OAuth2 Client Credentials flow requires clientId'); + break; + + case 'password': + if (!auth.tokenUrl) throw new Error('OAuth2 Password flow requires tokenUrl'); + if (!auth.clientId) throw new Error('OAuth2 Password flow requires clientId'); + if (!auth.username) throw new Error('OAuth2 Password flow requires username'); + if (!auth.password) throw new Error('OAuth2 Password flow requires password'); + 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 Error(`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 Error('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: APet; + requestHeaders?: { marshal: () => string }; +} + +/** + * HTTP POST request to /pet + */ +async function addPet(context: AddPetContext): Promise> { + // Apply defaults + const config = { + path: '/pet', + server: 'localhost:3000', + ...context, + }; + + // Validate OAuth2 config if present + if (config.auth?.type === 'oauth2' && AUTH_FEATURES.oauth2) { + validateOAuth2Config(config.auth); + } + + // Build headers + let headers = context.requestHeaders + ? applyTypedHeaders(context.requestHeaders, config.additionalHeaders) + : { 'Content-Type': 'application/json', ...config.additionalHeaders } as Record; + + // Build URL + let url = `${config.server}${config.path}`; + url = applyQueryParams(config.queryParams, url); + + // Apply pagination (can affect URL and/or headers) + const paginationResult = applyPagination(config.pagination, url, headers); + url = paginationResult.url; + headers = paginationResult.headers; + + // Apply authentication + const authResult = applyAuth(config.auth, headers, url); + headers = authResult.headers; + url = authResult.url; + + // Prepare body + const body = context.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 Error('Unauthorized'); + } + } + + // Handle error responses + if (!response.ok) { + handleHttpError(response.status, response.statusText); + } + + // Parse response + const rawData = await response.json(); + const responseData = APet.unmarshal(JSON.stringify(rawData)); + + // Extract response metadata + const responseHeaders = extractHeaders(response); + const paginationInfo = extractPaginationInfo(responseHeaders, config.pagination); + + // Build response wrapper with pagination helpers + const result: HttpClientResponse = { + data: responseData, + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + rawData, + pagination: paginationInfo, + ...createPaginationHelpers(config, paginationInfo, addPet), + }; + + return result; + + } catch (error) { + // Apply onError hook if present + if (config.hooks?.onError && error instanceof Error) { + throw await config.hooks.onError(error, requestParams); + } + throw error; + } +} + +export interface UpdatePetContext extends HttpClientContext { + payload: APet; + requestHeaders?: { marshal: () => string }; +} + +/** + * HTTP PUT request to /pet + */ +async function updatePet(context: UpdatePetContext): Promise> { + // Apply defaults + const config = { + path: '/pet', + server: 'localhost:3000', + ...context, + }; + + // Validate OAuth2 config if present + if (config.auth?.type === 'oauth2' && AUTH_FEATURES.oauth2) { + validateOAuth2Config(config.auth); + } + + // Build headers + let headers = context.requestHeaders + ? applyTypedHeaders(context.requestHeaders, config.additionalHeaders) + : { 'Content-Type': 'application/json', ...config.additionalHeaders } as Record; + + // Build URL + let url = `${config.server}${config.path}`; + url = applyQueryParams(config.queryParams, url); + + // Apply pagination (can affect URL and/or headers) + const paginationResult = applyPagination(config.pagination, url, headers); + url = paginationResult.url; + headers = paginationResult.headers; + + // Apply authentication + const authResult = applyAuth(config.auth, headers, url); + headers = authResult.headers; + url = authResult.url; + + // Prepare body + const body = context.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 Error('Unauthorized'); + } + } + + // Handle error responses + if (!response.ok) { + handleHttpError(response.status, response.statusText); + } + + // Parse response + const rawData = await response.json(); + const responseData = APet.unmarshal(JSON.stringify(rawData)); + + // Extract response metadata + const responseHeaders = extractHeaders(response); + const paginationInfo = extractPaginationInfo(responseHeaders, config.pagination); + + // Build response wrapper with pagination helpers + const result: HttpClientResponse = { + data: responseData, + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + rawData, + pagination: paginationInfo, + ...createPaginationHelpers(config, paginationInfo, updatePet), + }; + + return result; + + } catch (error) { + // Apply onError hook if present + if (config.hooks?.onError && error instanceof Error) { + throw await config.hooks.onError(error, requestParams); + } + throw error; + } +} + +export interface FindPetsByStatusAndCategoryContext extends HttpClientContext { + parameters: { getChannelWithParameters: (path: string) => string }; + requestHeaders?: { marshal: () => string }; +} + +/** + * Find pets by status and category with additional filtering options + */ +async function findPetsByStatusAndCategory(context: FindPetsByStatusAndCategoryContext): Promise> { + // Apply defaults + const config = { + path: '/pet/findByStatus/{status}/{categoryId}', + server: 'localhost:3000', + ...context, + }; + + // Validate OAuth2 config if present + if (config.auth?.type === 'oauth2' && AUTH_FEATURES.oauth2) { + validateOAuth2Config(config.auth); + } + + // Build headers + let headers = context.requestHeaders + ? applyTypedHeaders(context.requestHeaders, config.additionalHeaders) + : { 'Content-Type': 'application/json', ...config.additionalHeaders } as Record; + + // Build URL + let url = buildUrlWithParameters(config.server, '/pet/findByStatus/{status}/{categoryId}', context.parameters); + url = applyQueryParams(config.queryParams, url); + + // Apply pagination (can affect URL and/or headers) + const paginationResult = applyPagination(config.pagination, url, headers); + url = paginationResult.url; + headers = paginationResult.headers; + + // 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 Error('Unauthorized'); + } + } + + // Handle error responses + if (!response.ok) { + handleHttpError(response.status, response.statusText); + } + + // Parse response + const rawData = await response.json(); + const responseData = FindPetsByStatusAndCategoryResponse_200Module.unmarshal(JSON.stringify(rawData)); + + // Extract response metadata + const responseHeaders = extractHeaders(response); + const paginationInfo = extractPaginationInfo(responseHeaders, config.pagination); + + // Build response wrapper with pagination helpers + const result: HttpClientResponse = { + data: responseData, + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + rawData, + pagination: paginationInfo, + ...createPaginationHelpers(config, paginationInfo, findPetsByStatusAndCategory), + }; + + return result; + + } catch (error) { + // Apply onError hook if present + if (config.hooks?.onError && error instanceof Error) { + throw await config.hooks.onError(error, requestParams); + } + throw error; + } +} + +export { addPet, updatePet, findPetsByStatusAndCategory }; diff --git a/test/runtime/typescript/src/openapi-tag-organization/channels/index.ts b/test/runtime/typescript/src/openapi-tag-organization/channels/index.ts new file mode 100644 index 00000000..6dd96f3b --- /dev/null +++ b/test/runtime/typescript/src/openapi-tag-organization/channels/index.ts @@ -0,0 +1,9 @@ +import * as internal_http_client from './http_client'; + +export const http_client = { + pet: { + addPet: internal_http_client.addPet, + updatePet: internal_http_client.updatePet, + findPetsByStatusAndCategory: internal_http_client.findPetsByStatusAndCategory + } +} as const; diff --git a/test/runtime/typescript/src/openapi-tag-organization/channels/parameter/FindPetsByStatusAndCategoryParameters.ts b/test/runtime/typescript/src/openapi-tag-organization/channels/parameter/FindPetsByStatusAndCategoryParameters.ts new file mode 100644 index 00000000..f90e6da9 --- /dev/null +++ b/test/runtime/typescript/src/openapi-tag-organization/channels/parameter/FindPetsByStatusAndCategoryParameters.ts @@ -0,0 +1,391 @@ +import {Status} from './Status'; +import {SortBy} from './SortBy'; +import {SortOrder} from './SortOrder'; +import {Format} from './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: { + status: Status, + categoryId: number, + limit?: number, + offset?: number, + sortBy?: SortBy, + sortOrder?: SortOrder, + tags?: string[], + includePetDetails?: boolean, + format?: Format, + }) { + 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 }; \ No newline at end of file diff --git a/test/runtime/typescript/src/openapi-tag-organization/channels/parameter/Format.ts b/test/runtime/typescript/src/openapi-tag-organization/channels/parameter/Format.ts new file mode 100644 index 00000000..53f45e9c --- /dev/null +++ b/test/runtime/typescript/src/openapi-tag-organization/channels/parameter/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-tag-organization/channels/parameter/SortBy.ts b/test/runtime/typescript/src/openapi-tag-organization/channels/parameter/SortBy.ts new file mode 100644 index 00000000..b267a9d1 --- /dev/null +++ b/test/runtime/typescript/src/openapi-tag-organization/channels/parameter/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-tag-organization/channels/parameter/SortOrder.ts b/test/runtime/typescript/src/openapi-tag-organization/channels/parameter/SortOrder.ts new file mode 100644 index 00000000..efb5a9d9 --- /dev/null +++ b/test/runtime/typescript/src/openapi-tag-organization/channels/parameter/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-tag-organization/channels/parameter/Status.ts b/test/runtime/typescript/src/openapi-tag-organization/channels/parameter/Status.ts new file mode 100644 index 00000000..c82a0529 --- /dev/null +++ b/test/runtime/typescript/src/openapi-tag-organization/channels/parameter/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-tag-organization/channels/payload/APet.ts b/test/runtime/typescript/src/openapi-tag-organization/channels/payload/APet.ts new file mode 100644 index 00000000..7983585d --- /dev/null +++ b/test/runtime/typescript/src/openapi-tag-organization/channels/payload/APet.ts @@ -0,0 +1,156 @@ +import {PetCategory} from './PetCategory'; +import {PetTag} from './PetTag'; +import {Status} from './Status'; +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +/** + * 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: { + id?: number, + category?: PetCategory, + name: string, + photoUrls: string[], + tags?: PetTag[], + status?: Status, + additionalProperties?: Record, + }) { + 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 marshal() : string { + let json = '{' + if(this.id !== undefined) { + json += `"id": ${typeof this.id === 'number' || typeof this.id === 'boolean' ? this.id : JSON.stringify(this.id)},`; + } + if(this.category !== undefined) { + json += `"category": ${this.category && typeof this.category === 'object' && 'marshal' in this.category && typeof this.category.marshal === 'function' ? this.category.marshal() : JSON.stringify(this.category)},`; + } + if(this.name !== undefined) { + json += `"name": ${typeof this.name === 'number' || typeof this.name === 'boolean' ? this.name : JSON.stringify(this.name)},`; + } + if(this.photoUrls !== undefined) { + let photoUrlsJsonValues: any[] = []; + for (const unionItem of this.photoUrls) { + photoUrlsJsonValues.push(`${typeof unionItem === 'number' || typeof unionItem === 'boolean' ? unionItem : JSON.stringify(unionItem)}`); + } + json += `"photoUrls": [${photoUrlsJsonValues.join(',')}],`; + } + if(this.tags !== undefined) { + let tagsJsonValues: any[] = []; + for (const unionItem of this.tags) { + tagsJsonValues.push(`${unionItem && typeof unionItem === 'object' && 'marshal' in unionItem && typeof unionItem.marshal === 'function' ? unionItem.marshal() : JSON.stringify(unionItem)}`); + } + json += `"tags": [${tagsJsonValues.join(',')}],`; + } + if(this.status !== undefined) { + json += `"status": ${typeof this.status === 'number' || typeof this.status === 'boolean' ? this.status : JSON.stringify(this.status)},`; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of this.additionalProperties.entries()) { + //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}": ${typeof value === 'number' || typeof value === 'boolean' ? value : JSON.stringify(value)},`; + } + } + //Remove potential last comma + return `${json.charAt(json.length-1) === ',' ? json.slice(0, json.length-1) : json}}`; + } + + public static unmarshal(json: string | object): APet { + const obj = typeof json === "object" ? json : JSON.parse(json); + const instance = new APet({} as any); + + if (obj["id"] !== undefined) { + instance.id = obj["id"]; + } + if (obj["category"] !== undefined) { + instance.category = PetCategory.unmarshal(obj["category"]); + } + if (obj["name"] !== undefined) { + instance.name = obj["name"]; + } + if (obj["photoUrls"] !== undefined) { + instance.photoUrls = obj["photoUrls"]; + } + if (obj["tags"] !== undefined) { + instance.tags = obj["tags"] == null + ? undefined + : obj["tags"].map((item: any) => PetTag.unmarshal(item)); + } + if (obj["status"] !== undefined) { + instance.status = obj["status"]; + } + + instance.additionalProperties = new Map(); + 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.set(key, value as any); + } + return instance; + } + 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 ?? {})}; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { APet }; \ No newline at end of file diff --git a/test/runtime/typescript/src/openapi-tag-organization/channels/payload/AUser.ts b/test/runtime/typescript/src/openapi-tag-organization/channels/payload/AUser.ts new file mode 100644 index 00000000..939e139a --- /dev/null +++ b/test/runtime/typescript/src/openapi-tag-organization/channels/payload/AUser.ts @@ -0,0 +1,164 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +/** + * 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: { + id?: number, + username?: string, + firstName?: string, + lastName?: string, + email?: string, + password?: string, + phone?: string, + userStatus?: number, + additionalProperties?: Record, + }) { + 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 marshal() : string { + let json = '{' + if(this.id !== undefined) { + json += `"id": ${typeof this.id === 'number' || typeof this.id === 'boolean' ? this.id : JSON.stringify(this.id)},`; + } + if(this.username !== undefined) { + json += `"username": ${typeof this.username === 'number' || typeof this.username === 'boolean' ? this.username : JSON.stringify(this.username)},`; + } + if(this.firstName !== undefined) { + json += `"firstName": ${typeof this.firstName === 'number' || typeof this.firstName === 'boolean' ? this.firstName : JSON.stringify(this.firstName)},`; + } + if(this.lastName !== undefined) { + json += `"lastName": ${typeof this.lastName === 'number' || typeof this.lastName === 'boolean' ? this.lastName : JSON.stringify(this.lastName)},`; + } + if(this.email !== undefined) { + json += `"email": ${typeof this.email === 'number' || typeof this.email === 'boolean' ? this.email : JSON.stringify(this.email)},`; + } + if(this.password !== undefined) { + json += `"password": ${typeof this.password === 'number' || typeof this.password === 'boolean' ? this.password : JSON.stringify(this.password)},`; + } + if(this.phone !== undefined) { + json += `"phone": ${typeof this.phone === 'number' || typeof this.phone === 'boolean' ? this.phone : JSON.stringify(this.phone)},`; + } + if(this.userStatus !== undefined) { + json += `"userStatus": ${typeof this.userStatus === 'number' || typeof this.userStatus === 'boolean' ? this.userStatus : JSON.stringify(this.userStatus)},`; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of this.additionalProperties.entries()) { + //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}": ${typeof value === 'number' || typeof value === 'boolean' ? value : JSON.stringify(value)},`; + } + } + //Remove potential last comma + return `${json.charAt(json.length-1) === ',' ? json.slice(0, json.length-1) : json}}`; + } + + public static unmarshal(json: string | object): AUser { + const obj = typeof json === "object" ? json : JSON.parse(json); + const instance = new AUser({} as any); + + if (obj["id"] !== undefined) { + instance.id = obj["id"]; + } + if (obj["username"] !== undefined) { + instance.username = obj["username"]; + } + if (obj["firstName"] !== undefined) { + instance.firstName = obj["firstName"]; + } + if (obj["lastName"] !== undefined) { + instance.lastName = obj["lastName"]; + } + if (obj["email"] !== undefined) { + instance.email = obj["email"]; + } + if (obj["password"] !== undefined) { + instance.password = obj["password"]; + } + if (obj["phone"] !== undefined) { + instance.phone = obj["phone"]; + } + if (obj["userStatus"] !== undefined) { + instance.userStatus = obj["userStatus"]; + } + + instance.additionalProperties = new Map(); + 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.set(key, value as any); + } + return instance; + } + 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 ?? {})}; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { AUser }; \ No newline at end of file diff --git a/test/runtime/typescript/src/openapi-tag-organization/channels/payload/AnUploadedResponse.ts b/test/runtime/typescript/src/openapi-tag-organization/channels/payload/AnUploadedResponse.ts new file mode 100644 index 00000000..eaf68676 --- /dev/null +++ b/test/runtime/typescript/src/openapi-tag-organization/channels/payload/AnUploadedResponse.ts @@ -0,0 +1,101 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +/** + * Describes the result of uploading an image resource + */ +class AnUploadedResponse { + private _code?: number; + private _type?: string; + private _message?: string; + private _additionalProperties?: Record; + + constructor(input: { + code?: number, + type?: string, + message?: string, + additionalProperties?: Record, + }) { + 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 marshal() : string { + let json = '{' + if(this.code !== undefined) { + json += `"code": ${typeof this.code === 'number' || typeof this.code === 'boolean' ? this.code : JSON.stringify(this.code)},`; + } + if(this.type !== undefined) { + json += `"type": ${typeof this.type === 'number' || typeof this.type === 'boolean' ? this.type : JSON.stringify(this.type)},`; + } + if(this.message !== undefined) { + json += `"message": ${typeof this.message === 'number' || typeof this.message === 'boolean' ? this.message : JSON.stringify(this.message)},`; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of this.additionalProperties.entries()) { + //Only unwrap those that are not already a property in the JSON object + if(["code","type","message","additionalProperties"].includes(String(key))) continue; + json += `"${key}": ${typeof value === 'number' || typeof value === 'boolean' ? value : JSON.stringify(value)},`; + } + } + //Remove potential last comma + return `${json.charAt(json.length-1) === ',' ? json.slice(0, json.length-1) : json}}`; + } + + public static unmarshal(json: string | object): AnUploadedResponse { + const obj = typeof json === "object" ? json : JSON.parse(json); + const instance = new AnUploadedResponse({} as any); + + if (obj["code"] !== undefined) { + instance.code = obj["code"]; + } + if (obj["type"] !== undefined) { + instance.type = obj["type"]; + } + if (obj["message"] !== undefined) { + instance.message = obj["message"]; + } + + instance.additionalProperties = new Map(); + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["code","type","message","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties.set(key, value as any); + } + return instance; + } + 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 ?? {})}; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { AnUploadedResponse }; \ No newline at end of file diff --git a/test/runtime/typescript/src/openapi-tag-organization/channels/payload/FindPetsByStatusAndCategoryResponse_200.ts b/test/runtime/typescript/src/openapi-tag-organization/channels/payload/FindPetsByStatusAndCategoryResponse_200.ts new file mode 100644 index 00000000..316729c0 --- /dev/null +++ b/test/runtime/typescript/src/openapi-tag-organization/channels/payload/FindPetsByStatusAndCategoryResponse_200.ts @@ -0,0 +1,45 @@ +import {APet} from './APet'; +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} 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 ?? {})}; + 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-tag-organization/channels/payload/ItemStatus.ts b/test/runtime/typescript/src/openapi-tag-organization/channels/payload/ItemStatus.ts new file mode 100644 index 00000000..706c5beb --- /dev/null +++ b/test/runtime/typescript/src/openapi-tag-organization/channels/payload/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-tag-organization/channels/payload/PetCategory.ts b/test/runtime/typescript/src/openapi-tag-organization/channels/payload/PetCategory.ts new file mode 100644 index 00000000..56f53566 --- /dev/null +++ b/test/runtime/typescript/src/openapi-tag-organization/channels/payload/PetCategory.ts @@ -0,0 +1,89 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +/** + * A category for a pet + */ +class PetCategory { + private _id?: number; + private _name?: string; + private _additionalProperties?: Record; + + constructor(input: { + id?: number, + name?: string, + additionalProperties?: Record, + }) { + 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 marshal() : string { + let json = '{' + if(this.id !== undefined) { + json += `"id": ${typeof this.id === 'number' || typeof this.id === 'boolean' ? this.id : JSON.stringify(this.id)},`; + } + if(this.name !== undefined) { + json += `"name": ${typeof this.name === 'number' || typeof this.name === 'boolean' ? this.name : JSON.stringify(this.name)},`; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of this.additionalProperties.entries()) { + //Only unwrap those that are not already a property in the JSON object + if(["id","name","additionalProperties"].includes(String(key))) continue; + json += `"${key}": ${typeof value === 'number' || typeof value === 'boolean' ? value : JSON.stringify(value)},`; + } + } + //Remove potential last comma + return `${json.charAt(json.length-1) === ',' ? json.slice(0, json.length-1) : json}}`; + } + + public static unmarshal(json: string | object): PetCategory { + const obj = typeof json === "object" ? json : JSON.parse(json); + const instance = new PetCategory({} as any); + + if (obj["id"] !== undefined) { + instance.id = obj["id"]; + } + if (obj["name"] !== undefined) { + instance.name = obj["name"]; + } + + instance.additionalProperties = new Map(); + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["id","name","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties.set(key, value as any); + } + return instance; + } + 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 ?? {})}; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { PetCategory }; \ No newline at end of file diff --git a/test/runtime/typescript/src/openapi-tag-organization/channels/payload/PetOrder.ts b/test/runtime/typescript/src/openapi-tag-organization/channels/payload/PetOrder.ts new file mode 100644 index 00000000..ec225b6b --- /dev/null +++ b/test/runtime/typescript/src/openapi-tag-organization/channels/payload/PetOrder.ts @@ -0,0 +1,141 @@ +import {Status} from './Status'; +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +/** + * 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: { + id?: number, + petId?: number, + quantity?: number, + shipDate?: Date, + status?: Status, + complete?: boolean, + additionalProperties?: Record, + }) { + 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 marshal() : string { + let json = '{' + if(this.id !== undefined) { + json += `"id": ${typeof this.id === 'number' || typeof this.id === 'boolean' ? this.id : JSON.stringify(this.id)},`; + } + if(this.petId !== undefined) { + json += `"petId": ${typeof this.petId === 'number' || typeof this.petId === 'boolean' ? this.petId : JSON.stringify(this.petId)},`; + } + if(this.quantity !== undefined) { + json += `"quantity": ${typeof this.quantity === 'number' || typeof this.quantity === 'boolean' ? this.quantity : JSON.stringify(this.quantity)},`; + } + if(this.shipDate !== undefined) { + json += `"shipDate": ${typeof this.shipDate === 'number' || typeof this.shipDate === 'boolean' ? this.shipDate : JSON.stringify(this.shipDate)},`; + } + if(this.status !== undefined) { + json += `"status": ${typeof this.status === 'number' || typeof this.status === 'boolean' ? this.status : JSON.stringify(this.status)},`; + } + if(this.complete !== undefined) { + json += `"complete": ${typeof this.complete === 'number' || typeof this.complete === 'boolean' ? this.complete : JSON.stringify(this.complete)},`; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of this.additionalProperties.entries()) { + //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}": ${typeof value === 'number' || typeof value === 'boolean' ? value : JSON.stringify(value)},`; + } + } + //Remove potential last comma + return `${json.charAt(json.length-1) === ',' ? json.slice(0, json.length-1) : json}}`; + } + + public static unmarshal(json: string | object): PetOrder { + const obj = typeof json === "object" ? json : JSON.parse(json); + const instance = new PetOrder({} as any); + + if (obj["id"] !== undefined) { + instance.id = obj["id"]; + } + if (obj["petId"] !== undefined) { + instance.petId = obj["petId"]; + } + if (obj["quantity"] !== undefined) { + instance.quantity = obj["quantity"]; + } + if (obj["shipDate"] !== undefined) { + instance.shipDate = obj["shipDate"] == null ? undefined : new Date(obj["shipDate"]); + } + if (obj["status"] !== undefined) { + instance.status = obj["status"]; + } + if (obj["complete"] !== undefined) { + instance.complete = obj["complete"]; + } + + instance.additionalProperties = new Map(); + 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.set(key, value as any); + } + return instance; + } + 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 ?? {})}; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { PetOrder }; \ No newline at end of file diff --git a/test/runtime/typescript/src/openapi-tag-organization/channels/payload/PetTag.ts b/test/runtime/typescript/src/openapi-tag-organization/channels/payload/PetTag.ts new file mode 100644 index 00000000..6a7dab71 --- /dev/null +++ b/test/runtime/typescript/src/openapi-tag-organization/channels/payload/PetTag.ts @@ -0,0 +1,89 @@ +import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; +import {default as addFormats} from 'ajv-formats'; +/** + * A tag for a pet + */ +class PetTag { + private _id?: number; + private _name?: string; + private _additionalProperties?: Record; + + constructor(input: { + id?: number, + name?: string, + additionalProperties?: Record, + }) { + 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 marshal() : string { + let json = '{' + if(this.id !== undefined) { + json += `"id": ${typeof this.id === 'number' || typeof this.id === 'boolean' ? this.id : JSON.stringify(this.id)},`; + } + if(this.name !== undefined) { + json += `"name": ${typeof this.name === 'number' || typeof this.name === 'boolean' ? this.name : JSON.stringify(this.name)},`; + } + if(this.additionalProperties !== undefined) { + for (const [key, value] of this.additionalProperties.entries()) { + //Only unwrap those that are not already a property in the JSON object + if(["id","name","additionalProperties"].includes(String(key))) continue; + json += `"${key}": ${typeof value === 'number' || typeof value === 'boolean' ? value : JSON.stringify(value)},`; + } + } + //Remove potential last comma + return `${json.charAt(json.length-1) === ',' ? json.slice(0, json.length-1) : json}}`; + } + + public static unmarshal(json: string | object): PetTag { + const obj = typeof json === "object" ? json : JSON.parse(json); + const instance = new PetTag({} as any); + + if (obj["id"] !== undefined) { + instance.id = obj["id"]; + } + if (obj["name"] !== undefined) { + instance.name = obj["name"]; + } + + instance.additionalProperties = new Map(); + const propsToCheck = Object.entries(obj).filter((([key,]) => {return !["id","name","additionalProperties"].includes(key);})); + for (const [key, value] of propsToCheck) { + instance.additionalProperties.set(key, value as any); + } + return instance; + } + 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 ?? {})}; + addFormats(ajvInstance); + ajvInstance.addVocabulary(["xml", "example"]) + const validate = ajvInstance.compile(this.theCodeGenSchema); + return validate; + } + +} +export { PetTag }; \ No newline at end of file diff --git a/test/runtime/typescript/src/openapi-tag-organization/channels/payload/Status.ts b/test/runtime/typescript/src/openapi-tag-organization/channels/payload/Status.ts new file mode 100644 index 00000000..28dc3f54 --- /dev/null +++ b/test/runtime/typescript/src/openapi-tag-organization/channels/payload/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/organization/asyncapi-path.spec.ts b/test/runtime/typescript/test/channels/organization/asyncapi-path.spec.ts new file mode 100644 index 00000000..2e80299a --- /dev/null +++ b/test/runtime/typescript/test/channels/organization/asyncapi-path.spec.ts @@ -0,0 +1,38 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import {nats} from '../../../src/asyncapi-path-organization/channels'; +import * as rawNats from '../../../src/asyncapi-path-organization/channels/nats'; + +const normalize = (source: string): string => source.replace(/\s+/g, ''); + +describe('channels organization: asyncapi path', () => { + it('nests operation-sourced functions by channel address segments with a clean action verb as the leaf', () => { + expect(typeof nats.user).toBe('object'); + expect(typeof nats.user.signedup).toBe('object'); + expect(typeof nats.user.signedup.publish).toBe('function'); + expect(typeof nats.user.signedup.subscribe).toBe('function'); + expect(typeof nats.user.signedup.jetStreamPublish).toBe('function'); + }); + + it('collapses single-segment addresses to one level', () => { + expect(typeof nats.noparameters).toBe('object'); + expect(typeof nats.noparameters.publish).toBe('function'); + }); + + it('leaves reference the same underlying flat functions', () => { + expect(nats.user.signedup.publish).toBe(rawNats.publishToSendUserSignedup); + expect(nats.noparameters.publish).toBe(rawNats.publishToNoParameter); + }); + + it('generated barrel matches the expected reference', () => { + const generated = fs.readFileSync( + path.join(__dirname, '../../../src/asyncapi-path-organization/channels/index.ts'), + 'utf-8' + ); + const expected = fs.readFileSync( + path.join(__dirname, 'expected/asyncapi-path.index.txt'), + 'utf-8' + ); + expect(normalize(generated)).toBe(normalize(expected)); + }); +}); diff --git a/test/runtime/typescript/test/channels/organization/asyncapi-tag.spec.ts b/test/runtime/typescript/test/channels/organization/asyncapi-tag.spec.ts new file mode 100644 index 00000000..8d059aac --- /dev/null +++ b/test/runtime/typescript/test/channels/organization/asyncapi-tag.spec.ts @@ -0,0 +1,45 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import {nats} from '../../../src/asyncapi-tag-organization/channels'; +import * as rawNats from '../../../src/asyncapi-tag-organization/channels/nats'; + +const normalize = (source: string): string => source.replace(/\s+/g, ''); + +describe('channels organization: asyncapi tag', () => { + it('groups operations under their operation tag with verbatim leaf names', () => { + expect(typeof nats.user).toBe('object'); + expect(typeof nats.user.publishToSendUserSignedup).toBe('function'); + expect(typeof nats.user.subscribeToReceiveUserSignedup).toBe('function'); + expect(typeof nats.admin).toBe('object'); + expect(typeof nats.admin.publishToSendAdminAlert).toBe('function'); + }); + + it('places operations without a tag under the untagged bucket', () => { + expect(typeof nats.untagged).toBe('object'); + expect(typeof nats.untagged.publishToSendSystemPing).toBe('function'); + }); + + it('leaves reference the same underlying flat functions', () => { + expect(nats.user.publishToSendUserSignedup).toBe( + rawNats.publishToSendUserSignedup + ); + expect(nats.admin.publishToSendAdminAlert).toBe( + rawNats.publishToSendAdminAlert + ); + expect(nats.untagged.publishToSendSystemPing).toBe( + rawNats.publishToSendSystemPing + ); + }); + + it('generated barrel matches the expected reference', () => { + const generated = fs.readFileSync( + path.join(__dirname, '../../../src/asyncapi-tag-organization/channels/index.ts'), + 'utf-8' + ); + const expected = fs.readFileSync( + path.join(__dirname, 'expected/asyncapi-tag.index.txt'), + 'utf-8' + ); + expect(normalize(generated)).toBe(normalize(expected)); + }); +}); diff --git a/test/runtime/typescript/test/channels/organization/expected/asyncapi-path.index.txt b/test/runtime/typescript/test/channels/organization/expected/asyncapi-path.index.txt new file mode 100644 index 00000000..8e0c07f2 --- /dev/null +++ b/test/runtime/typescript/test/channels/organization/expected/asyncapi-path.index.txt @@ -0,0 +1,56 @@ +import * as internal_nats from './nats'; + +export const nats = { + user: { + signedup: { + publish: internal_nats.publishToSendUserSignedup, + jetStreamPublish: internal_nats.jetStreamPublishToSendUserSignedup, + subscribe: internal_nats.subscribeToReceiveUserSignedup, + jetStreamPullSubscribe: internal_nats.jetStreamPullSubscribeToReceiveUserSignedup, + jetStreamPushSubscribe: internal_nats.jetStreamPushSubscriptionFromReceiveUserSignedup + } + }, + noparameters: { + publish: internal_nats.publishToNoParameter, + subscribe: internal_nats.subscribeToNoParameter, + jetStreamPullSubscribe: internal_nats.jetStreamPullSubscribeToNoParameter, + jetStreamPushSubscribe: internal_nats.jetStreamPushSubscriptionFromNoParameter, + jetStreamPublish: internal_nats.jetStreamPublishToNoParameter + }, + string: { + payload: { + publish: internal_nats.publishToSendStringPayload, + jetStreamPublish: internal_nats.jetStreamPublishToSendStringPayload, + subscribe: internal_nats.subscribeToReceiveStringPayload, + jetStreamPullSubscribe: internal_nats.jetStreamPullSubscribeToReceiveStringPayload, + jetStreamPushSubscribe: internal_nats.jetStreamPushSubscriptionFromReceiveStringPayload + } + }, + array: { + payload: { + publish: internal_nats.publishToSendArrayPayload, + jetStreamPublish: internal_nats.jetStreamPublishToSendArrayPayload, + subscribe: internal_nats.subscribeToReceiveArrayPayload, + jetStreamPullSubscribe: internal_nats.jetStreamPullSubscribeToReceiveArrayPayload, + jetStreamPushSubscribe: internal_nats.jetStreamPushSubscriptionFromReceiveArrayPayload + } + }, + union: { + payload: { + publish: internal_nats.publishToSendUnionPayload, + jetStreamPublish: internal_nats.jetStreamPublishToSendUnionPayload, + subscribe: internal_nats.subscribeToReceiveUnionPayload, + jetStreamPullSubscribe: internal_nats.jetStreamPullSubscribeToReceiveUnionPayload, + jetStreamPushSubscribe: internal_nats.jetStreamPushSubscriptionFromReceiveUnionPayload + } + }, + legacy: { + notification: { + publish: internal_nats.publishToSendLegacyNotification, + jetStreamPublish: internal_nats.jetStreamPublishToSendLegacyNotification, + subscribe: internal_nats.subscribeToReceiveLegacyNotification, + jetStreamPullSubscribe: internal_nats.jetStreamPullSubscribeToReceiveLegacyNotification, + jetStreamPushSubscribe: internal_nats.jetStreamPushSubscriptionFromReceiveLegacyNotification + } + } +} as const; diff --git a/test/runtime/typescript/test/channels/organization/expected/asyncapi-tag.index.txt b/test/runtime/typescript/test/channels/organization/expected/asyncapi-tag.index.txt new file mode 100644 index 00000000..4570badf --- /dev/null +++ b/test/runtime/typescript/test/channels/organization/expected/asyncapi-tag.index.txt @@ -0,0 +1,19 @@ +import * as internal_nats from './nats'; + +export const nats = { + user: { + publishToSendUserSignedup: internal_nats.publishToSendUserSignedup, + jetStreamPublishToSendUserSignedup: internal_nats.jetStreamPublishToSendUserSignedup, + subscribeToReceiveUserSignedup: internal_nats.subscribeToReceiveUserSignedup, + jetStreamPullSubscribeToReceiveUserSignedup: internal_nats.jetStreamPullSubscribeToReceiveUserSignedup, + jetStreamPushSubscriptionFromReceiveUserSignedup: internal_nats.jetStreamPushSubscriptionFromReceiveUserSignedup + }, + admin: { + publishToSendAdminAlert: internal_nats.publishToSendAdminAlert, + jetStreamPublishToSendAdminAlert: internal_nats.jetStreamPublishToSendAdminAlert + }, + untagged: { + publishToSendSystemPing: internal_nats.publishToSendSystemPing, + jetStreamPublishToSendSystemPing: internal_nats.jetStreamPublishToSendSystemPing + } +} as const; diff --git a/test/runtime/typescript/test/channels/organization/expected/openapi-path.index.txt b/test/runtime/typescript/test/channels/organization/expected/openapi-path.index.txt new file mode 100644 index 00000000..5a010983 --- /dev/null +++ b/test/runtime/typescript/test/channels/organization/expected/openapi-path.index.txt @@ -0,0 +1,11 @@ +import * as internal_http_client from './http_client'; + +export const http_client = { + pet: { + post: internal_http_client.addPet, + put: internal_http_client.updatePet, + findByStatus: { + get: internal_http_client.findPetsByStatusAndCategory + } + } +} as const; diff --git a/test/runtime/typescript/test/channels/organization/expected/openapi-tag.index.txt b/test/runtime/typescript/test/channels/organization/expected/openapi-tag.index.txt new file mode 100644 index 00000000..6dd96f3b --- /dev/null +++ b/test/runtime/typescript/test/channels/organization/expected/openapi-tag.index.txt @@ -0,0 +1,9 @@ +import * as internal_http_client from './http_client'; + +export const http_client = { + pet: { + addPet: internal_http_client.addPet, + updatePet: internal_http_client.updatePet, + findPetsByStatusAndCategory: internal_http_client.findPetsByStatusAndCategory + } +} as const; diff --git a/test/runtime/typescript/test/channels/organization/openapi-path.spec.ts b/test/runtime/typescript/test/channels/organization/openapi-path.spec.ts new file mode 100644 index 00000000..e615dc17 --- /dev/null +++ b/test/runtime/typescript/test/channels/organization/openapi-path.spec.ts @@ -0,0 +1,36 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import {http_client} from '../../../src/openapi-path-organization/channels'; +import * as rawHttpClient from '../../../src/openapi-path-organization/channels/http_client'; + +const normalize = (source: string): string => source.replace(/\s+/g, ''); + +describe('channels organization: openapi path', () => { + it('nests http_client operations by path segment with the HTTP method as the leaf', () => { + expect(typeof http_client.pet).toBe('object'); + expect(typeof http_client.pet.post).toBe('function'); + expect(typeof http_client.pet.put).toBe('function'); + expect(typeof http_client.pet.findByStatus).toBe('object'); + expect(typeof http_client.pet.findByStatus.get).toBe('function'); + }); + + it('leaves reference the same underlying flat functions', () => { + expect(http_client.pet.post).toBe(rawHttpClient.addPet); + expect(http_client.pet.put).toBe(rawHttpClient.updatePet); + expect(http_client.pet.findByStatus.get).toBe( + rawHttpClient.findPetsByStatusAndCategory + ); + }); + + it('generated barrel matches the expected reference', () => { + const generated = fs.readFileSync( + path.join(__dirname, '../../../src/openapi-path-organization/channels/index.ts'), + 'utf-8' + ); + const expected = fs.readFileSync( + path.join(__dirname, 'expected/openapi-path.index.txt'), + 'utf-8' + ); + expect(normalize(generated)).toBe(normalize(expected)); + }); +}); diff --git a/test/runtime/typescript/test/channels/organization/openapi-tag.spec.ts b/test/runtime/typescript/test/channels/organization/openapi-tag.spec.ts new file mode 100644 index 00000000..c4bab54e --- /dev/null +++ b/test/runtime/typescript/test/channels/organization/openapi-tag.spec.ts @@ -0,0 +1,35 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import {http_client} from '../../../src/openapi-tag-organization/channels'; +import * as rawHttpClient from '../../../src/openapi-tag-organization/channels/http_client'; + +const normalize = (source: string): string => source.replace(/\s+/g, ''); + +describe('channels organization: openapi tag', () => { + it('groups http_client operations under their tag with verbatim leaf names', () => { + expect(typeof http_client.pet).toBe('object'); + expect(typeof http_client.pet.addPet).toBe('function'); + expect(typeof http_client.pet.updatePet).toBe('function'); + expect(typeof http_client.pet.findPetsByStatusAndCategory).toBe('function'); + }); + + it('leaves reference the same underlying flat functions', () => { + expect(http_client.pet.addPet).toBe(rawHttpClient.addPet); + expect(http_client.pet.updatePet).toBe(rawHttpClient.updatePet); + expect(http_client.pet.findPetsByStatusAndCategory).toBe( + rawHttpClient.findPetsByStatusAndCategory + ); + }); + + it('generated barrel matches the expected reference', () => { + const generated = fs.readFileSync( + path.join(__dirname, '../../../src/openapi-tag-organization/channels/index.ts'), + 'utf-8' + ); + const expected = fs.readFileSync( + path.join(__dirname, 'expected/openapi-tag.index.txt'), + 'utf-8' + ); + expect(normalize(generated)).toBe(normalize(expected)); + }); +});