From 9396a0e3c0d74f4c33fbc1d15f365ed7586e1d1b Mon Sep 17 00:00:00 2001 From: Yan Date: Tue, 23 Sep 2025 12:34:59 -0700 Subject: [PATCH 1/2] fix: updated parseRequestUnion for union requests Path parameters were being dropped when the first schema in a union request didn't contain params. Now it iterates through all schemas to find the first one with path parameters. --- packages/openapi-generator/src/route.ts | 25 +- .../test/openapi/union.test.ts | 359 ++++++++++++++++++ 2 files changed, 373 insertions(+), 11 deletions(-) diff --git a/packages/openapi-generator/src/route.ts b/packages/openapi-generator/src/route.ts index fa7c84bb..be8df670 100644 --- a/packages/openapi-generator/src/route.ts +++ b/packages/openapi-generator/src/route.ts @@ -183,17 +183,20 @@ function parseRequestUnion( parameters.push(...headerParams.values()); } - const firstSubSchema = schema.schemas[0]; - if (firstSubSchema !== undefined && firstSubSchema.type === 'object') { - const pathSchema = firstSubSchema.properties['params']; - if (pathSchema !== undefined && pathSchema.type === 'object') { - for (const [name, prop] of Object.entries(pathSchema.properties)) { - parameters.push({ - type: 'path', - name, - schema: prop, - required: pathSchema.required.includes(name), - }); + // Find the first schema in the union that has path parameters + for (const subSchema of schema.schemas) { + if (subSchema.type === 'object') { + const pathSchema = subSchema.properties['params']; + if (pathSchema !== undefined && pathSchema.type === 'object') { + for (const [name, prop] of Object.entries(pathSchema.properties)) { + parameters.push({ + type: 'path', + name, + schema: prop, + required: pathSchema.required.includes(name), + }); + } + break; // Found path params, stop looking } } } diff --git a/packages/openapi-generator/test/openapi/union.test.ts b/packages/openapi-generator/test/openapi/union.test.ts index 492a7633..fb2d660a 100644 --- a/packages/openapi-generator/test/openapi/union.test.ts +++ b/packages/openapi-generator/test/openapi/union.test.ts @@ -353,6 +353,365 @@ testCase('route with unknown unions', ROUTE_WITH_UNKNOWN_UNIONS, { }, }); +const ROUTE_WITH_PATH_PARAMS_IN_UNION_NOT_FIRST = ` +import * as t from 'io-ts'; +import * as h from '@api-ts/io-ts-http'; + +export const route = h.httpRoute({ + path: '/internal/api/policy/v1/{applicationName}/touchpoints/{touchpoint}/rules/evaluation', + method: 'POST', + request: t.union([ + // First schema has NO path parameters - this was causing the bug + h.httpRequest({ + body: { emptyRequest: t.boolean } + }), + // Second schema HAS path parameters - these should be preserved + h.httpRequest({ + params: { + applicationName: t.string, + touchpoint: t.string, + }, + body: { requestWithParams: t.string } + }), + ]), + response: { + 200: t.string, + }, +}); +`; + +testCase( + 'route with path params in union second schema (regression test)', + ROUTE_WITH_PATH_PARAMS_IN_UNION_NOT_FIRST, + { + info: { + title: 'Test', + version: '1.0.0', + }, + openapi: '3.0.3', + paths: { + '/internal/api/policy/v1/{applicationName}/touchpoints/{touchpoint}/rules/evaluation': + { + post: { + parameters: [ + { + in: 'path', + name: 'applicationName', + required: true, + schema: { type: 'string' }, + }, + { + in: 'path', + name: 'touchpoint', + required: true, + schema: { type: 'string' }, + }, + ], + requestBody: { + content: { + 'application/json': { + schema: { + oneOf: [ + { + properties: { + emptyRequest: { type: 'boolean' }, + }, + required: ['emptyRequest'], + type: 'object', + }, + { + properties: { + requestWithParams: { type: 'string' }, + }, + required: ['requestWithParams'], + type: 'object', + }, + ], + }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: {}, + }, + }, +); + +const ROUTE_WITH_PATH_PARAMS_ONLY_IN_THIRD_SCHEMA = ` +import * as t from 'io-ts'; +import * as h from '@api-ts/io-ts-http'; + +export const route = h.httpRoute({ + path: '/api/{userId}/posts/{postId}', + method: 'GET', + request: t.union([ + // First: empty request + h.httpRequest({}), + // Second: only query params + h.httpRequest({ + query: { filter: t.string } + }), + // Third: has the path params + h.httpRequest({ + params: { + userId: t.string, + postId: t.string, + }, + query: { details: t.boolean } + }), + ]), + response: { + 200: t.string, + }, +}); +`; + +testCase( + 'route with path params only in third schema', + ROUTE_WITH_PATH_PARAMS_ONLY_IN_THIRD_SCHEMA, + { + info: { + title: 'Test', + version: '1.0.0', + }, + openapi: '3.0.3', + paths: { + '/api/{userId}/posts/{postId}': { + get: { + parameters: [ + { + in: 'query', + name: 'union', + required: true, + explode: true, + style: 'form', + schema: { + oneOf: [ + { + properties: { filter: { type: 'string' } }, + required: ['filter'], + type: 'object', + }, + { + properties: { details: { type: 'boolean' } }, + required: ['details'], + type: 'object', + }, + ], + }, + }, + { in: 'path', name: 'userId', required: true, schema: { type: 'string' } }, + { in: 'path', name: 'postId', required: true, schema: { type: 'string' } }, + ], + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: {}, + }, + }, +); + +const REAL_WORLD_POLICY_EVALUATION_ROUTE = ` +import * as t from 'io-ts'; +import * as h from '@api-ts/io-ts-http'; + +const AddressBookConnectionSides = t.union([t.literal('send'), t.literal('receive')]); + +/** + * Create policy evaluation definition + * @operationId v1.post.policy.evaluation.definition + * @tag Policy Builder + * @private + */ +export const route = h.httpRoute({ + path: '/internal/api/policy/v1/{applicationName}/touchpoints/{touchpoint}/rules/evaluations', + method: 'POST', + request: t.union([ + h.httpRequest({ + params: { + applicationName: t.string, + touchpoint: t.string, + }, + body: t.type({ + approvalRequestId: t.string, + counterPartyId: t.string, + description: h.optional(t.string), + enterpriseId: t.string, + grossAmount: h.optional(t.number), + idempotencyKey: t.string, + isFirstTimeCounterParty: t.boolean, + isMutualConnection: t.boolean, + netAmount: h.optional(t.number), + settlementId: t.string, + userId: t.string, + walletId: t.string, + }) + }), + h.httpRequest({ + params: { + applicationName: t.string, + touchpoint: t.string, + }, + body: t.type({ + connectionId: t.string, + description: h.optional(t.string), + enterpriseId: t.string, + idempotencyKey: t.string, + side: AddressBookConnectionSides, + walletId: t.string, + }) + }), + ]), + response: { + 200: t.string, + }, +}); +`; + +testCase( + 'real-world policy evaluation route with union request bodies', + REAL_WORLD_POLICY_EVALUATION_ROUTE, + { + info: { + title: 'Test', + version: '1.0.0', + }, + openapi: '3.0.3', + paths: { + '/internal/api/policy/v1/{applicationName}/touchpoints/{touchpoint}/rules/evaluations': + { + post: { + summary: 'Create policy evaluation definition', + operationId: 'v1.post.policy.evaluation.definition', + tags: ['Policy Builder'], + 'x-internal': true, + parameters: [ + { + in: 'path', + name: 'applicationName', + required: true, + schema: { type: 'string' }, + }, + { + in: 'path', + name: 'touchpoint', + required: true, + schema: { type: 'string' }, + }, + ], + requestBody: { + content: { + 'application/json': { + schema: { + oneOf: [ + { + type: 'object', + properties: { + approvalRequestId: { type: 'string' }, + counterPartyId: { type: 'string' }, + description: { type: 'string' }, + enterpriseId: { type: 'string' }, + grossAmount: { type: 'number' }, + idempotencyKey: { type: 'string' }, + isFirstTimeCounterParty: { type: 'boolean' }, + isMutualConnection: { type: 'boolean' }, + netAmount: { type: 'number' }, + settlementId: { type: 'string' }, + userId: { type: 'string' }, + walletId: { type: 'string' }, + }, + required: [ + 'approvalRequestId', + 'counterPartyId', + 'enterpriseId', + 'idempotencyKey', + 'isFirstTimeCounterParty', + 'isMutualConnection', + 'settlementId', + 'userId', + 'walletId', + ], + }, + { + type: 'object', + properties: { + connectionId: { type: 'string' }, + description: { type: 'string' }, + enterpriseId: { type: 'string' }, + idempotencyKey: { type: 'string' }, + side: { + $ref: '#/components/schemas/AddressBookConnectionSides', + }, + walletId: { type: 'string' }, + }, + required: [ + 'connectionId', + 'enterpriseId', + 'idempotencyKey', + 'side', + 'walletId', + ], + }, + ], + }, + }, + }, + }, + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + AddressBookConnectionSides: { + enum: ['send', 'receive'], + title: 'AddressBookConnectionSides', + type: 'string', + }, + }, + }, + }, +); + const ROUTE_WITH_DUPLICATE_HEADERS = ` import * as t from 'io-ts'; import * as h from '@api-ts/io-ts-http'; From d2836f4efbb2e79de65aad1b9d8b4ec9301219cd Mon Sep 17 00:00:00 2001 From: Yan Date: Wed, 24 Sep 2025 10:05:46 -0700 Subject: [PATCH 2/2] fix: renamed testcase REAL_WORLD_POLICY_EVALUATION_ROUTE for clarity --- packages/openapi-generator/test/openapi/union.test.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/openapi-generator/test/openapi/union.test.ts b/packages/openapi-generator/test/openapi/union.test.ts index fb2d660a..22edbea1 100644 --- a/packages/openapi-generator/test/openapi/union.test.ts +++ b/packages/openapi-generator/test/openapi/union.test.ts @@ -361,11 +361,9 @@ export const route = h.httpRoute({ path: '/internal/api/policy/v1/{applicationName}/touchpoints/{touchpoint}/rules/evaluation', method: 'POST', request: t.union([ - // First schema has NO path parameters - this was causing the bug h.httpRequest({ body: { emptyRequest: t.boolean } }), - // Second schema HAS path parameters - these should be preserved h.httpRequest({ params: { applicationName: t.string, @@ -539,7 +537,7 @@ testCase( }, ); -const REAL_WORLD_POLICY_EVALUATION_ROUTE = ` +const ROUTE_WITH_FULLY_DEFINED_PARAMS = ` import * as t from 'io-ts'; import * as h from '@api-ts/io-ts-http'; @@ -597,8 +595,8 @@ export const route = h.httpRoute({ `; testCase( - 'real-world policy evaluation route with union request bodies', - REAL_WORLD_POLICY_EVALUATION_ROUTE, + 'union request with consistently defined path parameters', + ROUTE_WITH_FULLY_DEFINED_PARAMS, { info: { title: 'Test',