Skip to content

Commit b2731e4

Browse files
committed
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.
1 parent 43dee8f commit b2731e4

File tree

2 files changed

+342
-11
lines changed

2 files changed

+342
-11
lines changed

packages/openapi-generator/src/route.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -183,17 +183,20 @@ function parseRequestUnion(
183183
parameters.push(...headerParams.values());
184184
}
185185

186-
const firstSubSchema = schema.schemas[0];
187-
if (firstSubSchema !== undefined && firstSubSchema.type === 'object') {
188-
const pathSchema = firstSubSchema.properties['params'];
189-
if (pathSchema !== undefined && pathSchema.type === 'object') {
190-
for (const [name, prop] of Object.entries(pathSchema.properties)) {
191-
parameters.push({
192-
type: 'path',
193-
name,
194-
schema: prop,
195-
required: pathSchema.required.includes(name),
196-
});
186+
// Find the first schema in the union that has path parameters
187+
for (const subSchema of schema.schemas) {
188+
if (subSchema.type === 'object') {
189+
const pathSchema = subSchema.properties['params'];
190+
if (pathSchema !== undefined && pathSchema.type === 'object') {
191+
for (const [name, prop] of Object.entries(pathSchema.properties)) {
192+
parameters.push({
193+
type: 'path',
194+
name,
195+
schema: prop,
196+
required: pathSchema.required.includes(name),
197+
});
198+
}
199+
break; // Found path params, stop looking
197200
}
198201
}
199202
}

packages/openapi-generator/test/openapi/union.test.ts

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,334 @@ testCase('route with unknown unions', ROUTE_WITH_UNKNOWN_UNIONS, {
353353
},
354354
});
355355

356+
const ROUTE_WITH_PATH_PARAMS_IN_UNION_NOT_FIRST = `
357+
import * as t from 'io-ts';
358+
import * as h from '@api-ts/io-ts-http';
359+
360+
export const route = h.httpRoute({
361+
path: '/internal/api/policy/v1/{applicationName}/touchpoints/{touchpoint}/rules/evaluation',
362+
method: 'POST',
363+
request: t.union([
364+
// First schema has NO path parameters - this was causing the bug
365+
h.httpRequest({
366+
body: { emptyRequest: t.boolean }
367+
}),
368+
// Second schema HAS path parameters - these should be preserved
369+
h.httpRequest({
370+
params: {
371+
applicationName: t.string,
372+
touchpoint: t.string,
373+
},
374+
body: { requestWithParams: t.string }
375+
}),
376+
]),
377+
response: {
378+
200: t.string,
379+
},
380+
});
381+
`;
382+
383+
testCase(
384+
'route with path params in union second schema (regression test)',
385+
ROUTE_WITH_PATH_PARAMS_IN_UNION_NOT_FIRST,
386+
{
387+
info: {
388+
title: 'Test',
389+
version: '1.0.0',
390+
},
391+
openapi: '3.0.3',
392+
paths: {
393+
'/internal/api/policy/v1/{applicationName}/touchpoints/{touchpoint}/rules/evaluation': {
394+
post: {
395+
parameters: [
396+
{ in: 'path', name: 'applicationName', required: true, schema: { type: 'string' } },
397+
{ in: 'path', name: 'touchpoint', required: true, schema: { type: 'string' } },
398+
],
399+
requestBody: {
400+
content: {
401+
'application/json': {
402+
schema: {
403+
oneOf: [
404+
{
405+
properties: {
406+
emptyRequest: { type: 'boolean' }
407+
},
408+
required: ['emptyRequest'],
409+
type: 'object',
410+
},
411+
{
412+
properties: {
413+
requestWithParams: { type: 'string' }
414+
},
415+
required: ['requestWithParams'],
416+
type: 'object',
417+
},
418+
],
419+
},
420+
},
421+
},
422+
},
423+
responses: {
424+
'200': {
425+
description: 'OK',
426+
content: {
427+
'application/json': {
428+
schema: {
429+
type: 'string',
430+
},
431+
},
432+
},
433+
},
434+
},
435+
},
436+
},
437+
},
438+
components: {
439+
schemas: {},
440+
},
441+
},
442+
);
443+
444+
const ROUTE_WITH_PATH_PARAMS_ONLY_IN_THIRD_SCHEMA = `
445+
import * as t from 'io-ts';
446+
import * as h from '@api-ts/io-ts-http';
447+
448+
export const route = h.httpRoute({
449+
path: '/api/{userId}/posts/{postId}',
450+
method: 'GET',
451+
request: t.union([
452+
// First: empty request
453+
h.httpRequest({}),
454+
// Second: only query params
455+
h.httpRequest({
456+
query: { filter: t.string }
457+
}),
458+
// Third: has the path params
459+
h.httpRequest({
460+
params: {
461+
userId: t.string,
462+
postId: t.string,
463+
},
464+
query: { details: t.boolean }
465+
}),
466+
]),
467+
response: {
468+
200: t.string,
469+
},
470+
});
471+
`;
472+
473+
testCase(
474+
'route with path params only in third schema',
475+
ROUTE_WITH_PATH_PARAMS_ONLY_IN_THIRD_SCHEMA,
476+
{
477+
info: {
478+
title: 'Test',
479+
version: '1.0.0',
480+
},
481+
openapi: '3.0.3',
482+
paths: {
483+
'/api/{userId}/posts/{postId}': {
484+
get: {
485+
parameters: [
486+
{ in: 'query', name: 'union', required: true, explode: true, style: 'form', schema: {
487+
oneOf: [
488+
{
489+
properties: { filter: { type: 'string' } },
490+
required: ['filter'],
491+
type: 'object'
492+
},
493+
{
494+
properties: { details: { type: 'boolean' } },
495+
required: ['details'],
496+
type: 'object'
497+
}
498+
]
499+
}},
500+
{ in: 'path', name: 'userId', required: true, schema: { type: 'string' } },
501+
{ in: 'path', name: 'postId', required: true, schema: { type: 'string' } },
502+
],
503+
responses: {
504+
'200': {
505+
description: 'OK',
506+
content: {
507+
'application/json': {
508+
schema: {
509+
type: 'string',
510+
},
511+
},
512+
},
513+
},
514+
},
515+
},
516+
},
517+
},
518+
components: {
519+
schemas: {},
520+
},
521+
},
522+
);
523+
524+
const REAL_WORLD_POLICY_EVALUATION_ROUTE = `
525+
import * as t from 'io-ts';
526+
import * as h from '@api-ts/io-ts-http';
527+
528+
const AddressBookConnectionSides = t.union([t.literal('send'), t.literal('receive')]);
529+
530+
/**
531+
* Create policy evaluation definition
532+
* @operationId v1.post.policy.evaluation.definition
533+
* @tag Policy Builder
534+
* @private
535+
*/
536+
export const route = h.httpRoute({
537+
path: '/internal/api/policy/v1/{applicationName}/touchpoints/{touchpoint}/rules/evaluations',
538+
method: 'POST',
539+
request: t.union([
540+
h.httpRequest({
541+
params: {
542+
applicationName: t.string,
543+
touchpoint: t.string,
544+
},
545+
body: t.type({
546+
approvalRequestId: t.string,
547+
counterPartyId: t.string,
548+
description: h.optional(t.string),
549+
enterpriseId: t.string,
550+
grossAmount: h.optional(t.number),
551+
idempotencyKey: t.string,
552+
isFirstTimeCounterParty: t.boolean,
553+
isMutualConnection: t.boolean,
554+
netAmount: h.optional(t.number),
555+
settlementId: t.string,
556+
userId: t.string,
557+
walletId: t.string,
558+
})
559+
}),
560+
h.httpRequest({
561+
params: {
562+
applicationName: t.string,
563+
touchpoint: t.string,
564+
},
565+
body: t.type({
566+
connectionId: t.string,
567+
description: h.optional(t.string),
568+
enterpriseId: t.string,
569+
idempotencyKey: t.string,
570+
side: AddressBookConnectionSides,
571+
walletId: t.string,
572+
})
573+
}),
574+
]),
575+
response: {
576+
200: t.string,
577+
},
578+
});
579+
`;
580+
581+
testCase(
582+
'real-world policy evaluation route with union request bodies',
583+
REAL_WORLD_POLICY_EVALUATION_ROUTE,
584+
{
585+
info: {
586+
title: 'Test',
587+
version: '1.0.0',
588+
},
589+
openapi: '3.0.3',
590+
paths: {
591+
'/internal/api/policy/v1/{applicationName}/touchpoints/{touchpoint}/rules/evaluations': {
592+
post: {
593+
summary: 'Create policy evaluation definition',
594+
operationId: 'v1.post.policy.evaluation.definition',
595+
tags: ['Policy Builder'],
596+
'x-internal': true,
597+
parameters: [
598+
{ in: 'path', name: 'applicationName', required: true, schema: { type: 'string' } },
599+
{ in: 'path', name: 'touchpoint', required: true, schema: { type: 'string' } },
600+
],
601+
requestBody: {
602+
content: {
603+
'application/json': {
604+
schema: {
605+
oneOf: [
606+
{
607+
type: 'object',
608+
properties: {
609+
approvalRequestId: { type: 'string' },
610+
counterPartyId: { type: 'string' },
611+
description: { type: 'string' },
612+
enterpriseId: { type: 'string' },
613+
grossAmount: { type: 'number' },
614+
idempotencyKey: { type: 'string' },
615+
isFirstTimeCounterParty: { type: 'boolean' },
616+
isMutualConnection: { type: 'boolean' },
617+
netAmount: { type: 'number' },
618+
settlementId: { type: 'string' },
619+
userId: { type: 'string' },
620+
walletId: { type: 'string' },
621+
},
622+
required: [
623+
'approvalRequestId',
624+
'counterPartyId',
625+
'enterpriseId',
626+
'idempotencyKey',
627+
'isFirstTimeCounterParty',
628+
'isMutualConnection',
629+
'settlementId',
630+
'userId',
631+
'walletId'
632+
],
633+
},
634+
{
635+
type: 'object',
636+
properties: {
637+
connectionId: { type: 'string' },
638+
description: { type: 'string' },
639+
enterpriseId: { type: 'string' },
640+
idempotencyKey: { type: 'string' },
641+
side: { $ref: '#/components/schemas/AddressBookConnectionSides' },
642+
walletId: { type: 'string' },
643+
},
644+
required: [
645+
'connectionId',
646+
'enterpriseId',
647+
'idempotencyKey',
648+
'side',
649+
'walletId'
650+
],
651+
},
652+
],
653+
},
654+
},
655+
},
656+
},
657+
responses: {
658+
'200': {
659+
description: 'OK',
660+
content: {
661+
'application/json': {
662+
schema: {
663+
type: 'string',
664+
},
665+
},
666+
},
667+
},
668+
},
669+
},
670+
},
671+
},
672+
components: {
673+
schemas: {
674+
AddressBookConnectionSides: {
675+
enum: ['send', 'receive'],
676+
title: 'AddressBookConnectionSides',
677+
type: 'string',
678+
},
679+
},
680+
},
681+
},
682+
);
683+
356684
const ROUTE_WITH_DUPLICATE_HEADERS = `
357685
import * as t from 'io-ts';
358686
import * as h from '@api-ts/io-ts-http';

0 commit comments

Comments
 (0)