From 5910ad811c46a63f85fb3df065fd6e0639b3103c Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Tue, 27 May 2025 16:34:02 +0200 Subject: [PATCH 1/2] wip --- docs/generators/README.md | 2 +- docs/generators/payloads.md | 9 +- docs/inputs/asyncapi.md | 10 + docs/inputs/openapi.md | 12 +- examples/README.md | 16 +- src/codegen/generators/helpers/payloads.ts | 159 ------- src/codegen/generators/typescript/payloads.ts | 239 ++++++++-- .../inputs/asyncapi/generators/payloads.ts | 151 ++++++ .../inputs/openapi/generators/payloads.ts | 276 +++++++++++ src/codegen/inputs/openapi/index.ts | 1 + src/codegen/types.ts | 1 + src/codegen/utils.ts | 7 + .../generators/helpers/payloads.spec.ts | 67 --- .../__snapshots__/payload.spec.ts.snap | 10 +- .../generators/typescript/payload.spec.ts | 428 ++++++++++++++++++ 15 files changed, 1095 insertions(+), 293 deletions(-) delete mode 100644 src/codegen/generators/helpers/payloads.ts create mode 100644 src/codegen/inputs/asyncapi/generators/payloads.ts create mode 100644 src/codegen/inputs/openapi/generators/payloads.ts delete mode 100644 test/codegen/generators/helpers/payloads.spec.ts diff --git a/docs/generators/README.md b/docs/generators/README.md index f96db7dd..9479baf2 100644 --- a/docs/generators/README.md +++ b/docs/generators/README.md @@ -20,7 +20,7 @@ All available generators, across languages and inputs: | **Inputs** | [`payloads`](./payloads.md) | [`parameters`](./parameters.md) | [`headers`](./headers.md) | [`types`](./types.md) | [`channels`](./channels.md) | [`client`](./client.md) | [`custom`](./custom.md) | |---|---|---|---|---|---|---|---| | AsyncAPI | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | ✔️ | -| OpenAPI | ➗ | ➗ | ✔️ | ➗ | ➗ | ➗ | ✔️ | +| OpenAPI | ✔️ | ➗ | ✔️ | ➗ | ➗ | ➗ | ✔️ | | **Languages** | [`payloads`](./payloads.md) | [`parameters`](./parameters.md) | [`headers`](./headers.md) | [`types`](./types.md) | [`channels`](./channels.md) | [`client`](./client.md) | [`custom`](./custom.md) | |---|---|---|---|---|---|---|---| diff --git a/docs/generators/payloads.md b/docs/generators/payloads.md index 60384ff1..81f1209c 100644 --- a/docs/generators/payloads.md +++ b/docs/generators/payloads.md @@ -20,17 +20,10 @@ export default { `payloads` preset is for generating models that represent typed models that can be serialized into message payloads for communication use-cases. -This is supported through the following inputs: [`asyncapi`](#inputs) +This is supported through the following inputs: `asyncapi`, `openapi` It supports the following languages; [`typescript`](#typescript) -## Inputs - -### `asyncapi` -The `payloads` preset with `asyncapi` input generates all the message payloads for each channel in the AsyncAPI document. - -The return type is a map of channels and the model that represent the payload. - ## Languages Each language has a set of constraints which means that some typed model types are either supported or not, or it might just be the code generation library that does not yet support it. diff --git a/docs/inputs/asyncapi.md b/docs/inputs/asyncapi.md index 0ca2ec8b..16aa1ae3 100644 --- a/docs/inputs/asyncapi.md +++ b/docs/inputs/asyncapi.md @@ -15,6 +15,16 @@ The Codegen Project was started because of a need for a code generator that; There is a lot of overlap with existing tooling, however the idea is to form the same level of quality that the OpenAPI Generator provides to OpenAPI community for HTTP, for AsyncAPI and **any** protocol (including HTTP), and the usability of the Apollo GraphQL generator. How are we gonna achieve it? Together, and a [roadmap](https://github.com/orgs/the-codegen-project/projects/1/views/2). +| **Presets** | AsyncAPI | +|---|---| +| [`payloads`](../generators/payloads.md) | ✔️ | +| [`parameters`](../generators/parameters.md) | ✔️ | +| [`headers`](../generators/headers.md) | ✔️ | +| [`types`](../generators/types.md) | ✔️ | +| [`channels`](../generators/channels.md) | ✔️ | +| [`client`](../generators/client.md) | ✔️ | +| [`custom`](../generators/custom.md) | ✔️ | + ## Basic AsyncAPI Document Structure Here's a complete basic AsyncAPI document example to get you started: diff --git a/docs/inputs/openapi.md b/docs/inputs/openapi.md index 74bfbc14..87b14026 100644 --- a/docs/inputs/openapi.md +++ b/docs/inputs/openapi.md @@ -8,7 +8,17 @@ Input support; `openapi` - OpenAPI 3.0.x - OpenAPI 3.1.x -- Swagger 2.0 (legacy support) +- OpenAPI 2.0.0 (Swagger) + +| **Presets** | OpenAPI | +|---|---| +| [`payloads`](../generators/payloads.md) | ✔️ | +| [`parameters`](../generators/parameters.md) | ➗ | +| [`headers`](../generators/headers.md) | ✔️ | +| [`types`](../generators/types.md) | ➗ | +| [`channels`](../generators/channels.md) | ➗ | +| [`client`](../generators/client.md) | ➗ | +| [`custom`](../generators/custom.md) | ✔️ | ## Basic Usage diff --git a/examples/README.md b/examples/README.md index 21b9e270..884e2ab8 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,10 +1,14 @@ # Examples -This list of examples shows how you can integrate The Codegen Project into your own projects. -## TypeScript +This directory contains practical examples demonstrating how to use The Codegen Project for different use cases. -### Simple Library -[Simple TypeScript library that use AsyncAPI as input to generate payload models that is serialized and printed in the console](./typescript-library/). +## Available Examples -### Next.JS -[Simple Next.JS server that use AsyncAPI as input to generate payload models that is serialized and printed on the website, works both client and server side](./typescript-nextjs/). +### [TypeScript Library](./typescript-library/) +A complete example showing how to generate a TypeScript library from OpenAPI specifications. + +### [TypeScript Next.js](./typescript-nextjs/) +An example demonstrating integration with Next.js applications. + +### [E-commerce Payload Models](./ecommerce-payloads/) +A comprehensive example showing how to generate TypeScript payload models from AsyncAPI specifications for an e-commerce order processing system. diff --git a/src/codegen/generators/helpers/payloads.ts b/src/codegen/generators/helpers/payloads.ts deleted file mode 100644 index 50e86d3c..00000000 --- a/src/codegen/generators/helpers/payloads.ts +++ /dev/null @@ -1,159 +0,0 @@ -/* eslint-disable security/detect-object-injection */ -import { - AsyncAPIInputProcessor, - ConstrainedObjectModel, - OutputModel -} from '@asyncapi/modelina'; -import {AsyncAPIDocumentInterface, MessageInterface} from '@asyncapi/parser'; -import {ChannelPayload, PayloadRenderType} from '../../types'; -import {pascalCase} from '../typescript/utils'; -import {findNameFromChannel, findOperationId, findReplyId} from '../../utils'; -type PayloadGenerationType = Record< - string, - {messageModel: OutputModel; messageType: string} ->; -// eslint-disable-next-line sonarjs/cognitive-complexity -export async function generateAsyncAPIPayloads( - asyncapiDocument: AsyncAPIDocumentInterface, - generator: (input: any) => Promise, - generatorConfig: GeneratorType -): Promise> { - const generatedChannelPayloads: PayloadGenerationType = {}; - const generatedOperationPayloads: PayloadGenerationType = {}; - let otherModels: ChannelPayload[] = []; - if (asyncapiDocument.allChannels().all().length > 0) { - for (const channel of asyncapiDocument.allChannels().all()) { - const processMessages = async ( - messagesToProcess: MessageInterface[], - preId: string - ): Promise< - {generatedMessages: any[]; messageType: string} | undefined - > => { - let schemaObj: any = { - type: 'object', - $schema: 'http://json-schema.org/draft-07/schema' - }; - const messages = messagesToProcess; - if (messages.length > 1) { - schemaObj.oneOf = []; - schemaObj['$id'] = pascalCase(`${preId}_Payload`); - for (const message of messages) { - if (!message.hasPayload()) { - break; - } - const schema = AsyncAPIInputProcessor.convertToInternalSchema( - message.payload() as any - ); - - const payloadId = message.id() ?? message.name(); - if (typeof schema === 'boolean') { - schemaObj.oneOf.push(schema); - } else { - const bindings = message.bindings(); - const statusCodesBindings = bindings?.get('http'); - const statusCodes = statusCodesBindings?.json()['statusCode']; - if (statusCodesBindings && statusCodes) { - schemaObj['x-modelina-has-status-codes'] = true; - schema['x-modelina-status-codes'] = statusCodes; - } - schemaObj.oneOf.push({ - ...schema, - $id: payloadId - }); - } - } - } else if (messages.length === 1) { - const message = messages[0]; - if (message.hasPayload()) { - const schema = AsyncAPIInputProcessor.convertToInternalSchema( - message.payload() as any - ); - let payloadId = message.id() ?? message.name(); - if (payloadId.includes('AnonymousSchema_')) { - payloadId = pascalCase(`${preId}_Payload`); - } - if (typeof schema === 'boolean') { - schemaObj = schema; - } else { - schemaObj = { - ...schemaObj, - ...(schema as any), - $id: payloadId - }; - } - } else { - return; - } - } else { - return; - } - const models = await generator(schemaObj); - const messageModel = models[0].model; - let messageType = messageModel.type; - // Workaround from Modelina as rendering root payload model can create simple types and `.type` is no longer valid for what we use it for - if (!(messageModel instanceof ConstrainedObjectModel)) { - messageType = messageModel.name; - } - return {generatedMessages: models, messageType}; - }; - for (const operation of channel.operations().all()) { - const operationMessages = operation.messages().all(); - const operationReply = operation.reply(); - if (operationReply) { - const operationReplyId = findReplyId( - operation, - operationReply, - channel - ); - const operationReplyGeneratedMessages = await processMessages( - operationReply.messages().all(), - operationReplyId - ); - if (operationReplyGeneratedMessages) { - generatedOperationPayloads[operationReplyId] = { - messageModel: - operationReplyGeneratedMessages.generatedMessages[0], - messageType: operationReplyGeneratedMessages.messageType - }; - } - } - const operationId = findOperationId(operation, channel); - const operationGeneratedMessages = await processMessages( - operationMessages, - operationId - ); - if (operationGeneratedMessages) { - generatedOperationPayloads[operationId] = { - messageModel: operationGeneratedMessages.generatedMessages[0], - messageType: operationGeneratedMessages.messageType - }; - } - } - const channelGeneratedMessages = await processMessages( - channel.messages().all(), - findNameFromChannel(channel) - ); - if (channelGeneratedMessages) { - generatedChannelPayloads[channel.id()] = { - messageModel: channelGeneratedMessages.generatedMessages[0], - messageType: channelGeneratedMessages.messageType - }; - } - } - } else { - const generatedModels = await generator(asyncapiDocument); - - otherModels = generatedModels.map((model) => { - return { - messageModel: model, - messageType: model.model.type - }; - }); - } - return { - channelModels: generatedChannelPayloads, - operationModels: generatedOperationPayloads, - otherModels, - generator: generatorConfig - }; -} diff --git a/src/codegen/generators/typescript/payloads.ts b/src/codegen/generators/typescript/payloads.ts index 90dcfdfd..a98f9493 100644 --- a/src/codegen/generators/typescript/payloads.ts +++ b/src/codegen/generators/typescript/payloads.ts @@ -6,16 +6,18 @@ import { ConstrainedReferenceModel, ConstrainedUnionModel, TS_COMMON_PRESET, - TypeScriptFileGenerator + TypeScriptFileGenerator, + OutputModel } from '@asyncapi/modelina'; import {GenericCodegenContext, PayloadRenderType} from '../../types'; import {AsyncAPIDocumentInterface} from '@asyncapi/parser'; -import {generateAsyncAPIPayloads} from '../helpers/payloads'; +import {processAsyncAPIPayloads, ProcessedPayloadSchemaData} from '../../inputs/asyncapi/generators/payloads'; +import {processOpenAPIPayloads} from '../../inputs/openapi/generators/payloads'; import {z} from 'zod'; import {defaultCodegenTypescriptModelinaOptions} from './utils'; import {Logger} from '../../../LoggingInterface'; import {TypeScriptRenderer} from '@asyncapi/modelina/lib/types/generators/typescript/TypeScriptRenderer'; -import {OpenAPIV2, OpenAPIV3, OpenAPIV3_1} from 'openapi-types'; +import { OpenAPIV2, OpenAPIV3, OpenAPIV3_1 } from 'openapi-types'; export const zodTypeScriptPayloadGenerator = z.object({ id: z.string().optional().default('payloads-typescript'), @@ -83,6 +85,13 @@ export interface TypeScriptPayloadContext extends GenericCodegenContext { export type TypeScriptPayloadRenderType = PayloadRenderType; +// Interface for processed payloads data (input-agnostic) +export interface ProcessedPayloadData { + channelModels: Record; + operationModels: Record; + otherModels: Array<{messageModel: OutputModel; messageType: string}>; +} + /** * Find the best possible discriminator value along side the properties using; * - Enum value @@ -291,56 +300,93 @@ ${statusCodeChecks.join('\n')} * Safe stringify that removes x- properties and circular references by assuming true */ export function safeStringify(value: any): string { - const stack: any[] = []; - let r = 0; - const replacer = (key: string, value: any) => { - // remove extension properties - if (key.startsWith('x-')) { - return; + let depth = 0; + const maxDepth = 255; + const maxRepetitions = 5; // Allow up to 5 repetitions of the same object + + function stringify(val: any, currentPath: any[] = []): any { + // Check depth limit + if (depth > maxDepth) { + return true; } - - switch (typeof value) { + + switch (typeof val) { case 'function': - return 'true'; - // is this a primitive value ? + return true; case 'boolean': case 'number': case 'string': - // primitives cannot have properties - // so these are safe to parse - return value; - default: { - // only null does not need to be stored - // for all objects check recursion first - // hopefully 255 calls are enough ... - if (!value || 255 < ++r) { - return 'true'; + return val; + case 'object': { + if (val === null) { + return null; } - - const i = stack.indexOf(value); - // all objects not already parsed - if (i < 0) { - return stack.push(value) && value; + + // Check for immediate circular reference (direct self-reference) + if (currentPath.length > 0 && currentPath[currentPath.length - 1] === val) { + return true; + } + + // Count how many times this object appears in the current path + const repetitionCount = currentPath.filter(obj => obj === val).length; + + // If we've seen this object too many times in the current path, cut it off + if (repetitionCount >= maxRepetitions) { + return true; + } + + depth++; + const newPath = [...currentPath, val]; + + let result: any; + + if (Array.isArray(val)) { + result = val.map(item => stringify(item, newPath)); + } else { + result = {}; + for (const [key, value] of Object.entries(val)) { + // Skip extension properties + if (key.startsWith('x-modelina') || key.startsWith('x-the-codegen-project') || key.startsWith('x-parser-') || key.startsWith('x-modelgen-') || key.startsWith('discriminator')) { + continue; + } + result[key] = stringify(value, newPath); + } } - // all others are duplicated or cyclic - // let them through - return 'true'; + + depth--; + return result; } + case 'undefined': + return undefined; + default: + return true; } - }; + } + + return JSON.stringify(stringify(value)); +} - return JSON.stringify(value, replacer); +// Core generator function that works with processed data +export async function generateTypescriptPayloadsCore( + processedData: ProcessedPayloadData, + generator: TypeScriptPayloadGeneratorInternal +): Promise { + // The models are already generated by the input processors, + // so we just need to return them in the expected format + return { + channelModels: processedData.channelModels, + operationModels: processedData.operationModels, + otherModels: processedData.otherModels, + generator + }; } +// Core generator function that works with processed schema data // eslint-disable-next-line sonarjs/cognitive-complexity -export async function generateTypescriptPayload( - context: TypeScriptPayloadContext +export async function generateTypescriptPayloadsCoreFromSchemas( + processedSchemaData: ProcessedPayloadSchemaData, + generator: TypeScriptPayloadGeneratorInternal ): Promise { - const {asyncapiDocument, inputType, generator} = context; - if (inputType === 'asyncapi' && asyncapiDocument === undefined) { - throw new Error('Expected AsyncAPI input, was not given'); - } - const modelinaGenerator = new TypeScriptFileGenerator({ ...defaultCodegenTypescriptModelinaOptions, presets: [ @@ -432,15 +478,116 @@ ${renderUnionUnmarshalByStatusCode(model)}`; rawPropertyNames: generator.rawPropertyNames, useJavascriptReservedKeywords: generator.useForJavaScript }); - return generateAsyncAPIPayloads( - asyncapiDocument!, - (input) => - modelinaGenerator.generateToFiles( - input, + + const channelModels: Record = {}; + const operationModels: Record = {}; + const otherModels: Array<{messageModel: OutputModel; messageType: string}> = []; + + // Generate models for channel payloads + for (const [channelId, schemaData] of Object.entries(processedSchemaData.channelPayloads)) { + if (schemaData) { + const models = await modelinaGenerator.generateToFiles( + schemaData.schema, + generator.outputPath, + {exportType: 'named'}, + true + ); + if (models.length > 0) { + const messageModel = models[0].model; + let messageType = messageModel.type; + if (!(messageModel instanceof ConstrainedObjectModel)) { + messageType = messageModel.name; + } + channelModels[channelId] = { + messageModel: models[0], + messageType + }; + } + } + } + + // Generate models for operation payloads + for (const [operationId, schemaData] of Object.entries(processedSchemaData.operationPayloads)) { + if (schemaData) { + const models = await modelinaGenerator.generateToFiles( + schemaData.schema, generator.outputPath, {exportType: 'named'}, true - ), + ); + if (models.length > 0) { + const messageModel = models[0].model; + let messageType = messageModel.type; + if (!(messageModel instanceof ConstrainedObjectModel)) { + messageType = messageModel.name; + } + operationModels[operationId] = { + messageModel: models[0], + messageType + }; + } + } + } + + // Generate models for other payloads + for (const schemaData of processedSchemaData.otherPayloads) { + const models = await modelinaGenerator.generateToFiles( + schemaData.schema, + generator.outputPath, + {exportType: 'named'}, + true + ); + for (const model of models) { + const messageModel = model.model; + let messageType = messageModel.type; + if (!(messageModel instanceof ConstrainedObjectModel)) { + messageType = messageModel.name; + } + otherModels.push({ + messageModel: model, + messageType + }); + } + } + + return { + channelModels, + operationModels, + otherModels, generator - ); + }; +} + +// Main generator function that orchestrates input processing and generation +export async function generateTypescriptPayload( + context: TypeScriptPayloadContext +): Promise { + const {asyncapiDocument, openapiDocument, inputType, generator} = context; + + let processedSchemaData: ProcessedPayloadSchemaData; + + // Process input based on type + switch (inputType) { + case 'asyncapi': { + if (!asyncapiDocument) { + throw new Error('Expected AsyncAPI input, was not given'); + } + + processedSchemaData = await processAsyncAPIPayloads(asyncapiDocument); + break; + } + case 'openapi': { + if (!openapiDocument) { + throw new Error('Expected OpenAPI input, was not given'); + } + + processedSchemaData = processOpenAPIPayloads(openapiDocument); + break; + } + default: + throw new Error(`Unsupported input type: ${inputType}`); + } + + // Generate final result using processed schema data + return generateTypescriptPayloadsCoreFromSchemas(processedSchemaData, generator); } diff --git a/src/codegen/inputs/asyncapi/generators/payloads.ts b/src/codegen/inputs/asyncapi/generators/payloads.ts new file mode 100644 index 00000000..b7b23b02 --- /dev/null +++ b/src/codegen/inputs/asyncapi/generators/payloads.ts @@ -0,0 +1,151 @@ +/* eslint-disable security/detect-object-injection */ +import { + AsyncAPIInputProcessor +} from '@asyncapi/modelina'; +import {AsyncAPIDocumentInterface, MessageInterface} from '@asyncapi/parser'; +import {pascalCase} from '../../../generators/typescript/utils'; +import {findNameFromChannel, findOperationId, findReplyId, onlyUnique} from '../../../utils'; + +// Interface for processed payload schema data +export interface ProcessedPayloadSchemaData { + channelPayloads: Record; + operationPayloads: Record; + otherPayloads: {schema: any; schemaId: string}[]; +} +const processor = new AsyncAPIInputProcessor(); +// AsyncAPI input processor +// eslint-disable-next-line sonarjs/cognitive-complexity +export async function processAsyncAPIPayloads( + asyncapiDocument: AsyncAPIDocumentInterface +): Promise { + const channelPayloads: Record = {}; + const operationPayloads: Record = {}; + const otherPayloads: {schema: any; schemaId: string}[] = []; + const processMessages = ( + messagesToProcess: MessageInterface[], + preId: string + ): {schema: any; schemaId: string} | undefined => { + let schemaObj: any = { + type: 'object', + $schema: 'http://json-schema.org/draft-07/schema' + }; + let id = preId; + const messages = messagesToProcess; + if (messages.length > 1) { + schemaObj.oneOf = []; + schemaObj['$id'] = pascalCase(`${preId}_Payload`); + for (const message of messages) { + if (!message.hasPayload()) { + break; + } + const schema = AsyncAPIInputProcessor.convertToInternalSchema( + message.payload() as any + ); + + const payloadId = message.id() ?? message.name(); + if (typeof schema === 'boolean') { + schemaObj.oneOf.push(schema); + } else { + const bindings = message.bindings(); + const statusCodesBindings = bindings?.get('http'); + const statusCodes = statusCodesBindings?.json()['statusCode']; + if (statusCodesBindings && statusCodes) { + schemaObj['x-modelina-has-status-codes'] = true; + schema['x-modelina-status-codes'] = statusCodes; + } + schemaObj.oneOf.push({ + ...schema, + $id: payloadId + }); + } + } + } else if (messages.length === 1) { + const message = messages[0]; + if (message.hasPayload()) { + const schema = AsyncAPIInputProcessor.convertToInternalSchema( + message.payload() as any + ); + if (typeof schema === 'boolean') { + schemaObj = schema; + } else { + id = message.id() ?? message.name() ?? schema['x-modelgen-inferred-name']; + if (id.includes('AnonymousSchema_')) { + id = pascalCase(`${preId}_Payload`); + } + schemaObj = { + ...schemaObj, + ...(schema as any), + $id: id + }; + } + } else { + return; + } + } else { + return; + } + + return { + schema: schemaObj, + schemaId: id ?? schemaObj.$id ?? pascalCase(`${preId}_Payload`) + }; + }; + + if (asyncapiDocument.allChannels().all().length > 0) { + for (const channel of asyncapiDocument.allChannels().all()) { + // Process operations + for (const operation of channel.operations().all()) { + const operationMessages = operation.messages().all(); + const operationReply = operation.reply(); + + if (operationReply) { + const operationReplyId = findReplyId( + operation, + operationReply, + channel + ); + const operationReplySchema = processMessages( + operationReply.messages().all(), + operationReplyId + ); + if (operationReplySchema) { + operationPayloads[operationReplyId] = operationReplySchema; + } + } + + const operationId = findOperationId(operation, channel); + const operationSchema = processMessages( + operationMessages, + operationId + ); + if (operationSchema) { + operationPayloads[operationId] = operationSchema; + } + } + + // Process channel messages + const channelSchema = processMessages( + channel.messages().all(), + findNameFromChannel(channel) + ); + if (channelSchema) { + channelPayloads[channel.id()] = channelSchema; + } + } + } else { + // Handle case where there are no channels but there might be components + for (const message of asyncapiDocument.allMessages()) { + const schemaId = message.id() ?? message.name(); + const schemaObj = processMessages([message], schemaId); + if (schemaObj) { + otherPayloads.push(schemaObj); + } + } + } + + return { + channelPayloads, + operationPayloads, + otherPayloads: onlyUnique(otherPayloads) + }; +} diff --git a/src/codegen/inputs/openapi/generators/payloads.ts b/src/codegen/inputs/openapi/generators/payloads.ts new file mode 100644 index 00000000..1e773130 --- /dev/null +++ b/src/codegen/inputs/openapi/generators/payloads.ts @@ -0,0 +1,276 @@ +/* eslint-disable sonarjs/cognitive-complexity */ +/* eslint-disable security/detect-object-injection */ +import {OpenAPIV2, OpenAPIV3, OpenAPIV3_1} from 'openapi-types'; +import {ProcessedPayloadSchemaData} from '../../asyncapi/generators/payloads'; +import {pascalCase} from '../../../generators/typescript/utils'; +import { onlyUnique } from '../../../utils'; + +// Constants +const JSON_SCHEMA_DRAFT_07 = 'http://json-schema.org/draft-07/schema'; + +// Helper function to extract schema from OpenAPI 2.0 response +function extractOpenAPI2ResponseSchema(response: OpenAPIV2.ResponseObject): any | null { + if (response.schema) { + return response.schema; + } + return null; +} + +// Helper function to extract schema from OpenAPI 3.x response content +function extractOpenAPI3ResponseSchema(response: OpenAPIV3.ResponseObject | OpenAPIV3_1.ResponseObject): any | null { + if (!response.content) { + return null; + } + + // Prioritize JSON content types + const jsonContentTypes = ['application/json', 'application/vnd.api+json', 'text/json']; + + // Fall back to any content type with a schema + for (const [contentType, mediaType] of Object.entries(response.content)) { + if (!jsonContentTypes.includes(contentType)) { + continue; + } + if (mediaType.schema) { + return mediaType.schema; + } + } + + return null; +} + +// Helper function to extract schema from OpenAPI 2.0 request body parameter +function extractOpenAPI2RequestSchema(parameters: OpenAPIV2.ParameterObject[]): any | null { + const bodyParam = parameters.find(param => param.in === 'body') as OpenAPIV2.InBodyParameterObject | undefined; + return bodyParam?.schema ?? null; +} + +// Helper function to extract schema from OpenAPI 3.x request body +function extractOpenAPI3RequestSchema(requestBody: OpenAPIV3.RequestBodyObject | OpenAPIV3_1.RequestBodyObject | undefined): any | null { + if (!requestBody?.content) { + return null; + } + + // Prioritize JSON content types + const jsonContentTypes = ['application/json', 'application/vnd.api+json', 'text/json']; + + // Fall back to any content type with a schema + for (const [contentType, mediaType] of Object.entries(requestBody.content)) { + if (!jsonContentTypes.includes(contentType)) { + continue; + } + if (mediaType.schema) { + return mediaType.schema; + } + } + + return null; +} + +// Helper function to create a union schema from multiple response schemas +function createUnionSchema(schemas: any[], baseId: string, hasStatusCodes: boolean = false): any { + if (schemas.length === 0) { + return null; + } + + if (schemas.length === 1) { + const schema = schemas[0]; + return { + ...schema, + $id: schema.$id ?? baseId, + $schema: JSON_SCHEMA_DRAFT_07 + }; + } + + const unionSchema: any = { + type: 'object', + oneOf: schemas, + $id: baseId, + $schema: JSON_SCHEMA_DRAFT_07 + }; + + if (hasStatusCodes) { + unionSchema['x-modelina-has-status-codes'] = true; + } + + return unionSchema; +} + +// Extract payload schemas from OpenAPI operations +function extractPayloadsFromOperations( + paths: OpenAPIV3.PathsObject | OpenAPIV2.PathsObject | OpenAPIV3_1.PathsObject +): { + requestPayloads: Record; + responsePayloads: Record; +} { + const requestPayloads: Record = {}; + const responsePayloads: Record = {}; + + for (const [pathKey, pathItem] of Object.entries(paths)) { + if (!pathItem) {continue;} + + for (const [method, operation] of Object.entries(pathItem)) { + if (!operation || typeof operation !== 'object') {continue;} + + const operationObj = operation as + | OpenAPIV3.OperationObject + | OpenAPIV2.OperationObject + | OpenAPIV3_1.OperationObject; + + const operationId = + operationObj.operationId ?? + `${method}${pathKey.replace(/[^a-zA-Z0-9]/g, '')}`; + + // Extract request payload schema + let requestSchema: any = null; + + // Check if this is OpenAPI 2.0 vs 3.x based on the structure + if ('parameters' in operationObj && operationObj.parameters) { + // OpenAPI 2.0 style + requestSchema = extractOpenAPI2RequestSchema(operationObj.parameters as OpenAPIV2.ParameterObject[]); + } else if ('requestBody' in operationObj && operationObj.requestBody) { + // OpenAPI 3.x style + requestSchema = extractOpenAPI3RequestSchema(operationObj.requestBody as OpenAPIV3.RequestBodyObject | OpenAPIV3_1.RequestBodyObject); + } + + if (requestSchema) { + const requestSchemaId = pascalCase(`${operationId}_Request`); + requestPayloads[operationId] = { + schema: { + ...requestSchema, + $id: requestSchemaId, + $schema: JSON_SCHEMA_DRAFT_07 + }, + schemaId: requestSchemaId + }; + } + + // Extract response payload schemas + if (operationObj.responses) { + const responseSchemas: any[] = []; + let hasStatusCodes = false; + + for (const [statusCode, response] of Object.entries(operationObj.responses)) { + if (!response || typeof response !== 'object') {continue;} + + let responseSchema: any = null; + + // Determine if this is OpenAPI 2.0 or 3.x response + if ('schema' in response) { + // OpenAPI 2.0 style + responseSchema = extractOpenAPI2ResponseSchema(response as OpenAPIV2.ResponseObject); + } else { + // OpenAPI 3.x style + responseSchema = extractOpenAPI3ResponseSchema(response as OpenAPIV3.ResponseObject | OpenAPIV3_1.ResponseObject); + } + + if (responseSchema) { + // Add status code information for proper discrimination + if (statusCode !== 'default' && !isNaN(Number(statusCode))) { + hasStatusCodes = true; + responseSchema['x-modelina-status-codes'] = { + code: Number(statusCode) + }; + } + + responseSchemas.push({ + ...responseSchema, + $id: `${operationId}_Response_${statusCode}` + }); + } + } + + if (responseSchemas.length > 0) { + const responseSchemaId = pascalCase(`${operationId}_Response`); + const unionSchema = createUnionSchema(responseSchemas, responseSchemaId, hasStatusCodes); + + if (unionSchema) { + responsePayloads[`${operationId}_Response`] = { + schema: unionSchema, + schemaId: responseSchemaId + }; + } + } + } + } + } + + return {requestPayloads, responsePayloads}; +} + +// Helper function to extract schemas from components/definitions +function extractComponentSchemas( + openapiDocument: OpenAPIV3.Document | OpenAPIV2.Document | OpenAPIV3_1.Document +): {schema: any; schemaId: string}[] { + const componentSchemas: {schema: any; schemaId: string}[] = []; + + // OpenAPI 3.x components + if ('components' in openapiDocument && openapiDocument.components?.schemas) { + for (const [schemaName, schema] of Object.entries(openapiDocument.components.schemas)) { + if (schema && typeof schema === 'object') { + componentSchemas.push({ + schema: { + ...schema, + $id: schemaName, + $schema: JSON_SCHEMA_DRAFT_07 + }, + schemaId: schemaName + }); + } + } + } + + // OpenAPI 2.0 definitions + if ('definitions' in openapiDocument && openapiDocument.definitions) { + for (const [schemaName, schema] of Object.entries(openapiDocument.definitions)) { + if (schema && typeof schema === 'object') { + componentSchemas.push({ + schema: { + ...schema, + $id: schemaName, + $schema: JSON_SCHEMA_DRAFT_07 + }, + schemaId: schemaName + }); + } + } + } + + return componentSchemas; +} + +// OpenAPI input processor +export function processOpenAPIPayloads( + openapiDocument: + | OpenAPIV3.Document + | OpenAPIV2.Document + | OpenAPIV3_1.Document +): ProcessedPayloadSchemaData { + const channelPayloads: Record = {}; + const operationPayloads: Record = {}; + const otherPayloads: {schema: any; schemaId: string}[] = []; + + // Extract request and response payloads from operations + const {requestPayloads, responsePayloads} = extractPayloadsFromOperations( + openapiDocument.paths ?? {} + ); + + // Map request payloads to operation payloads + for (const [operationId, payload] of Object.entries(requestPayloads)) { + operationPayloads[operationId] = payload; + } + + // Map response payloads to operation payloads + for (const [responseId, payload] of Object.entries(responsePayloads)) { + operationPayloads[responseId] = payload; + } + + // Extract component schemas + const componentSchemas = extractComponentSchemas(openapiDocument); + otherPayloads.push(...componentSchemas); + + return { + channelPayloads, + operationPayloads, + otherPayloads: onlyUnique(otherPayloads) + }; +} diff --git a/src/codegen/inputs/openapi/index.ts b/src/codegen/inputs/openapi/index.ts index c46f865e..3ef9d05b 100644 --- a/src/codegen/inputs/openapi/index.ts +++ b/src/codegen/inputs/openapi/index.ts @@ -1 +1,2 @@ export {loadOpenapi, loadOpenapiDocument} from './parser'; +export {processOpenAPIPayloads} from './generators/payloads'; diff --git a/src/codegen/types.ts b/src/codegen/types.ts index 62459402..2319b559 100644 --- a/src/codegen/types.ts +++ b/src/codegen/types.ts @@ -78,6 +78,7 @@ export const zodAsyncAPIGenerators = z.union([ ]); export const zodOpenAPITypeScriptGenerators = z.discriminatedUnion('preset', [ + zodTypeScriptPayloadGenerator, zodTypescriptHeadersGenerator, zodCustomGenerator ]); diff --git a/src/codegen/utils.ts b/src/codegen/utils.ts index 63a60385..d12500b3 100644 --- a/src/codegen/utils.ts +++ b/src/codegen/utils.ts @@ -165,3 +165,10 @@ export function findReplyId( ) { return `${findOperationId(operation, reply.channel() ?? channel)}_reply`; } + +export function onlyUnique(array: any[]) { + const onlyUnique = (value: any, index: number, array: any[]) => { + return array.indexOf(value) === index; + }; + return array.filter(onlyUnique); +} diff --git a/test/codegen/generators/helpers/payloads.spec.ts b/test/codegen/generators/helpers/payloads.spec.ts deleted file mode 100644 index 93a418b9..00000000 --- a/test/codegen/generators/helpers/payloads.spec.ts +++ /dev/null @@ -1,67 +0,0 @@ -import Parser from "@asyncapi/parser"; -import { generateAsyncAPIPayloads } from "../../../../src/codegen/generators/helpers/payloads"; -import { TypeScriptFileGenerator } from "@asyncapi/modelina"; - -describe('generateAsyncAPIPayloads', () => { - test('should generate model correctly', async () => { - const p = new Parser(); - const {document} = await p.parse({ - asyncapi: "3.0.0", - info: { - title: "Not example", - version: "1.0.0" - }, - channels: { - test: { - address: "test", - messages: { - testMessages: { - $ref: "#/components/messages/testMessages" - } - } - } - }, - operations: { - onTestMsg: { - action: "receive", - channel: { - $ref: "#/channels/test" - }, - messages: [ - { - $ref: "#/channels/test/messages/testMessages" - } - ] - } - }, - components: { - messages: { - testMessages: { - payload: { - $ref: "#/components/schemas/testSchema" - } - } - }, - schemas: { - testSchema: { - type: "object", - properties: { - key: { - not: { - type: "integer" - } - } - } - } - } - } - }); - - const modelinaGenerator = new TypeScriptFileGenerator(); - expect(document).not.toBeUndefined(); - const models = await generateAsyncAPIPayloads(document!, (input) => { - return modelinaGenerator.generate(input); - }, {}); - expect(Object.keys(models.channelModels).length).toEqual(1); - }); -}); diff --git a/test/codegen/generators/typescript/__snapshots__/payload.spec.ts.snap b/test/codegen/generators/typescript/__snapshots__/payload.spec.ts.snap index 72afd67d..52790599 100644 --- a/test/codegen/generators/typescript/__snapshots__/payload.spec.ts.snap +++ b/test/codegen/generators/typescript/__snapshots__/payload.spec.ts.snap @@ -203,7 +203,7 @@ export { SimpleObject2 };" exports[`payloads typescript should work with no channels 1`] = ` "import {Ajv, Options as AjvOptions, ErrorObject, ValidateFunction} from 'ajv'; import addFormats from 'ajv-formats'; -class AnonymousSchema_1 { +class SimpleObject { private _type?: 'SimpleObject' = 'SimpleObject'; private _displayName?: string; private _email?: string; @@ -252,9 +252,9 @@ class AnonymousSchema_1 { return \`\${json.charAt(json.length-1) === ',' ? json.slice(0, json.length-1) : json}}\`; } - public static unmarshal(json: string | object): AnonymousSchema_1 { + public static unmarshal(json: string | object): SimpleObject { const obj = typeof json === "object" ? json : JSON.parse(json); - const instance = new AnonymousSchema_1({} as any); + const instance = new SimpleObject({} as any); if (obj["displayName"] !== undefined) { instance.displayName = obj["displayName"]; @@ -270,7 +270,7 @@ class AnonymousSchema_1 { } return instance; } - public static theCodeGenSchema = {"type":"object","properties":{"type":{"const":"SimpleObject"},"displayName":{"type":"string","description":"Name of the user"},"email":{"type":"string","format":"email","description":"Email of the user"}}}; + public static theCodeGenSchema = {"type":"object","$schema":"http://json-schema.org/draft-07/schema","properties":{"type":{"const":"SimpleObject"},"displayName":{"type":"string","description":"Name of the user"},"email":{"type":"string","format":"email","description":"Email of the user"}},"$id":"SimpleObject"}; public static validate(context?: {data: any, ajvValidatorFunction?: ValidateFunction, ajvInstance?: Ajv, ajvOptions?: AjvOptions}): { valid: boolean; errors?: ErrorObject[]; } { const {data, ajvValidatorFunction} = context ?? {}; const parsedData = typeof data === 'string' ? JSON.parse(data) : data; @@ -288,5 +288,5 @@ class AnonymousSchema_1 { } } -export { AnonymousSchema_1 };" +export { SimpleObject };" `; diff --git a/test/codegen/generators/typescript/payload.spec.ts b/test/codegen/generators/typescript/payload.spec.ts index 9f4070ef..73ae0666 100644 --- a/test/codegen/generators/typescript/payload.spec.ts +++ b/test/codegen/generators/typescript/payload.spec.ts @@ -1,6 +1,8 @@ import path from "node:path"; import { defaultTypeScriptPayloadGenerator, generateTypescriptPayload } from "../../../../src/codegen/generators"; import { loadAsyncapiDocument } from "../../../../src/codegen/inputs/asyncapi"; +import { loadOpenapiDocument } from "../../../../src/codegen/inputs/openapi"; +import { safeStringify } from "../../../../src/codegen/generators/typescript/payloads"; describe('payloads', () => { describe('typescript', () => { @@ -73,5 +75,431 @@ describe('payloads', () => { expect(payloadNames.includes('TypescriptNatsSettings')).toEqual(true); expect(payloadNames.includes('OpenapiTypescriptFetch')).toEqual(true); }); + + it('should work with basic OpenAPI 2.0 inputs', async () => { + const parsedOpenAPIDocument = await loadOpenapiDocument(path.resolve(__dirname, '../../../configs/openapi-2.json')); + + const renderedContent = await generateTypescriptPayload({ + generator: { + ...defaultTypeScriptPayloadGenerator, + outputPath: path.resolve(__dirname, './output') + }, + inputType: 'openapi', + openapiDocument: parsedOpenAPIDocument, + dependencyOutputs: { } + }); + const modelsCreated = Object.entries(renderedContent.operationModels).map(([key, value]) => ({key, value: value.messageModel.modelName})); + expect(modelsCreated).toEqual([ + { + key: "addPet", + value: "AddPetRequest", + }, + { + key: "updatePet", + value: "UpdatePetRequest", + }, + { + key: "placeOrder", + value: "PlaceOrderRequest", + }, + { + key: "createUsersWithListInput", + value: "CreateUsersWithListInputRequest", + }, + { + key: "updateUser", + value: "UpdateUserRequest", + }, + { + key: "createUsersWithArrayInput", + value: "CreateUsersWithArrayInputRequest", + }, + { + key: "createUser", + value: "CreateUserRequest", + }, + { + key: "uploadFile_Response", + value: "UploadFileResponse_200", + }, + { + key: "findPetsByStatus_Response", + value: "FindPetsByStatusResponse_200", + }, + { + key: "findPetsByTags_Response", + value: "FindPetsByTagsResponse_200", + }, + { + key: "getPetById_Response", + value: "GetPetByIdResponse_200", + }, + { + key: "getInventory_Response", + value: "GetInventoryResponse_200", + }, + { + key: "placeOrder_Response", + value: "PlaceOrderResponse_200", + }, + { + key: "getOrderById_Response", + value: "GetOrderByIdResponse_200", + }, + { + key: "getUserByName_Response", + value: "GetUserByNameResponse_200", + }, + { + key: "loginUser_Response", + value: "LoginUserResponse_200", + }, + ]); + }); + it('should work with basic OpenAPI 3.0 inputs', async () => { + const parsedOpenAPIDocument = await loadOpenapiDocument(path.resolve(__dirname, '../../../configs/openapi-3.json')); + + const renderedContent = await generateTypescriptPayload({ + generator: { + ...defaultTypeScriptPayloadGenerator, + outputPath: path.resolve(__dirname, './output') + }, + inputType: 'openapi', + openapiDocument: parsedOpenAPIDocument, + dependencyOutputs: { } + }); + const modelsCreated = Object.entries(renderedContent.operationModels).map(([key, value]) => ({key, value: value.messageModel.modelName})); + expect(modelsCreated).toEqual([ + { + key: "addPet", + value: "APet", + }, + { + key: "updatePet", + value: "APet", + }, + { + key: "placeOrder", + value: "PetOrder", + }, + { + key: "createUser", + value: "AUser", + }, + { + key: "createUsersWithArrayInput", + value: "CreateUsersWithArrayInputRequest", + }, + { + key: "createUsersWithListInput", + value: "CreateUsersWithListInputRequest", + }, + { + key: "addPet_Response", + value: "APet", + }, + { + key: "updatePet_Response", + value: "APet", + }, + { + key: "findPetsByStatus_Response", + value: "FindPetsByStatusResponse_200", + }, + { + key: "findPetsByTags_Response", + value: "FindPetsByTagsResponse_200", + }, + { + key: "getPetById_Response", + value: "APet", + }, + { + key: "uploadFile_Response", + value: "AnUploadedResponse", + }, + { + key: "getInventory_Response", + value: "GetInventoryResponse_200", + }, + { + key: "placeOrder_Response", + value: "PetOrder", + }, + { + key: "getOrderById_Response", + value: "PetOrder", + }, + { + key: "loginUser_Response", + value: "LoginUserResponse_200", + }, + { + key: "getUserByName_Response", + value: "AUser", + }, + ]); + }); + it('should work with basic OpenAPI 3.1 inputs', async () => { + const parsedOpenAPIDocument = await loadOpenapiDocument(path.resolve(__dirname, '../../../configs/openapi-3_1.json')); + + const renderedContent = await generateTypescriptPayload({ + generator: { + ...defaultTypeScriptPayloadGenerator, + outputPath: path.resolve(__dirname, './output') + }, + inputType: 'openapi', + openapiDocument: parsedOpenAPIDocument, + dependencyOutputs: { } + }); + const modelsCreated = Object.entries(renderedContent.operationModels).map(([key, value]) => ({key, value: value.messageModel.modelName})); + expect(modelsCreated).toEqual([ + { + key: "addPet", + value: "APet", + }, + { + key: "updatePet", + value: "APet", + }, + { + key: "placeOrder", + value: "PetOrder", + }, + { + key: "createUser", + value: "AUser", + }, + { + key: "createUsersWithArrayInput", + value: "CreateUsersWithArrayInputRequest", + }, + { + key: "createUsersWithListInput", + value: "CreateUsersWithListInputRequest", + }, + { + key: "addPet_Response", + value: "APet", + }, + { + key: "updatePet_Response", + value: "APet", + }, + { + key: "findPetsByStatus_Response", + value: "FindPetsByStatusResponse_200", + }, + { + key: "findPetsByTags_Response", + value: "FindPetsByTagsResponse_200", + }, + { + key: "getPetById_Response", + value: "APet", + }, + { + key: "uploadFile_Response", + value: "AnUploadedResponse", + }, + { + key: "getInventory_Response", + value: "GetInventoryResponse_200", + }, + { + key: "placeOrder_Response", + value: "PetOrder", + }, + { + key: "getOrderById_Response", + value: "PetOrder", + }, + { + key: "loginUser_Response", + value: "LoginUserResponse_200", + }, + { + key: "getUserByName_Response", + value: "AUser", + }, + ]); + }); + describe('safeStringify', () => { + it('should stringify basic primitive values', () => { + expect(safeStringify('hello')).toBe('"hello"'); + expect(safeStringify(42)).toBe('42'); + expect(safeStringify(true)).toBe('true'); + expect(safeStringify(false)).toBe('false'); + expect(safeStringify(null)).toBe('null'); + }); + + it('should stringify simple objects', () => { + const obj = { name: 'test', value: 123 }; + expect(safeStringify(obj)).toBe('{"name":"test","value":123}'); + }); + + it('should stringify arrays', () => { + const arr = [1, 'two', true, null]; + expect(safeStringify(arr)).toBe('[1,"two",true,null]'); + }); + + it('should remove x-modelina extension properties', () => { + const obj = { + name: 'test', + 'x-modelina-type': 'string', + 'x-modelina-format': 'email', + value: 123 + }; + expect(safeStringify(obj)).toBe('{"name":"test","value":123}'); + }); + + it('should remove x-the-codegen-project extension properties', () => { + const obj = { + name: 'test', + 'x-the-codegen-project-version': '1.0.0', + 'x-the-codegen-project-config': { setting: true }, + value: 123 + }; + expect(safeStringify(obj)).toBe('{"name":"test","value":123}'); + }); + + it('should handle circular references by replacing with true', () => { + const obj: any = { name: 'test' }; + obj.self = obj; + const result = safeStringify(obj); + expect(result).toBe('{"name":"test","self":true}'); + }); + + it('should handle nested circular references by cutting off after repetitions', () => { + const parent: any = { name: 'parent' }; + const child: any = { name: 'child', parent }; + parent.child = child; + + const result = safeStringify(parent); + // Should allow some repetition but eventually cut off with true + expect(result).toContain('"name":"parent"'); + expect(result).toContain('"name":"child"'); + expect(result).toContain('true'); + // Should not be infinite + expect(result.length).toBeLessThan(1000); + }); + + it('should handle multiple references to the same object', () => { + const parent: any = { name: 'parent' }; + const root: any = { name: 'child', parent, parent_old: parent }; + + const result = safeStringify(root); + expect(result).toBe('{"name":"child","parent":{"name":"parent"},"parent_old":{"name":"parent"}}'); + }); + + it('should replace functions with true', () => { + const obj = { + name: 'test', + method() { return 'hello'; }, + arrow: () => 'world', + value: 123 + }; + expect(safeStringify(obj)).toBe('{"name":"test","method":true,"arrow":true,"value":123}'); + }); + + it('should handle deeply nested objects within recursion limit', () => { + let nested: any = { value: 'deep' }; + for (let i = 0; i < 10; i++) { + nested = { level: i, nested }; + } + + const result = safeStringify(nested); + expect(result).toContain('"value":"deep"'); + expect(result).toContain('"level":0'); + }); + + it('should handle objects beyond recursion limit by replacing with true', () => { + let nested: any = { value: 'deep' }; + // Create a very deep nesting that exceeds the 255 limit + for (let i = 0; i < 300; i++) { + nested = { level: i, nested }; + } + + const result = safeStringify(nested); + // Should contain true for deeply nested parts + expect(result).toContain('true'); + }); + + it('should handle mixed extension properties and regular properties', () => { + const obj = { + name: 'test', + 'x-modelina-discriminator': 'type', + description: 'A test object', + 'x-the-codegen-project-id': 'test-id', + properties: { + field1: 'value1', + 'x-modelina-required': true, + field2: 'value2' + } + }; + + const result = safeStringify(obj); + const parsed = JSON.parse(result); + + expect(parsed.name).toBe('test'); + expect(parsed.description).toBe('A test object'); + expect(parsed.properties.field1).toBe('value1'); + expect(parsed.properties.field2).toBe('value2'); + expect(parsed['x-modelina-discriminator']).toBeUndefined(); + expect(parsed['x-the-codegen-project-id']).toBeUndefined(); + expect(parsed.properties['x-modelina-required']).toBeUndefined(); + }); + + it('should handle undefined values', () => { + const obj = { + name: 'test', + undefinedValue: undefined, + value: 123 + }; + expect(safeStringify(obj)).toBe('{"name":"test","value":123}'); + }); + + it('should handle empty objects and arrays', () => { + expect(safeStringify({})).toBe('{}'); + expect(safeStringify([])).toBe('[]'); + }); + + it('should handle complex nested structures with extension properties', () => { + const complex = { + schema: { + type: 'object', + 'x-modelina-type': 'CustomType', + properties: { + name: { + type: 'string', + 'x-the-codegen-project-validation': 'required' + }, + items: { + type: 'array', + 'x-modelina-items': 'string', + items: { + type: 'string' + } + } + } + }, + 'x-the-codegen-project-version': '2.0.0' + }; + + const result = safeStringify(complex); + const parsed = JSON.parse(result); + + expect(parsed.schema.type).toBe('object'); + expect(parsed.schema.properties.name.type).toBe('string'); + expect(parsed.schema.properties.items.type).toBe('array'); + expect(parsed.schema.properties.items.items.type).toBe('string'); + + // Extension properties should be removed + expect(parsed.schema['x-modelina-type']).toBeUndefined(); + expect(parsed.schema.properties.name['x-the-codegen-project-validation']).toBeUndefined(); + expect(parsed.schema.properties.items['x-modelina-items']).toBeUndefined(); + expect(parsed['x-the-codegen-project-version']).toBeUndefined(); + }); + }); }); }); From 70972679074f4adb91bd0032c2d1b74baa4b1bc5 Mon Sep 17 00:00:00 2001 From: jonaslagoni Date: Tue, 27 May 2025 16:46:26 +0200 Subject: [PATCH 2/2] fix lint --- src/codegen/generators/typescript/payloads.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/codegen/generators/typescript/payloads.ts b/src/codegen/generators/typescript/payloads.ts index a98f9493..73569748 100644 --- a/src/codegen/generators/typescript/payloads.ts +++ b/src/codegen/generators/typescript/payloads.ts @@ -304,6 +304,7 @@ export function safeStringify(value: any): string { const maxDepth = 255; const maxRepetitions = 5; // Allow up to 5 repetitions of the same object + // eslint-disable-next-line sonarjs/cognitive-complexity function stringify(val: any, currentPath: any[] = []): any { // Check depth limit if (depth > maxDepth) {