Skip to content

Commit 2842460

Browse files
authored
fix(core): inline string type for $ref binary fields in x-www-form-urlencoded bodies (#3422)
When a property in an application/x-www-form-urlencoded request body is a $ref to a component schema whose body is { type: string, format: binary } (the shape Swashbuckle emits for C# IFormFile parameters), the generated body type used the imported component name. Because that standalone model is generated as Blob, URLSearchParams.append(key, value) then failed to type-check (TS2345: 'Blob' is not assignable to 'string'). Inline-binary properties already stayed as 'string' via formDataContext .urlEncoded inside scalar.ts (#1624 / #3395), but the component-$ref branch in resolveValue returned the import name without consulting that flag. Short-circuit there: when the resolved component schema is a binary scalar (string + format: binary, or contentMediaType: application/octet -stream w/o contentEncoding) and we are inside a url-encoded body, fall back to the inlined scalar so the property type collapses to 'string'. The standalone IFormFile model is intentionally left as Blob — the same component may be referenced by a multipart/form-data endpoint where Blob is correct. Only the url-encoded body property is rewritten in place. Runtime side is already correct: resolveSchemaPropertiesToFormData unwraps the $ref before checking property.format === 'binary', so the generated append call needs no change. Fixes #2410
1 parent 4809bce commit 2842460

26 files changed

Lines changed: 941 additions & 15 deletions

File tree

packages/core/src/getters/scalar.test.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, it } from 'vitest';
22

33
import type { ContextSpec, OpenApiSchemaObject } from '../types';
4-
import { getScalar } from './scalar';
4+
import { getScalar, isBinaryScalarSchema } from './scalar';
55

66
const context = {
77
output: {
@@ -60,3 +60,75 @@ describe('getScalar (contentMediaType: application/octet-stream)', () => {
6060
expect(result.value).toBe('string');
6161
});
6262
});
63+
64+
describe('isBinaryScalarSchema', () => {
65+
it('returns true for { type: "string", format: "binary" }', () => {
66+
expect(isBinaryScalarSchema({ type: 'string', format: 'binary' })).toBe(
67+
true,
68+
);
69+
});
70+
71+
it('returns true for { type: "string", contentMediaType: "application/octet-stream" }', () => {
72+
expect(
73+
isBinaryScalarSchema({
74+
type: 'string',
75+
contentMediaType: 'application/octet-stream',
76+
}),
77+
).toBe(true);
78+
});
79+
80+
it('returns false when contentMediaType has a contentEncoding (base64)', () => {
81+
expect(
82+
isBinaryScalarSchema({
83+
type: 'string',
84+
contentMediaType: 'application/octet-stream',
85+
contentEncoding: 'base64',
86+
}),
87+
).toBe(false);
88+
});
89+
90+
it('returns false for plain string without binary signals', () => {
91+
expect(isBinaryScalarSchema({ type: 'string' })).toBe(false);
92+
});
93+
94+
it('returns false for non-string scalars (number)', () => {
95+
expect(
96+
isBinaryScalarSchema({
97+
type: 'number',
98+
format: 'binary',
99+
} as OpenApiSchemaObject),
100+
).toBe(false);
101+
});
102+
103+
it('accepts OAS 3.1 nullable union [string, null] + format: binary', () => {
104+
// getScalar normalizes ['string','null'] → case 'string' before invoking
105+
// this predicate, so the predicate must agree to keep the url-encoded
106+
// $ref short-circuit firing for nullable binary scalars.
107+
expect(
108+
isBinaryScalarSchema({
109+
type: ['string', 'null'],
110+
format: 'binary',
111+
} as unknown as OpenApiSchemaObject),
112+
).toBe(true);
113+
});
114+
115+
it('accepts OAS 3.1 nullable union [string, null] + contentMediaType: octet-stream', () => {
116+
expect(
117+
isBinaryScalarSchema({
118+
type: ['string', 'null'],
119+
contentMediaType: 'application/octet-stream',
120+
} as unknown as OpenApiSchemaObject),
121+
).toBe(true);
122+
});
123+
124+
it('rejects mixed unions that include non-string non-null members', () => {
125+
// e.g. ['string','integer'] would not dispatch to case 'string' in
126+
// getScalar, so isBinaryScalarSchema must not promise binary semantics.
127+
expect(
128+
isBinaryScalarSchema({
129+
type: ['string', 'integer'],
130+
format: 'binary',
131+
} as unknown as OpenApiSchemaObject),
132+
).toBe(false);
133+
});
134+
});

packages/core/src/getters/scalar.ts

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,43 @@ import { getObject } from './object';
1717
/** Bridge type for enum values extracted from OpenAPI schemas infected by AnyOtherAttribute */
1818
type SchemaEnumValue = string | number | boolean | null;
1919

20+
/**
21+
* Returns true when a schema describes a raw binary string scalar — i.e. one
22+
* that getScalar's `case 'string':` branch would coerce to `Blob` outside a
23+
* url-encoded context (see the formDataContext.urlEncoded gate below). Shared
24+
* with resolveValue so the component-`$ref` urlEncoded short-circuit and the
25+
* inline scalar path stay in lockstep when new binary shapes are added
26+
* (#1624 / #3395 / #2410).
27+
*
28+
* Accepts OAS 3.1 nullable unions (`type: ['string', 'null']`) since getScalar
29+
* normalizes those into `case 'string':` before invoking this predicate.
30+
*/
31+
export function isBinaryScalarSchema(schema: OpenApiSchemaObject): boolean {
32+
const schemaType = schema.type as
33+
| OpenApiSchemaObjectType
34+
| OpenApiSchemaObjectType[]
35+
| undefined;
36+
const isStringLike =
37+
schemaType === 'string' ||
38+
(isArray(schemaType) &&
39+
schemaType.includes('string') &&
40+
schemaType.every((type) => type === 'string' || type === 'null'));
41+
if (!isStringLike) {
42+
return false;
43+
}
44+
if (schema.format === 'binary') {
45+
return true;
46+
}
47+
// The @scalar/openapi-parser upgrader rewrites format: binary to
48+
// contentMediaType: application/octet-stream during Swagger 2.0 / OAS 3.0 →
49+
// OAS 3.1 upgrades; treat the upgraded shape the same. A non-empty
50+
// contentEncoding signals an encoded string payload (e.g. base64), not raw
51+
// binary.
52+
const contentMediaType = schema.contentMediaType as string | undefined;
53+
const contentEncoding = schema.contentEncoding as string | undefined;
54+
return contentMediaType === 'application/octet-stream' && !contentEncoding;
55+
}
56+
2057
interface GetScalarOptions {
2158
item: OpenApiSchemaObject;
2259
name?: string;
@@ -50,8 +87,6 @@ export function getScalar({
5087
const schemaConst = item.const as string | undefined;
5188
const schemaFormat = item.format as string | undefined;
5289
const schemaNullable = item.nullable as boolean | undefined;
53-
const schemaContentMediaType = item.contentMediaType as string | undefined;
54-
const schemaContentEncoding = item.contentEncoding as string | undefined;
5590

5691
const nullable =
5792
(isArray(schemaType) && schemaType.includes('null')) ||
@@ -187,14 +222,11 @@ export function getScalar({
187222
if (fileType) {
188223
value = fileType === 'binary' ? 'Blob' : 'Blob | string';
189224
}
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.
225+
} else if (isBinaryScalarSchema(item)) {
226+
// The previous arm caught format: binary directly; this matches the
227+
// OAS 3.1 contentMediaType: application/octet-stream variant via the
228+
// shared predicate so any future binary shapes added there flow
229+
// through here too (#2410).
198230
value = 'Blob';
199231
}
200232
}

packages/core/src/resolvers/value.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { getScalar } from '../getters';
22
import type { FormDataContext } from '../getters/object';
33
import { isComponentRef } from '../getters/ref';
4+
import { isBinaryScalarSchema } from '../getters/scalar';
45
import type {
56
ContextSpec,
67
GeneratorImport,
@@ -70,6 +71,33 @@ export function resolveValue({
7071
return { ...scalar, originalSchema: schemaObject, isRef: false };
7172
}
7273

74+
// application/x-www-form-urlencoded bodies are serialized via
75+
// URLSearchParams.append(), which only accepts strings. Inline binary
76+
// properties already skip the Blob coercion via formDataContext.urlEncoded
77+
// inside scalar.ts (#1624 / #3395), but the component-$ref path below
78+
// returns the imported type name unconditionally — so e.g. C#'s
79+
// `IFormFile` (= Blob) leaks into the body type and fails to type-check.
80+
// When the resolved component schema is a binary scalar that scalar.ts
81+
// would coerce to Blob, fall back to the inlined scalar (which becomes
82+
// `string` under formDataContext.urlEncoded) instead of the import.
83+
// The standalone IFormFile model is intentionally left as Blob — it may
84+
// still be referenced by a multipart/form-data endpoint where Blob is
85+
// correct. Fixes #2410.
86+
if (formDataContext?.urlEncoded && isBinaryScalarSchema(schemaObject)) {
87+
// Pass the resolveValue input `name` (the property name) so this branch
88+
// truly behaves like an inline property schema. Using the component
89+
// import name here would leak component-based naming into downstream
90+
// scalar paths (validators/docs/naming) even though the resulting type
91+
// is plain `string`.
92+
const scalar = getScalar({
93+
item: schemaObject,
94+
name,
95+
context,
96+
formDataContext,
97+
});
98+
return { ...scalar, originalSchema: schemaObject, isRef: false };
99+
}
100+
73101
const resolvedImport = imports[0];
74102

75103
let hasReadonlyProps = false;

tests/__snapshots__/fetch/form-url-encoded-with-custom-fetch/endpoints.ts

Lines changed: 111 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@
44
* Swagger Petstore
55
* OpenAPI spec version: 1.0.0
66
*/
7-
import type { CreatePetsBody, Error, Pet, UploadPetContentBody } from './model';
7+
import type {
8+
CreatePetsBody,
9+
Error,
10+
Pet,
11+
UploadPetContentBody,
12+
UploadPetContentRefBody,
13+
} from './model';
814

915
import { faker } from '@faker-js/faker';
1016

@@ -150,6 +156,57 @@ export const uploadPetContent = async (
150156
});
151157
};
152158

159+
export type uploadPetContentRefResponse200 = {
160+
data: Pet;
161+
status: 200;
162+
};
163+
164+
export type uploadPetContentRefResponseDefault = {
165+
data: Error;
166+
status: Exclude<HTTPStatusCodes, 200>;
167+
};
168+
169+
export type uploadPetContentRefResponseSuccess =
170+
uploadPetContentRefResponse200 & {
171+
headers: Headers;
172+
};
173+
export type uploadPetContentRefResponseError =
174+
uploadPetContentRefResponseDefault & {
175+
headers: Headers;
176+
};
177+
178+
export type uploadPetContentRefResponse =
179+
| uploadPetContentRefResponseSuccess
180+
| uploadPetContentRefResponseError;
181+
182+
export const getUploadPetContentRefUrl = () => {
183+
return `/pets/upload-ref`;
184+
};
185+
186+
/**
187+
* @summary Upload pet content using a $ref to a binary component schema
188+
*/
189+
export const uploadPetContentRef = async (
190+
uploadPetContentRefBody: UploadPetContentRefBody,
191+
options?: RequestInit,
192+
): Promise<uploadPetContentRefResponse> => {
193+
const formUrlEncoded = new URLSearchParams();
194+
formUrlEncoded.append(`name`, uploadPetContentRefBody.name);
195+
if (uploadPetContentRefBody.content !== undefined) {
196+
formUrlEncoded.append(`content`, uploadPetContentRefBody.content);
197+
}
198+
199+
return customFetch<uploadPetContentRefResponse>(getUploadPetContentRefUrl(), {
200+
...options,
201+
method: 'POST',
202+
headers: {
203+
'Content-Type': 'application/x-www-form-urlencoded',
204+
...options?.headers,
205+
},
206+
body: formUrlEncoded,
207+
});
208+
};
209+
153210
export const getCreatePetsResponseMock = (
154211
overrideResponse: Partial<Extract<Pet, object>> = {},
155212
): Pet => ({
@@ -206,6 +263,34 @@ export const getUploadPetContentResponseMock = (
206263
...overrideResponse,
207264
});
208265

266+
export const getUploadPetContentRefResponseMock = (
267+
overrideResponse: Partial<Extract<Pet, object>> = {},
268+
): Pet => ({
269+
'@id': faker.helpers.arrayElement([
270+
faker.string.alpha({ length: { min: 10, max: 20 } }),
271+
undefined,
272+
]),
273+
id: faker.number.int(),
274+
name: faker.string.alpha({ length: { min: 10, max: 20 } }),
275+
tag: faker.helpers.arrayElement([
276+
faker.string.alpha({ length: { min: 10, max: 20 } }),
277+
undefined,
278+
]),
279+
email: faker.helpers.arrayElement([faker.internet.email(), undefined]),
280+
callingCode: faker.helpers.arrayElement([
281+
faker.helpers.arrayElement(['+33', '+420', '+33'] as const),
282+
undefined,
283+
]),
284+
country: faker.helpers.arrayElement([
285+
faker.helpers.arrayElement([
286+
"People's Republic of China",
287+
'Uruguay',
288+
] as const),
289+
undefined,
290+
]),
291+
...overrideResponse,
292+
});
293+
209294
export const getCreatePetsMockHandler = (
210295
overrideResponse?:
211296
| Pet
@@ -253,7 +338,32 @@ export const getUploadPetContentMockHandler = (
253338
options,
254339
);
255340
};
341+
342+
export const getUploadPetContentRefMockHandler = (
343+
overrideResponse?:
344+
| Pet
345+
| ((
346+
info: Parameters<Parameters<typeof http.post>[1]>[0],
347+
) => Promise<Pet> | Pet),
348+
options?: RequestHandlerOptions,
349+
) => {
350+
return http.post(
351+
'*/pets/upload-ref',
352+
async (info: Parameters<Parameters<typeof http.post>[1]>[0]) => {
353+
return HttpResponse.json(
354+
overrideResponse !== undefined
355+
? typeof overrideResponse === 'function'
356+
? await overrideResponse(info)
357+
: overrideResponse
358+
: getUploadPetContentRefResponseMock(),
359+
{ status: 200 },
360+
);
361+
},
362+
options,
363+
);
364+
};
256365
export const getSwaggerPetstoreMock = () => [
257366
getCreatePetsMockHandler(),
258367
getUploadPetContentMockHandler(),
368+
getUploadPetContentRefMockHandler(),
259369
];
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/**
2+
* Generated by orval v8.12.2 🍺
3+
* Do not edit manually.
4+
* Swagger Petstore
5+
* OpenAPI spec version: 1.0.0
6+
*/
7+
8+
export type IFormFile = Blob;

tests/__snapshots__/fetch/form-url-encoded-with-custom-fetch/model/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77

88
export * from './createPetsBody';
99
export * from './error';
10+
export * from './iFormFile';
1011
export * from './pet';
1112
export * from './petCallingCode';
1213
export * from './petCountry';
1314
export * from './uploadPetContentBody';
15+
export * from './uploadPetContentRefBody';
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/**
2+
* Generated by orval v8.12.2 🍺
3+
* Do not edit manually.
4+
* Swagger Petstore
5+
* OpenAPI spec version: 1.0.0
6+
*/
7+
8+
export type UploadPetContentRefBody = {
9+
name: string;
10+
content?: string;
11+
};

0 commit comments

Comments
 (0)