Skip to content

Commit b035ece

Browse files
authored
fix(core): treat binary fields in x-www-form-urlencoded bodies as strings (orval-labs#3395)
* fix(core): treat binary fields in x-www-form-urlencoded bodies as strings application/x-www-form-urlencoded bodies are built with URLSearchParams, whose append() only accepts strings. orval typed format: binary fields as Blob and emitted FormData-style append code for them, producing a TypeScript error (TS2345: 'Blob' is not assignable to 'string'). Treat file/binary fields in url-encoded bodies as strings, both in the generated body type and the URLSearchParams append code. This also covers the oneOf/anyOf runtime cast loop, which previously emitted Blob/Buffer handling that is invalid for URLSearchParams. Fixes orval-labs#1624 * fix(core): preserve enum unions on url-encoded string fields The url-encoded handling unconditionally reset string scalars to `string`, which erased enum literal unions (e.g. 'A' | 'B' became string). Gate only the Blob/file coercion behind the urlEncoded flag so enum unions stay intact.
1 parent 8125449 commit b035ece

17 files changed

Lines changed: 787 additions & 55 deletions

File tree

packages/core/src/getters/object.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -124,10 +124,11 @@ function getPropertyNamesRecordType(
124124
}
125125

126126
/**
127-
* Context for multipart/form-data type generation.
127+
* Context for form request body (multipart/form-data and
128+
* application/x-www-form-urlencoded) type generation.
128129
* Discriminated union with two states:
129130
*
130-
* 1. `{ atPart: false, encoding }` - At form-data root, before property iteration
131+
* 1. `{ atPart: false, encoding }` - At form root, before property iteration
131132
* - May traverse through allOf/anyOf/oneOf to reach properties
132133
* - Carries encoding map so getObject can look up `encoding[key]`
133134
*
@@ -136,11 +137,19 @@ function getPropertyNamesRecordType(
136137
* - Used by getScalar for file type detection (precedence over contentMediaType)
137138
* - Arrays pass this through to items; combiners inside arrays also get context
138139
*
139-
* `undefined` means not in form-data context (or nested inside plain object field = JSON)
140+
* `urlEncoded` marks an application/x-www-form-urlencoded body. Such bodies are
141+
* built with URLSearchParams, whose values are always strings, so getScalar
142+
* keeps file/binary fields as `string` instead of `Blob` (#1624).
143+
*
144+
* `undefined` means not in form context (or nested inside plain object field = JSON)
140145
*/
141146
export type FormDataContext =
142-
| { atPart: false; encoding: Record<string, { contentType?: string }> }
143-
| { atPart: true; partContentType?: string };
147+
| {
148+
atPart: false;
149+
encoding: Record<string, { contentType?: string }>;
150+
urlEncoded?: boolean;
151+
}
152+
| { atPart: true; partContentType?: string; urlEncoded?: boolean };
144153

145154
interface GetObjectOptions {
146155
item: OpenApiSchemaObject;
@@ -289,13 +298,15 @@ export function getObject({
289298
propName = propName + 'Property';
290299
}
291300

292-
// Transition multipart context: atPart: false → atPart: true
293-
// Look up encoding[key].contentType and pass to property resolution
301+
// Transition form context: atPart: false → atPart: true
302+
// Look up encoding[key].contentType and pass to property resolution.
303+
// The urlEncoded flag is carried through so nested scalars stay strings.
294304
const propertyFormDataContext: FormDataContext | undefined =
295305
formDataContext && !formDataContext.atPart
296306
? {
297307
atPart: true,
298308
partContentType: formDataContext.encoding[key]?.contentType, // eslint-disable-line @typescript-eslint/no-unnecessary-condition -- Record index access can return undefined at runtime
309+
urlEncoded: formDataContext.urlEncoded,
299310
}
300311
: undefined;
301312

packages/core/src/getters/res-req-types.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,118 @@ bodyRequestBody.photos.forEach(value => formData.append(\`photos\`, value));
432432
});
433433
});
434434

435+
// application/x-www-form-urlencoded is a text-only serialization built with
436+
// URLSearchParams, whose append() only accepts strings. Binary fields must
437+
// therefore be typed and appended as strings, not Blob (#1624).
438+
describe('x-www-form-urlencoded with binary fields (#1624)', () => {
439+
const urlEncodedReqBody: [string, OpenApiRequestBodyObject][] = [
440+
[
441+
'requestBody',
442+
{
443+
content: {
444+
'application/x-www-form-urlencoded': {
445+
schema: {
446+
type: 'object',
447+
properties: {
448+
// format: binary → would be Blob under multipart
449+
content_file: { type: 'string', format: 'binary' },
450+
// contentMediaType text file → Blob | string under multipart
451+
content_xml: {
452+
type: 'string',
453+
contentMediaType: 'application/xml',
454+
},
455+
content_string: { type: 'string' },
456+
// enum unions must survive the url-encoded handling
457+
kind: { type: 'string', enum: ['LOGO', 'CONTENT'] },
458+
},
459+
},
460+
},
461+
},
462+
required: true,
463+
},
464+
],
465+
];
466+
467+
it('types binary and file url-encoded fields as string, not Blob', () => {
468+
const result = getResReqTypes(urlEncodedReqBody, 'Asset', context)[0];
469+
470+
const bodySchema = result.schemas.find(
471+
(s) => s.name === 'AssetRequestBody',
472+
);
473+
expect(bodySchema).toBeDefined();
474+
expect(bodySchema?.model).toContain('content_file?: string;');
475+
expect(bodySchema?.model).toContain('content_xml?: string;');
476+
expect(bodySchema?.model).not.toContain('Blob');
477+
});
478+
479+
it('preserves enum unions on url-encoded string fields', () => {
480+
const result = getResReqTypes(urlEncodedReqBody, 'Asset', context)[0];
481+
482+
// url-encoded handling must not flatten enums down to `string`: the enum
483+
// is still extracted to its own type with the literal union intact.
484+
const bodySchema = result.schemas.find(
485+
(s) => s.name === 'AssetRequestBody',
486+
);
487+
expect(bodySchema?.model).toContain('kind?: AssetRequestBodyKind;');
488+
expect(bodySchema?.model).not.toContain('kind?: string;');
489+
490+
const kindSchema = result.schemas.find(
491+
(s) => s.name === 'AssetRequestBodyKind',
492+
);
493+
expect(kindSchema?.model).toContain("'LOGO'");
494+
expect(kindSchema?.model).toContain("'CONTENT'");
495+
});
496+
497+
it('appends binary and file url-encoded fields directly as strings', () => {
498+
const result = getResReqTypes(urlEncodedReqBody, 'Asset', context)[0];
499+
500+
expect(result.formUrlEncoded).toContain(
501+
'formUrlEncoded.append(`content_file`, assetRequestBody.content_file)',
502+
);
503+
expect(result.formUrlEncoded).toContain(
504+
'formUrlEncoded.append(`content_xml`, assetRequestBody.content_xml)',
505+
);
506+
expect(result.formUrlEncoded).not.toContain('Blob');
507+
});
508+
509+
it('uses a string-only runtime loop for oneOf/anyOf url-encoded bodies', () => {
510+
const oneOfReqBody: [string, OpenApiRequestBodyObject][] = [
511+
[
512+
'requestBody',
513+
{
514+
content: {
515+
'application/x-www-form-urlencoded': {
516+
schema: {
517+
oneOf: [
518+
{
519+
type: 'object',
520+
properties: {
521+
content_file: { type: 'string', format: 'binary' },
522+
},
523+
},
524+
{
525+
type: 'object',
526+
properties: { other: { type: 'string' } },
527+
},
528+
],
529+
},
530+
},
531+
},
532+
required: true,
533+
},
534+
],
535+
];
536+
537+
const result = getResReqTypes(oneOfReqBody, 'Asset', context)[0];
538+
539+
// URLSearchParams holds strings only — no File/Blob/Buffer branches
540+
expect(result.formUrlEncoded).toContain('formUrlEncoded.append(key,');
541+
expect(result.formUrlEncoded).not.toContain('Blob');
542+
expect(result.formUrlEncoded).not.toContain('Buffer');
543+
expect(result.formUrlEncoded).not.toContain('instanceof File');
544+
});
545+
});
546+
435547
describe('FormData with schema composition (oneOf/anyOf/allOf)', () => {
436548
// Covers: anyOf at root, nested oneOf, allOf with $ref (#2873)
437549
it('anyOf at root with scalar, array, oneOf, and allOf branches', () => {

packages/core/src/getters/res-req-types.ts

Lines changed: 47 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,15 @@ function getResReqContentTypes({
8989
return;
9090
}
9191

92-
// For form-data, pass context that tracks encoding for file type detection
92+
// For form bodies, pass context that tracks encoding for file type
93+
// detection. url-encoded bodies additionally flag `urlEncoded` so file/binary
94+
// fields are typed as `string` rather than `Blob` (#1624).
95+
const isFormUrlEncoded = formUrlEncodedContentTypes.has(contentType);
9396
const formDataContext: FormDataContext | undefined = isFormData
9497
? { atPart: false, encoding: mediaType.encoding ?? {} }
95-
: undefined;
98+
: isFormUrlEncoded
99+
? { atPart: false, encoding: mediaType.encoding ?? {}, urlEncoded: true }
100+
: undefined;
96101

97102
const resolvedObject = resolveObject({
98103
schema: mediaType.schema,
@@ -598,25 +603,39 @@ function getSchemaFormDataAndUrlEncoded({
598603
form += `Object.entries(${propName} ?? {}).forEach(([key, value]) => {\n`;
599604
form += skipLine;
600605
form += ` if (value !== undefined && value !== null) {\n`;
601-
form += ` if ((typeof File !== 'undefined' && value instanceof File) || value instanceof Blob) {\n`;
602-
form += ` ${variableName}.append(key, value);\n`;
603-
form += ` } else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(value)) {\n`;
604-
form += ` ${variableName}.append(key, new Blob([Uint8Array.from(value)]));\n`;
605-
form += ` } else if (Array.isArray(value)) {\n`;
606-
form += ` value.forEach(v => {\n`;
607-
form += ` if ((typeof File !== 'undefined' && v instanceof File) || v instanceof Blob) {\n`;
608-
form += ` ${variableName}.append(key, v);\n`;
609-
form += ` } else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(v)) {\n`;
610-
form += ` ${variableName}.append(key, new Blob([Uint8Array.from(v)]));\n`;
611-
form += ` } else {\n`;
612-
form += ` ${variableName}.append(key, typeof v === 'object' ? JSON.stringify(v) : String(v));\n`;
613-
form += ` }\n`;
614-
form += ` });\n`;
615-
form += ` } else if (typeof value === 'object') {\n`;
616-
form += ` ${variableName}.append(key, JSON.stringify(value));\n`;
617-
form += ` } else {\n`;
618-
form += ` ${variableName}.append(key, String(value));\n`;
619-
form += ` }\n`;
606+
if (isUrlEncoded) {
607+
// url-encoded: URLSearchParams holds strings only, so File/Blob/
608+
// Buffer handling does not apply — coerce every value to string (#1624)
609+
form += ` if (Array.isArray(value)) {\n`;
610+
form += ` value.forEach(v => {\n`;
611+
form += ` ${variableName}.append(key, typeof v === 'object' ? JSON.stringify(v) : String(v));\n`;
612+
form += ` });\n`;
613+
form += ` } else if (typeof value === 'object') {\n`;
614+
form += ` ${variableName}.append(key, JSON.stringify(value));\n`;
615+
form += ` } else {\n`;
616+
form += ` ${variableName}.append(key, String(value));\n`;
617+
form += ` }\n`;
618+
} else {
619+
form += ` if ((typeof File !== 'undefined' && value instanceof File) || value instanceof Blob) {\n`;
620+
form += ` ${variableName}.append(key, value);\n`;
621+
form += ` } else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(value)) {\n`;
622+
form += ` ${variableName}.append(key, new Blob([Uint8Array.from(value)]));\n`;
623+
form += ` } else if (Array.isArray(value)) {\n`;
624+
form += ` value.forEach(v => {\n`;
625+
form += ` if ((typeof File !== 'undefined' && v instanceof File) || v instanceof Blob) {\n`;
626+
form += ` ${variableName}.append(key, v);\n`;
627+
form += ` } else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(v)) {\n`;
628+
form += ` ${variableName}.append(key, new Blob([Uint8Array.from(v)]));\n`;
629+
form += ` } else {\n`;
630+
form += ` ${variableName}.append(key, typeof v === 'object' ? JSON.stringify(v) : String(v));\n`;
631+
form += ` }\n`;
632+
form += ` });\n`;
633+
form += ` } else if (typeof value === 'object') {\n`;
634+
form += ` ${variableName}.append(key, JSON.stringify(value));\n`;
635+
form += ` } else {\n`;
636+
form += ` ${variableName}.append(key, String(value));\n`;
637+
form += ` }\n`;
638+
}
620639
form += ` }\n`;
621640
form += `});\n`;
622641
} else {
@@ -710,6 +729,9 @@ function resolveSchemaPropertiesToFormData({
710729
encoding,
711730
}: ResolveSchemaPropertiesToFormDataOptions): string {
712731
let formDataValues = '';
732+
// url-encoded bodies use URLSearchParams (string values only), so file/binary
733+
// fields are appended as plain strings rather than wrapped in a Blob (#1624).
734+
const isUrlEncoded = variableName === 'formUrlEncoded';
713735
const schemaProps = getSchemaProperties(schema) ?? {};
714736
for (const [key, value] of Object.entries(schemaProps)) {
715737
const { schema: property } = resolveSchemaRef(value, context);
@@ -742,7 +764,10 @@ function resolveSchemaPropertiesToFormData({
742764
const effectiveContentType =
743765
partContentType ?? (property.contentMediaType as string | undefined);
744766

745-
if (fileType === 'binary' || property.format === 'binary') {
767+
if (isUrlEncoded && (fileType || property.format === 'binary')) {
768+
// url-encoded: file/binary fields are plain strings (URLSearchParams)
769+
formDataValue = `${variableName}.append(\`${keyPrefix}${key}\`, ${nonOptionalValueKey});\n`;
770+
} else if (fileType === 'binary' || property.format === 'binary') {
746771
// Binary: append directly (value is Blob)
747772
formDataValue = `${variableName}.append(\`${keyPrefix}${key}\`, ${nonOptionalValueKey});\n`;
748773
} else if (fileType === 'text') {

packages/core/src/getters/scalar.ts

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -173,25 +173,30 @@ export function getScalar({
173173
isEnum = true;
174174
}
175175

176-
if (schemaFormat === 'binary') {
177-
value = 'Blob';
178-
} else if (formDataContext?.atPart) {
179-
const fileType = getFormDataFieldFileType(
180-
item,
181-
formDataContext.partContentType,
182-
);
183-
if (fileType) {
184-
value = fileType === 'binary' ? 'Blob' : 'Blob | string';
176+
// application/x-www-form-urlencoded bodies are built with URLSearchParams,
177+
// whose values are always strings. Skip Blob/file coercion so file/binary
178+
// fields stay `string`; enum unions computed above are left intact (#1624).
179+
if (!formDataContext?.urlEncoded) {
180+
if (schemaFormat === 'binary') {
181+
value = 'Blob';
182+
} else if (formDataContext?.atPart) {
183+
const fileType = getFormDataFieldFileType(
184+
item,
185+
formDataContext.partContentType,
186+
);
187+
if (fileType) {
188+
value = fileType === 'binary' ? 'Blob' : 'Blob | string';
189+
}
190+
} else if (
191+
schemaContentMediaType === 'application/octet-stream' &&
192+
!schemaContentEncoding
193+
) {
194+
// The @scalar/openapi-parser upgrader converts format: binary to
195+
// contentMediaType: application/octet-stream when upgrading
196+
// Swagger 2.0 / OAS 3.0 → OAS 3.1. Treat it the same as
197+
// format: binary so $ref-based model types generate Blob.
198+
value = 'Blob';
185199
}
186-
} else if (
187-
schemaContentMediaType === 'application/octet-stream' &&
188-
!schemaContentEncoding
189-
) {
190-
// The @scalar/openapi-parser upgrader converts format: binary to
191-
// contentMediaType: application/octet-stream when upgrading
192-
// Swagger 2.0 / OAS 3.0 → OAS 3.1. Treat it the same as
193-
// format: binary so $ref-based model types generate Blob.
194-
value = 'Blob';
195200
}
196201

197202
if (

0 commit comments

Comments
 (0)