Skip to content

Commit 0fa84e7

Browse files
nx-plugin-for-awstest
andauthored
fix(open-api#ts-client): fail loud on non-encodable urlencoded array bodies (#938)
A top-level array/tuple application/x-www-form-urlencoded body has no property names to key on, so $urlEncodedForm emitted index-keyed pairs (0=a&1=b) that no form parser can decode. Raise a generation-time error directing users to an object or primitive schema, matching the existing fail-loud guards for ambiguous unions and non-JSON content params. The guard tracks the client's actual wire media type (JSON-preferred), so an array body offering both JSON and urlencoded is unaffected. Dictionary (additionalProperties) and object bodies remain form-encoded; primitives are sent verbatim (#937). Co-authored-by: test <test@example.com>
1 parent 2396847 commit 0fa84e7

2 files changed

Lines changed: 190 additions & 0 deletions

File tree

packages/nx-plugin/src/open-api/ts-client/generator.content-type.spec.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,4 +508,161 @@ describe('openApiTsClientGenerator - content type header', () => {
508508
}),
509509
);
510510
});
511+
512+
it('should form-encode a urlencoded dictionary (additionalProperties) body', async () => {
513+
const spec: Spec = {
514+
info: { title, version: '1.0.0' },
515+
openapi: '3.0.0',
516+
paths: {
517+
'/urlencoded-dict': {
518+
post: {
519+
requestBody: {
520+
required: true,
521+
content: {
522+
'application/x-www-form-urlencoded': {
523+
schema: {
524+
type: 'object',
525+
additionalProperties: { type: 'string' },
526+
},
527+
},
528+
},
529+
},
530+
responses: {
531+
'200': {
532+
content: {
533+
'application/json': {
534+
schema: { type: 'array', items: { type: 'string' } },
535+
},
536+
},
537+
description: 'Success',
538+
},
539+
},
540+
},
541+
},
542+
},
543+
};
544+
545+
tree.write('openapi.json', JSON.stringify(spec));
546+
547+
await openApiTsClientGenerator(tree, {
548+
openApiSpecPath: 'openapi.json',
549+
outputPath: 'src/generated',
550+
});
551+
552+
validateTypeScript([
553+
'src/generated/client.gen.ts',
554+
'src/generated/types.gen.ts',
555+
]);
556+
557+
const client = tree.read('src/generated/client.gen.ts', 'utf-8');
558+
const mockFetch = vi.fn();
559+
mockFetch.mockResolvedValue({
560+
status: 200,
561+
json: vi.fn().mockResolvedValue(['ok']),
562+
});
563+
564+
// Each dictionary entry becomes a form field.
565+
await callGeneratedClient(client, mockFetch, 'postUrlencodedDict', {
566+
first: 'a b',
567+
second: 'c&d',
568+
});
569+
expect(mockFetch).toHaveBeenCalledWith(
570+
`${baseUrl}/urlencoded-dict`,
571+
expect.objectContaining({
572+
method: 'POST',
573+
body: 'first=a+b&second=c%26d',
574+
headers: [['Content-Type', 'application/x-www-form-urlencoded']],
575+
}),
576+
);
577+
});
578+
579+
it('should throw for a urlencoded body whose schema is an array', async () => {
580+
// A top-level array has no property names to key on, so it has no defined
581+
// form encoding — fail fast rather than emit index-keyed pairs (0=a&1=b)
582+
// that no form parser can decode.
583+
const spec: Spec = {
584+
info: { title, version: '1.0.0' },
585+
openapi: '3.0.0',
586+
paths: {
587+
'/urlencoded-array': {
588+
post: {
589+
requestBody: {
590+
required: true,
591+
content: {
592+
'application/x-www-form-urlencoded': {
593+
schema: { type: 'array', items: { type: 'string' } },
594+
},
595+
},
596+
},
597+
responses: { '204': { description: 'No content' } },
598+
},
599+
},
600+
},
601+
};
602+
603+
tree.write('openapi.json', JSON.stringify(spec));
604+
605+
await expect(
606+
openApiTsClientGenerator(tree, {
607+
openApiSpecPath: 'openapi.json',
608+
outputPath: 'src/generated',
609+
}),
610+
).rejects.toThrow(/x-www-form-urlencoded.*array.*no defined form encoding/);
611+
});
612+
613+
it('should not throw for an array body offering both JSON and urlencoded', async () => {
614+
// JSON is the preferred wire type, so the array body is sent as JSON — the
615+
// urlencoded offering is never used and must not trip the form-encoding guard.
616+
const spec: Spec = {
617+
info: { title, version: '1.0.0' },
618+
openapi: '3.0.0',
619+
paths: {
620+
'/array-json-or-form': {
621+
post: {
622+
requestBody: {
623+
required: true,
624+
content: {
625+
'application/json': {
626+
schema: { type: 'array', items: { type: 'string' } },
627+
},
628+
'application/x-www-form-urlencoded': {
629+
schema: { type: 'array', items: { type: 'string' } },
630+
},
631+
},
632+
},
633+
responses: { '204': { description: 'No content' } },
634+
},
635+
},
636+
},
637+
};
638+
639+
tree.write('openapi.json', JSON.stringify(spec));
640+
641+
await openApiTsClientGenerator(tree, {
642+
openApiSpecPath: 'openapi.json',
643+
outputPath: 'src/generated',
644+
});
645+
646+
validateTypeScript([
647+
'src/generated/client.gen.ts',
648+
'src/generated/types.gen.ts',
649+
]);
650+
651+
const client = tree.read('src/generated/client.gen.ts', 'utf-8');
652+
const mockFetch = vi.fn();
653+
mockFetch.mockResolvedValue({ status: 204 });
654+
655+
await callGeneratedClient(client, mockFetch, 'postArrayJsonOrForm', [
656+
'a',
657+
'b',
658+
]);
659+
expect(mockFetch).toHaveBeenCalledWith(
660+
`${baseUrl}/array-json-or-form`,
661+
expect.objectContaining({
662+
method: 'POST',
663+
body: JSON.stringify(['a', 'b']),
664+
headers: [['Content-Type', 'application/json']],
665+
}),
666+
);
667+
});
511668
});

