From a6558e7a164a46ee8b63ed3972c2b8fe77118c5a Mon Sep 17 00:00:00 2001 From: Lagoni Date: Thu, 2 Jul 2026 20:41:40 +0200 Subject: [PATCH] fix: generate tsc-safe TypeScript for parameters and payloads presets Generation reported zero errors but the emitted .ts failed `tsc`, breaking downstream `npm run build` in publish pipelines. Four template defects across two presets are fixed. parameters preset (#372): - Serialize/deserialize now access the constrained camelCase field (this.skip) instead of the raw spec name (this.Skip), while the wire key (params.append('Skip'), path placeholders, extractPathParameters cases) keeps the original casing. Threaded a ParameterConfig carrying both names. - Always emit deserializeUrl (a no-op with no query params) so path-only classes, whose fromUrl() calls it unconditionally, compile. payloads/headers presets (#373): - New createMarshallingFixPreset corrects Modelina's class marshal/unmarshal output (Modelina is an external package with no patch tooling). Derived from the model so each replacement targets only the affected property: - drop the `== null ? null : new Date(...)` fallback for required, non-nullable date-time fields (was Date | null, declared Date). - null-guard nullable arrays before iterating them in marshal(). Verified by regenerating both minimal repros and the full safepay-v2 client through tsc --strict (clean), plus regression tests for all four defects. Refs: #372, #373 Co-Authored-By: Claude Opus 4.8 --- src/codegen/generators/typescript/headers.ts | 6 +- src/codegen/generators/typescript/payloads.ts | 4 +- .../inputs/openapi/generators/parameters.ts | 225 +++---- src/codegen/modelina/presets/index.ts | 3 + src/codegen/modelina/presets/marshalling.ts | 90 +++ .../__snapshots__/parameters.spec.ts.snap | 616 +++++++++++++++++- .../generators/typescript/parameters.spec.ts | 58 ++ .../generators/typescript/payload.spec.ts | 56 ++ 8 files changed, 919 insertions(+), 139 deletions(-) create mode 100644 src/codegen/modelina/presets/marshalling.ts diff --git a/src/codegen/generators/typescript/headers.ts b/src/codegen/generators/typescript/headers.ts index fd0d1cd0..76c09735 100644 --- a/src/codegen/generators/typescript/headers.ts +++ b/src/codegen/generators/typescript/headers.ts @@ -16,7 +16,10 @@ import { TS_COMMON_PRESET, typeScriptDefaultPropertyKeyConstraints } from '@asyncapi/modelina'; -import {createValidationPreset} from '../../modelina/presets'; +import { + createValidationPreset, + createMarshallingFixPreset +} from '../../modelina/presets'; import {createMissingInputDocumentError} from '../../errors'; import {generateModels} from '../../output'; @@ -129,6 +132,7 @@ export async function generateTypescriptHeadersCore({ marshalling: true } }, + createMarshallingFixPreset(), createValidationPreset( { includeValidation: generator.includeValidation diff --git a/src/codegen/generators/typescript/payloads.ts b/src/codegen/generators/typescript/payloads.ts index 8e362419..571f74f6 100644 --- a/src/codegen/generators/typescript/payloads.ts +++ b/src/codegen/generators/typescript/payloads.ts @@ -22,7 +22,8 @@ import {TS_COMMON_PRESET, TS_DESCRIPTION_PRESET} from '@asyncapi/modelina'; import { createValidationPreset, createUnionPreset, - createPrimitivesPreset + createPrimitivesPreset, + createMarshallingFixPreset } from '../../modelina/presets'; import {createMissingInputDocumentError} from '../../errors'; import {generateModels} from '../../output'; @@ -174,6 +175,7 @@ export async function generateTypescriptPayloadsCoreFromSchemas({ marshalling: true } }, + createMarshallingFixPreset(), createValidationPreset( { includeValidation: generator.includeValidation diff --git a/src/codegen/inputs/openapi/generators/parameters.ts b/src/codegen/inputs/openapi/generators/parameters.ts index 8e75944c..caf72fae 100644 --- a/src/codegen/inputs/openapi/generators/parameters.ts +++ b/src/codegen/inputs/openapi/generators/parameters.ts @@ -18,6 +18,37 @@ const X_PARAMETER_EXPLODE = 'x-parameter-explode'; const X_PARAMETER_ALLOW_RESERVED = 'x-parameter-allowReserved'; const X_PARAMETER_COLLECTION_FORMAT = 'x-parameter-collectionFormat'; +/** + * A serializable parameter. + * `name` is the original spec name used as the wire key (query key / path + * placeholder), while `propertyName` is the constrained (camelCase) identifier + * of the generated class field. These differ whenever the spec name isn't + * already a valid camelCase identifier (e.g. `Skip` -> `skip`), so the two must + * never be conflated: wire keys keep the original casing, property accessors use + * the constrained name. + */ +interface ParameterConfig { + name: string; + propertyName: string; + style: string; + explode: boolean; + allowReserved: boolean; +} + +/** + * Build a lookup from a parameter's original spec name to the constrained + * property name Modelina assigned to the generated class field. + */ +function buildConstrainedNameMap( + model: ConstrainedObjectModel +): Record { + const map: Record = {}; + for (const propModel of Object.values(model.properties)) { + map[propModel.unconstrainedPropertyName] = propModel.propertyName; + } + return map; +} + // OpenAPI parameter processor export function processOpenAPIParameters( openapiDocument: @@ -173,23 +204,18 @@ export function createParameterSchema( */ function generateOpenAPIParameterMethods(model: ConstrainedObjectModel) { const properties = model.originalInput?.properties ?? {}; + const constrainedNameMap = buildConstrainedNameMap(model); // Collect path and query parameters - const pathParams: Array<{ - name: string; - style: string; - explode: boolean; - allowReserved: boolean; - }> = []; - const queryParams: Array<{ - name: string; - style: string; - explode: boolean; - allowReserved: boolean; - }> = []; + const pathParams: ParameterConfig[] = []; + const queryParams: ParameterConfig[] = []; for (const [propName, propSchema] of Object.entries(properties)) { - const paramConfig = processParameterSchema(propName, propSchema); + const paramConfig = processParameterSchema( + propName, + propSchema, + constrainedNameMap[propName] ?? propName + ); if (paramConfig) { if (paramConfig.location === 'path') { pathParams.push(paramConfig); @@ -226,14 +252,9 @@ function generateOpenAPIParameterMethods(model: ConstrainedObjectModel) { */ function processParameterSchema( propName: string, - propSchema: any -): { - name: string; - location: string; - style: string; - explode: boolean; - allowReserved: boolean; -} | null { + propSchema: any, + propertyName: string = propName +): (ParameterConfig & {location: string}) | null { const schema = propSchema; const location = schema[X_PARAMETER_LOCATION]; @@ -263,6 +284,7 @@ function processParameterSchema( return { name: propName, + propertyName, location, style, explode, @@ -274,18 +296,8 @@ function processParameterSchema( * Generate all serialization methods */ function generateSerializationMethods( - pathParams: Array<{ - name: string; - style: string; - explode: boolean; - allowReserved: boolean; - }>, - queryParams: Array<{ - name: string; - style: string; - explode: boolean; - allowReserved: boolean; - }> + pathParams: ParameterConfig[], + queryParams: ParameterConfig[] ): string { let methods = ''; @@ -312,12 +324,7 @@ function generateSerializationMethods( * Generate path parameter serialization method */ function generatePathSerializationMethod( - pathParams: Array<{ - name: string; - style: string; - explode: boolean; - allowReserved: boolean; - }> + pathParams: ParameterConfig[] ): string { const paramSerializations = pathParams .map((param) => generatePathParameterSerialization(param)) @@ -341,12 +348,7 @@ ${paramSerializations} * Generate query parameter serialization method */ function generateQuerySerializationMethod( - queryParams: Array<{ - name: string; - style: string; - explode: boolean; - allowReserved: boolean; - }> + queryParams: ParameterConfig[] ): string { const paramSerializations = queryParams .map((param) => generateQueryParameterSerialization(param)) @@ -421,18 +423,13 @@ getChannelWithParameters(basePath: string): string { /** * Generate serialization code for a single path parameter */ -function generatePathParameterSerialization(param: { - name: string; - style: string; - explode: boolean; - allowReserved: boolean; -}): string { - const {name, style, explode, allowReserved} = param; +function generatePathParameterSerialization(param: ParameterConfig): string { + const {name, propertyName, style, explode, allowReserved} = param; const encoding = allowReserved ? '' : 'encodeURIComponent'; return ` // Serialize path parameter: ${name} (style: ${style}, explode: ${explode}) - if (this.${name} !== undefined && this.${name} !== null) { - const value = this.${name}; + if (this.${propertyName} !== undefined && this.${propertyName} !== null) { + const value = this.${propertyName}; ${generatePathSerializationLogic(name, style, explode, encoding)} }`; } @@ -440,18 +437,13 @@ function generatePathParameterSerialization(param: { /** * Generate serialization code for a single query parameter */ -function generateQueryParameterSerialization(param: { - name: string; - style: string; - explode: boolean; - allowReserved: boolean; -}): string { - const {name, style, explode, allowReserved} = param; +function generateQueryParameterSerialization(param: ParameterConfig): string { + const {name, propertyName, style, explode, allowReserved} = param; const encoding = allowReserved ? '' : 'encodeURIComponent'; return ` // Serialize query parameter: ${name} (style: ${style}, explode: ${explode}) - if (this.${name} !== undefined && this.${name} !== null) { - const value = this.${name}; + if (this.${propertyName} !== undefined && this.${propertyName} !== null) { + const value = this.${propertyName}; ${generateQuerySerializationLogic(name, style, explode, encoding)} }`; } @@ -740,26 +732,15 @@ function convertCollectionFormatToStyleAndExplode( * Generate all deserialization methods */ function generateDeserializationMethods( - pathParams: Array<{ - name: string; - style: string; - explode: boolean; - allowReserved: boolean; - }>, - queryParams: Array<{ - name: string; - style: string; - explode: boolean; - allowReserved: boolean; - }>, + pathParams: ParameterConfig[], + queryParams: ParameterConfig[], model: ConstrainedObjectModel ): string { let methods = ''; - // Generate URL deserialization method - if (queryParams.length > 0) { - methods += generateUrlDeserializationMethod(queryParams, model); - } + // Always emit deserializeUrl (a no-op when there are no query parameters). + // fromUrl() calls it unconditionally, so a path-only class must still define it. + methods += generateUrlDeserializationMethod(queryParams, model); // Generate static fromUrl method methods += generateFromUrlStaticMethod(pathParams, model); @@ -771,12 +752,7 @@ function generateDeserializationMethods( * Generate URL deserialization method */ function generateUrlDeserializationMethod( - queryParams: Array<{ - name: string; - style: string; - explode: boolean; - allowReserved: boolean; - }>, + queryParams: ParameterConfig[], model: ConstrainedObjectModel ): string { const paramDeserializations = queryParams @@ -812,15 +788,11 @@ ${paramDeserializations} * Generate static fromUrl method */ function generateFromUrlStaticMethod( - pathParams: Array<{ - name: string; - style: string; - explode: boolean; - allowReserved: boolean; - }>, + pathParams: ParameterConfig[], model: ConstrainedObjectModel ): string { const properties = model.originalInput?.properties ?? {}; + const constrainedNameMap = buildConstrainedNameMap(model); const requiredParams: string[] = []; // Find required parameters to determine constructor defaults @@ -835,15 +807,19 @@ function generateFromUrlStaticMethod( const pathParamExtraction = pathParams.length > 0 ? generatePathParameterExtraction(pathParams) : ''; - // Generate constructor arguments with path parameters first, then required non-path parameters + // Generate constructor arguments with path parameters first, then required + // non-path parameters. Constructor keys use the constrained property name. const pathParamArgs = pathParams - .map((param) => `${param.name}: pathParams.${param.name}`) + .map((param) => `${param.propertyName}: pathParams.${param.propertyName}`) .join(', '); const requiredNonPathParams = requiredParams.filter( (param) => !pathParams.some((pathParam) => pathParam.name === param) ); const requiredParamArgs = requiredNonPathParams - .map((param) => `${param}: default${pascalCase(param)}`) + .map( + (param) => + `${constrainedNameMap[param] ?? param}: default${pascalCase(param)}` + ) .join(', '); let constructorArgs = ''; @@ -898,12 +874,7 @@ static fromUrl(url: string, basePath: string${functionParams}): ${model.type} { * Generate path parameter extraction logic for the fromUrl method */ function generatePathParameterExtraction( - pathParams: Array<{ - name: string; - style: string; - explode: boolean; - allowReserved: boolean; - }> + pathParams: ParameterConfig[] ): string { if (pathParams.length === 0) { return ''; @@ -917,20 +888,16 @@ function generatePathParameterExtraction( * Generate deserialization code for a single query parameter */ function generateQueryParameterDeserialization( - param: { - name: string; - style: string; - explode: boolean; - allowReserved: boolean; - }, + param: ParameterConfig, model: ConstrainedObjectModel ): string { - const {name, style, explode} = param; + const {name, propertyName, style, explode} = param; const properties = model.originalInput?.properties ?? {}; const propSchema = properties[name]; const logicCode = generateQueryDeserializationLogic( name, + propertyName, style, explode, propSchema @@ -948,6 +915,7 @@ function generateQueryParameterDeserialization( */ function generateQueryDeserializationLogic( name: string, + propertyName: string, style: string, explode: boolean, propSchema: any @@ -962,6 +930,7 @@ function generateQueryDeserializationLogic( case 'form': return generateFormStyleDeserializationLogic( name, + propertyName, explode, isArray, isBoolean, @@ -971,6 +940,7 @@ function generateQueryDeserializationLogic( case 'spaceDelimited': return generateSpaceDelimitedDeserializationLogic( name, + propertyName, explode, isArray, isBoolean, @@ -980,6 +950,7 @@ function generateQueryDeserializationLogic( case 'pipeDelimited': return generatePipeDelimitedDeserializationLogic( name, + propertyName, explode, isArray, isBoolean, @@ -989,6 +960,7 @@ function generateQueryDeserializationLogic( case 'deepObject': return generateDeepObjectDeserializationLogic( name, + propertyName, isBoolean, isNumber, paramType @@ -1003,6 +975,7 @@ function generateQueryDeserializationLogic( */ function generateFormStyleDeserializationLogic( name: string, + propertyName: string, explode: boolean, isArray: boolean, isBoolean: boolean, @@ -1012,30 +985,30 @@ function generateFormStyleDeserializationLogic( if (isArray && !explode) { const typecast = paramType.includes('[]') ? ` as ${paramType}` : ''; return `if (value === '') { - this.${name} = []; + this.${propertyName} = []; } else if (value) { // Split by comma and decode const decodedValues = value.split(',').map(val => decodeURIComponent(val.trim())); - this.${name} = decodedValues${typecast}; + this.${propertyName} = decodedValues${typecast}; }`; } else if (isArray && explode) { const typecast = paramType.includes('[]') ? ` as ${paramType}` : ''; return `const allValues = params.getAll('${name}'); if (allValues.length > 0) { const decodedValues = allValues.map(val => decodeURIComponent(val)); - this.${name} = decodedValues${typecast}; + this.${propertyName} = decodedValues${typecast}; }`; } else if (isBoolean) { return `if (value) { const decodedValue = decodeURIComponent(value); - this.${name} = decodedValue.toLowerCase() === 'true'; + this.${propertyName} = decodedValue.toLowerCase() === 'true'; }`; } else if (isNumber) { return `if (value) { const decodedValue = decodeURIComponent(value); const numValue = Number(decodedValue); if (!isNaN(numValue)) { - this.${name} = numValue; + this.${propertyName} = numValue; } }`; } @@ -1045,7 +1018,7 @@ function generateFormStyleDeserializationLogic( : ''; return `if (value) { const decodedValue = decodeURIComponent(value); - this.${name} = decodedValue${typecast}; + this.${propertyName} = decodedValue${typecast}; }`; } @@ -1054,6 +1027,7 @@ function generateFormStyleDeserializationLogic( */ function generateSpaceDelimitedDeserializationLogic( name: string, + propertyName: string, explode: boolean, isArray: boolean, isBoolean: boolean, @@ -1063,15 +1037,16 @@ function generateSpaceDelimitedDeserializationLogic( if (isArray && !explode) { const typecast = paramType.includes('[]') ? ` as ${paramType}` : ''; return `if (value === '') { - this.${name} = []; + this.${propertyName} = []; } else if (value) { // Split by space and decode const decodedValues = value.split(' ').map(val => decodeURIComponent(val.trim())); - this.${name} = decodedValues${typecast}; + this.${propertyName} = decodedValues${typecast}; }`; } return generateFormStyleDeserializationLogic( name, + propertyName, explode, isArray, isBoolean, @@ -1085,6 +1060,7 @@ function generateSpaceDelimitedDeserializationLogic( */ function generatePipeDelimitedDeserializationLogic( name: string, + propertyName: string, explode: boolean, isArray: boolean, isBoolean: boolean, @@ -1094,15 +1070,16 @@ function generatePipeDelimitedDeserializationLogic( if (isArray && !explode) { const typecast = paramType.includes('[]') ? ` as ${paramType}` : ''; return `if (value === '') { - this.${name} = []; + this.${propertyName} = []; } else if (value) { // Split by pipe and decode const decodedValues = value.split('|').map(val => decodeURIComponent(val.trim())); - this.${name} = decodedValues${typecast}; + this.${propertyName} = decodedValues${typecast}; }`; } return generateFormStyleDeserializationLogic( name, + propertyName, explode, isArray, isBoolean, @@ -1116,6 +1093,7 @@ function generatePipeDelimitedDeserializationLogic( */ function generateDeepObjectDeserializationLogic( name: string, + propertyName: string, isBoolean: boolean, isNumber: boolean, paramType: string @@ -1134,7 +1112,7 @@ function generateDeepObjectDeserializationLogic( } } if (Object.keys(deepObjectParams).length > 0) { - this.${name} = deepObjectParams${typecast}; + this.${propertyName} = deepObjectParams${typecast}; }`; } @@ -1190,12 +1168,7 @@ function generateDefaultValue(propSchema: any, paramName: string): string { * Generate the extractPathParameters static method */ function generateExtractPathParametersMethod( - pathParams: Array<{ - name: string; - style: string; - explode: boolean; - allowReserved: boolean; - }>, + pathParams: ParameterConfig[], model: ConstrainedObjectModel ): string { const properties = model.originalInput?.properties ?? {}; @@ -1209,7 +1182,7 @@ function generateExtractPathParametersMethod( const typecast = paramType !== 'string' ? ` as ${paramType}` : ''; return ` case '${param.name}': - result.${param.name} = ${conversion}${typecast}; + result.${param.propertyName} = ${conversion}${typecast}; break;`; }) .join('\n'); @@ -1222,7 +1195,7 @@ function generateExtractPathParametersMethod( * @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): { ${pathParams.map((p) => `${p.name}: ${getParameterType(properties[p.name])}`).join(', ')} } { +private static extractPathParameters(url: string, basePath: string): { ${pathParams.map((p) => `${p.propertyName}: ${getParameterType(properties[p.name])}`).join(', ')} } { // Remove query string from URL for path matching const urlPath = url.split('?')[0]; diff --git a/src/codegen/modelina/presets/index.ts b/src/codegen/modelina/presets/index.ts index 69ab474b..50c967b3 100644 --- a/src/codegen/modelina/presets/index.ts +++ b/src/codegen/modelina/presets/index.ts @@ -14,3 +14,6 @@ export { createPrimitivesPreset, type PrimitivesPresetOptions } from './primitives'; + +// Corrects tsc-breaking defects in Modelina's class marshal/unmarshal output +export {createMarshallingFixPreset} from './marshalling'; diff --git a/src/codegen/modelina/presets/marshalling.ts b/src/codegen/modelina/presets/marshalling.ts new file mode 100644 index 00000000..73974c98 --- /dev/null +++ b/src/codegen/modelina/presets/marshalling.ts @@ -0,0 +1,90 @@ +import { + ConstrainedArrayModel, + ConstrainedMetaModel, + ConstrainedObjectModel, + ConstrainedStringModel +} from '@asyncapi/modelina'; + +/** + * Whether a property renders as a JavaScript `Date` (i.e. a string schema with a + * `date`/`date-time` format). These are the properties Modelina converts from a + * JSON string into a `Date` during unmarshalling. + */ +function isDateProperty(model: ConstrainedMetaModel): boolean { + if (!(model instanceof ConstrainedStringModel)) { + return false; + } + const format = model.originalInput?.format; + return format === 'date' || format === 'date-time'; +} + +/** + * Whether a property's declared type includes `null`. + */ +function isNullable(model: ConstrainedMetaModel): boolean { + return model.options?.isNullable === true || model.type.includes('| null'); +} + +/** + * Fixes two `tsc`-breaking defects in the marshal/unmarshal methods emitted by + * Modelina's `TS_COMMON_PRESET` (tracked upstream as the-codegen-project/cli#373). + * + * Modelina lives in an external package, so we correct its output here by + * post-processing the already-rendered class body. Each replacement is derived + * from the model, so it targets exactly the buggy line for the affected property + * and leaves every other property untouched: + * + * 1. `unmarshal()` emits `obj["x"] == null ? null : new Date(obj["x"])` for a + * required, non-nullable date property, yielding `Date | null` where `Date` + * is declared. The null branch is dropped so the assignment type-checks. + * 2. `marshal()` guards a nullable array with only `!== undefined` before + * iterating it, so `null` slips through and `for (const item of this.x)` + * reports "Object is possibly 'null'". An explicit null guard is added. + * + * This preset must run after `TS_COMMON_PRESET` so that `content` already + * contains the rendered marshal/unmarshal methods. + */ +export function createMarshallingFixPreset() { + return { + class: { + additionalContent({ + content, + model + }: { + content: string; + model: ConstrainedMetaModel; + }) { + if (!(model instanceof ConstrainedObjectModel)) { + return content; + } + + let result = content; + for (const propModel of Object.values(model.properties)) { + const property = propModel.property; + const accessor = propModel.propertyName; + const wireKey = propModel.unconstrainedPropertyName; + + if ( + isDateProperty(property) && + propModel.required && + !isNullable(property) + ) { + const buggy = `instance.${accessor} = obj["${wireKey}"] == null ? null : new Date(obj["${wireKey}"]);`; + const fixed = `instance.${accessor} = new Date(obj["${wireKey}"]);`; + result = result.replace(buggy, fixed); + } + + if ( + property instanceof ConstrainedArrayModel && + isNullable(property) + ) { + const buggy = `if(this.${accessor} !== undefined) {`; + const fixed = `if(this.${accessor} !== undefined && this.${accessor} !== null) {`; + result = result.replace(buggy, fixed); + } + } + return result; + } + } + }; +} diff --git a/test/codegen/generators/typescript/__snapshots__/parameters.spec.ts.snap b/test/codegen/generators/typescript/__snapshots__/parameters.spec.ts.snap index babddc6e..b4b31a55 100644 --- a/test/codegen/generators/typescript/__snapshots__/parameters.spec.ts.snap +++ b/test/codegen/generators/typescript/__snapshots__/parameters.spec.ts.snap @@ -268,6 +268,28 @@ class DeleteOrderParameters { 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); + + + } /** * Static method to create a new instance from a URL @@ -395,6 +417,28 @@ class DeletePetParameters { 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); + + + } /** * Static method to create a new instance from a URL @@ -522,6 +566,28 @@ class DeleteUserParameters { 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); + + + } /** * Static method to create a new instance from a URL @@ -885,6 +951,28 @@ class GetOrderByIdParameters { 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); + + + } /** * Static method to create a new instance from a URL @@ -1012,6 +1100,28 @@ class GetPetByIdParameters { 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); + + + } /** * Static method to create a new instance from a URL @@ -1139,6 +1249,28 @@ class GetUserByNameParameters { 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); + + + } /** * Static method to create a new instance from a URL @@ -1412,6 +1544,28 @@ class UpdatePetWithFormParameters { 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); + + + } /** * Static method to create a new instance from a URL @@ -1539,6 +1693,28 @@ class UpdateUserParameters { 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); + + + } /** * Static method to create a new instance from a URL @@ -1666,6 +1842,28 @@ class UploadFileParameters { 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); + + + } /** * Static method to create a new instance from a URL @@ -1793,6 +1991,28 @@ class DeleteOrderParameters { 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); + + + } /** * Static method to create a new instance from a URL @@ -1920,6 +2140,28 @@ class DeletePetParameters { 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); + + + } /** * Static method to create a new instance from a URL @@ -2047,6 +2289,28 @@ class DeleteUserParameters { 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); + + + } /** * Static method to create a new instance from a URL @@ -2414,6 +2678,28 @@ class GetOrderByIdParameters { 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); + + + } /** * Static method to create a new instance from a URL @@ -2541,6 +2827,28 @@ class GetPetByIdParameters { 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); + + + } /** * Static method to create a new instance from a URL @@ -2668,6 +2976,28 @@ class GetUserByNameParameters { 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); + + + } /** * Static method to create a new instance from a URL @@ -2927,19 +3257,41 @@ class UpdatePetWithFormParameters { url = url.replace(new RegExp(\`{\${name}}\`, 'g'), value); } - // Add query parameters - + // Add query parameters + + + return url; + } + + /** + * Get the channel path with parameters substituted (compatible with AsyncAPI channel interface) + * @param basePath The base path template (e.g., '/pet/findByStatus/{status}/{categoryId}') + * @returns The path with parameters replaced + */ + getChannelWithParameters(basePath: string): string { + return this.serializeUrl(basePath); + } + /** + * 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); - 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); } /** @@ -3068,6 +3420,28 @@ class UpdateUserParameters { 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); + + + } /** * Static method to create a new instance from a URL @@ -3195,6 +3569,28 @@ class UploadFileParameters { 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); + + + } /** * Static method to create a new instance from a URL @@ -3322,6 +3718,28 @@ class DeleteOrderParameters { 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); + + + } /** * Static method to create a new instance from a URL @@ -3449,6 +3867,28 @@ class DeletePetParameters { 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); + + + } /** * Static method to create a new instance from a URL @@ -3576,6 +4016,28 @@ class DeleteUserParameters { 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); + + + } /** * Static method to create a new instance from a URL @@ -3943,6 +4405,28 @@ class GetOrderByIdParameters { 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); + + + } /** * Static method to create a new instance from a URL @@ -4070,6 +4554,28 @@ class GetPetByIdParameters { 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); + + + } /** * Static method to create a new instance from a URL @@ -4197,6 +4703,28 @@ class GetUserByNameParameters { 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); + + + } /** * Static method to create a new instance from a URL @@ -4470,6 +4998,28 @@ class UpdatePetWithFormParameters { 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); + + + } /** * Static method to create a new instance from a URL @@ -4597,6 +5147,28 @@ class UpdateUserParameters { 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); + + + } /** * Static method to create a new instance from a URL @@ -4724,6 +5296,28 @@ class UploadFileParameters { 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); + + + } /** * Static method to create a new instance from a URL diff --git a/test/codegen/generators/typescript/parameters.spec.ts b/test/codegen/generators/typescript/parameters.spec.ts index de161cb0..146129fd 100644 --- a/test/codegen/generators/typescript/parameters.spec.ts +++ b/test/codegen/generators/typescript/parameters.spec.ts @@ -191,6 +191,64 @@ describe('parameters', () => { expect(Object.keys(renderedContent.channelModels).length).toEqual(0); }); + + it('should use constrained property accessors while keeping original wire keys (issue #372)', async () => { + // One operation with a PascalCase query param (defect 1) and one path-only + // operation (defect 2), mirroring the minimal reproduction. + const openapiDocument = { + openapi: '3.1.1', + info: { title: 'Test API', version: '1.0.0' }, + paths: { + '/items/{itemId}': { + get: { + operationId: 'getItem', + parameters: [ + { name: 'itemId', in: 'path', required: true, schema: { type: 'string' } }, + { name: 'Skip', in: 'query', schema: { type: ['integer', 'string'] } } + ] + } + }, + '/docs/{docId}': { + get: { + operationId: 'getDoc', + parameters: [ + { name: 'docId', in: 'path', required: true, schema: { type: 'string' } } + ] + } + } + } + }; + + const renderedContent = await generateTypescriptParameters({ + generator: { + serializationType: 'json', + outputPath: path.resolve(__dirname, './output'), + preset: 'parameters', + language: 'typescript', + dependencies: [], + id: 'test' + }, + inputType: 'openapi', + openapiDocument: openapiDocument as any, + dependencyOutputs: { } + }); + + // Defect 1: the query serializer/deserializer must access the constrained + // camelCase field (`this.skip`), never the raw spec name (`this.Skip`), + // while the wire key stays the original `Skip`. + const getItem = renderedContent.channelModels['getItem']?.result ?? ''; + expect(getItem).toContain('if (this.skip !== undefined && this.skip !== null)'); + expect(getItem).toContain("params.append('Skip'"); + expect(getItem).toContain("params.has('Skip')"); + expect(getItem).toContain('this.skip = decodedValue'); + expect(getItem).not.toContain('this.Skip'); + + // Defect 2: fromUrl() always calls deserializeUrl(), so a path-only class + // must still define it. + const getDoc = renderedContent.channelModels['getDoc']?.result ?? ''; + expect(getDoc).toContain('deserializeUrl(url: string): void'); + expect(getDoc).toContain('instance.deserializeUrl(url);'); + }); }); }); }); diff --git a/test/codegen/generators/typescript/payload.spec.ts b/test/codegen/generators/typescript/payload.spec.ts index 3201b91e..fa578fef 100644 --- a/test/codegen/generators/typescript/payload.spec.ts +++ b/test/codegen/generators/typescript/payload.spec.ts @@ -239,6 +239,62 @@ describe('payloads', () => { }, ]); }); + it('should emit tsc-safe marshal/unmarshal for date and nullable array properties (issue #373)', async () => { + // Thing has a required, non-nullable date-time (defect 3) and a nullable, + // optional array (defect 4), mirroring the minimal reproduction. + // Schema is inlined (no $ref) so the document needs no dereferencing. + const thingSchema = { + type: 'object', + title: 'Thing', + required: ['created'], + properties: { + created: { type: 'string', format: 'date-time' }, + signers: { type: ['null', 'array'], items: { type: 'string' } } + } + }; + const openapiDocument = { + openapi: '3.1.1', + info: { title: 'Test API', version: '1.0.0' }, + paths: { + '/thing': { + get: { + operationId: 'getThing', + responses: { + 200: { + description: 'OK', + content: { 'application/json': { schema: thingSchema } } + } + } + } + } + } + }; + + const renderedContent = await generateTypescriptPayload({ + generator: { + ...defaultTypeScriptPayloadGenerator, + outputPath: path.resolve(__dirname, './output') + }, + inputType: 'openapi', + openapiDocument: openapiDocument as any, + dependencyOutputs: { } + }); + + const results = [ + ...Object.values(renderedContent.operationModels).map((m) => m.messageModel.result), + ...renderedContent.otherModels.map((m) => m.messageModel.result) + ]; + const thing = results.find((r) => r.includes('this.signers') && r.includes('new Date')) ?? ''; + expect(thing).not.toEqual(''); + + // Defect 3: a required non-nullable date-time must not get a `null` fallback, + // which would produce `Date | null` where `Date` is declared. + expect(thing).toContain('instance.created = new Date(obj["created"]);'); + expect(thing).not.toContain('== null ? null : new Date'); + + // Defect 4: a nullable array must be null-guarded before it is iterated. + expect(thing).toContain('if(this.signers !== undefined && this.signers !== null)'); + }); it('should work with basic OpenAPI 3.1 inputs', async () => { const parsedOpenAPIDocument = await loadOpenapiDocument(path.resolve(__dirname, '../../../configs/openapi-3_1.json'));