fork of hey-api/openapi-ts because I need some additional things
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Fix FormData boundary mismatch in OFetch client

Co-authored-by: mrlubos <12529395+mrlubos@users.noreply.github.com>

+126 -2
+113
packages/openapi-ts/src/plugins/@hey-api/client-ofetch/__tests__/client.test.ts
··· 404 404 ); 405 405 }); 406 406 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 + 407 520 // Note: дополнительные проверки поведения ofetch (responseType/responseStyle/retry) 408 521 // не дублируем, чтобы набор тестов оставался сопоставим с другими клиентами.
+13 -2
packages/openapi-ts/src/plugins/@hey-api/client-ofetch/bundle/client.ts
··· 123 123 const applyRequestInterceptors = async ( 124 124 request: Request, 125 125 opts: ResolvedRequestOptions, 126 + body: BodyInit | null | undefined, 126 127 ) => { 127 128 for (const fn of interceptors.request.fns) { 128 129 if (fn) { ··· 136 137 // body comes only from getValidRequestBody(options) 137 138 // reflect signal if present 138 139 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 + 139 150 return request; 140 151 }; 141 152 ··· 174 185 }; 175 186 let request = new Request(url, requestInit); 176 187 177 - request = await applyRequestInterceptors(request, opts); 188 + request = await applyRequestInterceptors(request, opts, networkBody); 178 189 const finalUrl = request.url; 179 190 180 191 // build ofetch options and perform the request (.raw keeps the Response) ··· 233 244 method, 234 245 onRequest: async (url, init) => { 235 246 let request = new Request(url, init); 236 - request = await applyRequestInterceptors(request, opts); 247 + request = await applyRequestInterceptors(request, opts, networkBody); 237 248 return request; 238 249 }, 239 250 serializedBody: networkBody as BodyInit | null | undefined,