Skip to content

Commit 9396a0e

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 9396a0e

File tree

2 files changed

+373
-11
lines changed

2 files changed

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

0 commit comments

Comments
 (0)