Skip to content

Commit edf4322

Browse files
Copilotmrlubos
andcommitted
Fix FormData boundary mismatch in OFetch client
Co-authored-by: mrlubos <12529395+mrlubos@users.noreply.github.com>
1 parent b690d5c commit edf4322

File tree

2 files changed

+126
-2
lines changed

2 files changed

+126
-2
lines changed

packages/openapi-ts/src/plugins/@hey-api/client-ofetch/__tests__/client.test.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,5 +404,118 @@ describe('request interceptor', () => {
404404
);
405405
});
406406

407+
describe('FormData boundary handling', () => {
408+
const client = createClient({ baseUrl: 'https://example.com' });
409+
410+
it('should not include Content-Type header for FormData body to avoid boundary mismatch', async () => {
411+
const mockResponse = new Response(JSON.stringify({ success: true }), {
412+
headers: {
413+
'Content-Type': 'application/json',
414+
},
415+
status: 200,
416+
});
417+
418+
const mockOfetch = makeMockOfetch(mockResponse);
419+
420+
const formData = new FormData();
421+
formData.append('field1', 'value1');
422+
formData.append('field2', 'value2');
423+
424+
await client.post({
425+
body: formData,
426+
bodySerializer: null,
427+
ofetch: mockOfetch as any,
428+
url: '/upload',
429+
});
430+
431+
// Verify that ofetch.raw was called
432+
expect(mockOfetch.raw).toHaveBeenCalledOnce();
433+
434+
// Get the options passed to ofetch.raw
435+
const call = (mockOfetch.raw as any).mock.calls[0];
436+
const opts = call[1];
437+
438+
// Verify that FormData is passed as body
439+
expect(opts.body).toBeInstanceOf(FormData);
440+
441+
// Verify that Content-Type header is NOT set (so ofetch can set its own boundary)
442+
expect(opts.headers.get('Content-Type')).toBeNull();
443+
});
444+
445+
it('should preserve Content-Type header for non-FormData bodies', async () => {
446+
const mockResponse = new Response(JSON.stringify({ success: true }), {
447+
headers: {
448+
'Content-Type': 'application/json',
449+
},
450+
status: 200,
451+
});
452+
453+
const mockOfetch = makeMockOfetch(mockResponse);
454+
455+
await client.post({
456+
body: { test: 'data' },
457+
ofetch: mockOfetch as any,
458+
url: '/api',
459+
});
460+
461+
// Verify that ofetch.raw was called
462+
expect(mockOfetch.raw).toHaveBeenCalledOnce();
463+
464+
// Get the options passed to ofetch.raw
465+
const call = (mockOfetch.raw as any).mock.calls[0];
466+
const opts = call[1];
467+
468+
// Verify that Content-Type header IS set for JSON
469+
expect(opts.headers.get('Content-Type')).toBe('application/json');
470+
});
471+
472+
it('should handle FormData with interceptors correctly', async () => {
473+
const mockResponse = new Response(JSON.stringify({ success: true }), {
474+
headers: {
475+
'Content-Type': 'application/json',
476+
},
477+
status: 200,
478+
});
479+
480+
const mockOfetch = makeMockOfetch(mockResponse);
481+
482+
const formData = new FormData();
483+
formData.append('field1', 'value1');
484+
485+
const mockRequestInterceptor = vi
486+
.fn()
487+
.mockImplementation((request: Request) => {
488+
// Interceptor can modify headers but we should still remove Content-Type for FormData
489+
request.headers.set('X-Custom-Header', 'custom-value');
490+
return request;
491+
});
492+
493+
const interceptorId = client.interceptors.request.use(
494+
mockRequestInterceptor,
495+
);
496+
497+
await client.post({
498+
body: formData,
499+
bodySerializer: null,
500+
ofetch: mockOfetch as any,
501+
url: '/upload',
502+
});
503+
504+
expect(mockRequestInterceptor).toHaveBeenCalledOnce();
505+
506+
// Get the options passed to ofetch.raw
507+
const call = (mockOfetch.raw as any).mock.calls[0];
508+
const opts = call[1];
509+
510+
// Verify that Content-Type is NOT set even after interceptor
511+
expect(opts.headers.get('Content-Type')).toBeNull();
512+
513+
// Verify that custom header from interceptor IS preserved
514+
expect(opts.headers.get('X-Custom-Header')).toBe('custom-value');
515+
516+
client.interceptors.request.eject(interceptorId);
517+
});
518+
});
519+
407520
// Note: дополнительные проверки поведения ofetch (responseType/responseStyle/retry)
408521
// не дублируем, чтобы набор тестов оставался сопоставим с другими клиентами.

packages/openapi-ts/src/plugins/@hey-api/client-ofetch/bundle/client.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ export const createClient = (config: Config = {}): Client => {
123123
const applyRequestInterceptors = async (
124124
request: Request,
125125
opts: ResolvedRequestOptions,
126+
body: BodyInit | null | undefined,
126127
) => {
127128
for (const fn of interceptors.request.fns) {
128129
if (fn) {
@@ -136,6 +137,16 @@ export const createClient = (config: Config = {}): Client => {
136137
// body comes only from getValidRequestBody(options)
137138
// reflect signal if present
138139
opts.signal = (request as any).signal as AbortSignal | undefined;
140+
141+
// When body is FormData, remove Content-Type header to avoid boundary mismatch.
142+
// The Request constructor auto-generates a boundary and sets Content-Type, but
143+
// we pass the original FormData to ofetch which will generate its own boundary.
144+
// If we keep the Request's Content-Type, the boundary in the header won't match
145+
// the boundary in the actual request body sent by ofetch.
146+
if (typeof FormData !== 'undefined' && body instanceof FormData) {
147+
opts.headers.delete('Content-Type');
148+
}
149+
139150
return request;
140151
};
141152

@@ -174,7 +185,7 @@ export const createClient = (config: Config = {}): Client => {
174185
};
175186
let request = new Request(url, requestInit);
176187

177-
request = await applyRequestInterceptors(request, opts);
188+
request = await applyRequestInterceptors(request, opts, networkBody);
178189
const finalUrl = request.url;
179190

180191
// build ofetch options and perform the request (.raw keeps the Response)
@@ -233,7 +244,7 @@ export const createClient = (config: Config = {}): Client => {
233244
method,
234245
onRequest: async (url, init) => {
235246
let request = new Request(url, init);
236-
request = await applyRequestInterceptors(request, opts);
247+
request = await applyRequestInterceptors(request, opts, networkBody);
237248
return request;
238249
},
239250
serializedBody: networkBody as BodyInit | null | undefined,

0 commit comments

Comments
 (0)