Skip to content

Commit 2dcd053

Browse files
committed
fix(core): inline string type for $ref binary fields in x-www-form-urlencoded bodies
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 6fb7494 commit 2dcd053

18 files changed

Lines changed: 696 additions & 4 deletions

File tree

packages/core/src/resolvers/value.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,25 @@ interface ResolveValueOptions {
1919
formDataContext?: FormDataContext;
2020
}
2121

22+
// Mirrors the binary→Blob coercion in scalar.ts so callers can ask
23+
// "would scalar.ts treat this schema as a Blob outside a url-encoded context?"
24+
// Kept in sync with packages/core/src/getters/scalar.ts (#1624 / #3395 / #2410).
25+
function isBinaryScalarSchema(schema: OpenApiSchemaObject): boolean {
26+
if (schema.type !== 'string') {
27+
return false;
28+
}
29+
if (schema.format === 'binary') {
30+
return true;
31+
}
32+
// The @scalar/openapi-parser upgrader rewrites format: binary to
33+
// contentMediaType: application/octet-stream during Swagger 2.0 / OAS 3.0 →
34+
// OAS 3.1 upgrades; treat the upgraded shape the same. A non-empty
35+
// contentEncoding signals an encoded string payload, not raw binary.
36+
const contentMediaType = schema.contentMediaType as string | undefined;
37+
const contentEncoding = schema.contentEncoding as string | undefined;
38+
return contentMediaType === 'application/octet-stream' && !contentEncoding;
39+
}
40+
2241
export function resolveValue({
2342
schema,
2443
name,
@@ -70,6 +89,28 @@ export function resolveValue({
7089
return { ...scalar, originalSchema: schemaObject, isRef: false };
7190
}
7291

92+
// application/x-www-form-urlencoded bodies are serialized via
93+
// URLSearchParams.append(), which only accepts strings. Inline binary
94+
// properties already skip the Blob coercion via formDataContext.urlEncoded
95+
// inside scalar.ts (#1624 / #3395), but the component-$ref path below
96+
// returns the imported type name unconditionally — so e.g. C#'s
97+
// `IFormFile` (= Blob) leaks into the body type and fails to type-check.
98+
// When the resolved component schema is a binary scalar that scalar.ts
99+
// would coerce to Blob, fall back to the inlined scalar (which becomes
100+
// `string` under formDataContext.urlEncoded) instead of the import.
101+
// The standalone IFormFile model is intentionally left as Blob — it may
102+
// still be referenced by a multipart/form-data endpoint where Blob is
103+
// correct. Fixes #2410.
104+
if (formDataContext?.urlEncoded && isBinaryScalarSchema(schemaObject)) {
105+
const scalar = getScalar({
106+
item: schemaObject,
107+
name: imports[0]?.name,
108+
context,
109+
formDataContext,
110+
});
111+
return { ...scalar, originalSchema: schemaObject, isRef: false };
112+
}
113+
73114
const resolvedImport = imports[0];
74115

75116
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+
};

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

Lines changed: 122 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

@@ -167,6 +173,68 @@ export const uploadPetContent = async (
167173
} as uploadPetContentResponse;
168174
};
169175

176+
export type uploadPetContentRefResponse200 = {
177+
data: Pet;
178+
status: 200;
179+
};
180+
181+
export type uploadPetContentRefResponseDefault = {
182+
data: Error;
183+
status: Exclude<HTTPStatusCodes, 200>;
184+
};
185+
186+
export type uploadPetContentRefResponseSuccess =
187+
uploadPetContentRefResponse200 & {
188+
headers: Headers;
189+
};
190+
export type uploadPetContentRefResponseError =
191+
uploadPetContentRefResponseDefault & {
192+
headers: Headers;
193+
};
194+
195+
export type uploadPetContentRefResponse =
196+
| uploadPetContentRefResponseSuccess
197+
| uploadPetContentRefResponseError;
198+
199+
export const getUploadPetContentRefUrl = () => {
200+
return `/pets/upload-ref`;
201+
};
202+
203+
/**
204+
* @summary Upload pet content using a $ref to a binary component schema
205+
*/
206+
export const uploadPetContentRef = async (
207+
uploadPetContentRefBody: UploadPetContentRefBody,
208+
options?: RequestInit,
209+
): Promise<uploadPetContentRefResponse> => {
210+
const formUrlEncoded = new URLSearchParams();
211+
formUrlEncoded.append(`name`, uploadPetContentRefBody.name);
212+
if (uploadPetContentRefBody.content !== undefined) {
213+
formUrlEncoded.append(`content`, uploadPetContentRefBody.content);
214+
}
215+
216+
const res = await fetch(getUploadPetContentRefUrl(), {
217+
...options,
218+
method: 'POST',
219+
headers: {
220+
'Content-Type': 'application/x-www-form-urlencoded',
221+
...options?.headers,
222+
},
223+
body: formUrlEncoded,
224+
});
225+
226+
const body = [204, 205, 304].includes(res.status) ? null : await res.text();
227+
228+
const data: uploadPetContentRefResponse['data'] = body
229+
? JSON.parse(body)
230+
: {};
231+
return {
232+
data,
233+
status: res.status,
234+
headers: res.headers,
235+
} as uploadPetContentRefResponse;
236+
};
237+
170238
export const getCreatePetsResponseMock = (
171239
overrideResponse: Partial<Extract<Pet, object>> = {},
172240
): Pet => ({
@@ -223,6 +291,34 @@ export const getUploadPetContentResponseMock = (
223291
...overrideResponse,
224292
});
225293

294+
export const getUploadPetContentRefResponseMock = (
295+
overrideResponse: Partial<Extract<Pet, object>> = {},
296+
): Pet => ({
297+
'@id': faker.helpers.arrayElement([
298+
faker.string.alpha({ length: { min: 10, max: 20 } }),
299+
undefined,
300+
]),
301+
id: faker.number.int(),
302+
name: faker.string.alpha({ length: { min: 10, max: 20 } }),
303+
tag: faker.helpers.arrayElement([
304+
faker.string.alpha({ length: { min: 10, max: 20 } }),
305+
undefined,
306+
]),
307+
email: faker.helpers.arrayElement([faker.internet.email(), undefined]),
308+
callingCode: faker.helpers.arrayElement([
309+
faker.helpers.arrayElement(['+33', '+420', '+33'] as const),
310+
undefined,
311+
]),
312+
country: faker.helpers.arrayElement([
313+
faker.helpers.arrayElement([
314+
"People's Republic of China",
315+
'Uruguay',
316+
] as const),
317+
undefined,
318+
]),
319+
...overrideResponse,
320+
});
321+
226322
export const getCreatePetsMockHandler = (
227323
overrideResponse?:
228324
| Pet
@@ -270,7 +366,32 @@ export const getUploadPetContentMockHandler = (
270366
options,
271367
);
272368
};
369+
370+
export const getUploadPetContentRefMockHandler = (
371+
overrideResponse?:
372+
| Pet
373+
| ((
374+
info: Parameters<Parameters<typeof http.post>[1]>[0],
375+
) => Promise<Pet> | Pet),
376+
options?: RequestHandlerOptions,
377+
) => {
378+
return http.post(
379+
'*/pets/upload-ref',
380+
async (info: Parameters<Parameters<typeof http.post>[1]>[0]) => {
381+
return HttpResponse.json(
382+
overrideResponse !== undefined
383+
? typeof overrideResponse === 'function'
384+
? await overrideResponse(info)
385+
: overrideResponse
386+
: getUploadPetContentRefResponseMock(),
387+
{ status: 200 },
388+
);
389+
},
390+
options,
391+
);
392+
};
273393
export const getSwaggerPetstoreMock = () => [
274394
getCreatePetsMockHandler(),
275395
getUploadPetContentMockHandler(),
396+
getUploadPetContentRefMockHandler(),
276397
];
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/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';

0 commit comments

Comments
 (0)