packages/nx-plugin/src/open-api/utils/codegen-data.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@ export const buildOpenApiCodeGenData = (inSpec: Spec): CodeGenData => {
9292
assertNoConflictingUnionMemberMarshalling(model);
9393
}
9494

95+
for (const op of allOperations) {
96+
assertEncodableUrlEncodedBody(op);
97+
}
98+
9599
data.models = orderBy(data.models, (d) => d.name);
96100
// Default service first, then by name.
97101
data.services = orderBy(data.services, (s) =>
@@ -572,6 +576,35 @@ const assertNoConflictingUnionMemberMarshalling = (model: Model): void => {
572576
}
573577
};
574578

579+
/**
580+
* An `application/x-www-form-urlencoded` body is form-encoded as `key=value`
581+
* pairs, so the wire form is only defined for a schema with named properties
582+
* (an object) or a primitive sent verbatim. A top-level array or tuple has no
583+
* property names to key on — encoding it would emit index keys (`0=a&1=b`) that
584+
* no form parser can decode — so fail fast rather than generate a client that
585+
* is silently wrong on the wire.
586+
*/
587+
const assertEncodableUrlEncodedBody = (op: Operation): void => {
588+
const body = op.parametersBody;
589+
if (!body?.mediaTypes) return;
590+
const mediaTypes = Array.isArray(body.mediaTypes)
591+
? body.mediaTypes
592+
: [body.mediaTypes];
593+
// Match the wire media type the client actually sends: JSON is preferred, so
594+
// an array body offering both JSON and urlencoded is sent as JSON and is fine.
595+
const chosenMediaType =
596+
mediaTypes.find(
597+
(mt) => mt === 'application/json' || mt.endsWith('+json'),
598+
) ?? mediaTypes[0];
599+
if (chosenMediaType !== 'application/x-www-form-urlencoded') return;
600+
if (body.isPrimitive) return;
601+
if (body.export === 'array' || body.export === 'tuple') {
602+
throw new Error(
603+
`Operation ${op.method} ${op.path} has an application/x-www-form-urlencoded request body whose schema is a ${body.export}, which has no defined form encoding. Use an object schema (its properties become the form fields) or a primitive schema (sent verbatim) in your OpenAPI specification.`,
604+
);
605+
}
606+
};
607+
575608
/**
576609
* Group operations by their (camelCased) tags, collecting any untagged
577610
* operations separately.

0 commit comments

Comments
 (0)