diff --git a/.changeset/large-rats-hug.md b/.changeset/large-rats-hug.md new file mode 100644 index 0000000000..046c5f9b00 --- /dev/null +++ b/.changeset/large-rats-hug.md @@ -0,0 +1,5 @@ +--- +"@hey-api/openapi-ts": patch +--- + +**client-ofetch**: fix FormData boundary mismatch diff --git a/examples/openapi-ts-ofetch/src/client/client/client.gen.ts b/examples/openapi-ts-ofetch/src/client/client/client.gen.ts index e0e3c2a417..66fae47f79 100644 --- a/examples/openapi-ts-ofetch/src/client/client/client.gen.ts +++ b/examples/openapi-ts-ofetch/src/client/client/client.gen.ts @@ -125,6 +125,7 @@ export const createClient = (config: Config = {}): Client => { const applyRequestInterceptors = async ( request: Request, opts: ResolvedRequestOptions, + body: BodyInit | null | undefined, ) => { for (const fn of interceptors.request.fns) { if (fn) { @@ -138,6 +139,18 @@ export const createClient = (config: Config = {}): Client => { // body comes only from getValidRequestBody(options) // reflect signal if present opts.signal = (request as any).signal as AbortSignal | undefined; + + // When body is FormData, remove Content-Type header to avoid boundary mismatch. + // Note: We already delete Content-Type in resolveOptions for FormData, but the + // Request constructor (line 175) re-adds it with an auto-generated boundary. + // Since we pass the original FormData (not the Request's body) to ofetch, and + // ofetch will generate its own boundary, we must remove the Request's Content-Type + // to let ofetch set the correct one. Otherwise the boundary in the header won't + // match the boundary in the actual multipart body sent by ofetch. + if (typeof FormData !== 'undefined' && body instanceof FormData) { + opts.headers.delete('Content-Type'); + } + return request; }; @@ -176,7 +189,7 @@ export const createClient = (config: Config = {}): Client => { }; let request = new Request(url, requestInit); - request = await applyRequestInterceptors(request, opts); + request = await applyRequestInterceptors(request, opts, networkBody); const finalUrl = request.url; // build ofetch options and perform the request (.raw keeps the Response) @@ -235,7 +248,7 @@ export const createClient = (config: Config = {}): Client => { method, onRequest: async (url, init) => { let request = new Request(url, init); - request = await applyRequestInterceptors(request, opts); + request = await applyRequestInterceptors(request, opts, networkBody); return request; }, serializedBody: networkBody as BodyInit | null | undefined, diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/base-url-false/client/client.gen.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/base-url-false/client/client.gen.ts index e0e3c2a417..66fae47f79 100644 --- a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/base-url-false/client/client.gen.ts +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/base-url-false/client/client.gen.ts @@ -125,6 +125,7 @@ export const createClient = (config: Config = {}): Client => { const applyRequestInterceptors = async ( request: Request, opts: ResolvedRequestOptions, + body: BodyInit | null | undefined, ) => { for (const fn of interceptors.request.fns) { if (fn) { @@ -138,6 +139,18 @@ export const createClient = (config: Config = {}): Client => { // body comes only from getValidRequestBody(options) // reflect signal if present opts.signal = (request as any).signal as AbortSignal | undefined; + + // When body is FormData, remove Content-Type header to avoid boundary mismatch. + // Note: We already delete Content-Type in resolveOptions for FormData, but the + // Request constructor (line 175) re-adds it with an auto-generated boundary. + // Since we pass the original FormData (not the Request's body) to ofetch, and + // ofetch will generate its own boundary, we must remove the Request's Content-Type + // to let ofetch set the correct one. Otherwise the boundary in the header won't + // match the boundary in the actual multipart body sent by ofetch. + if (typeof FormData !== 'undefined' && body instanceof FormData) { + opts.headers.delete('Content-Type'); + } + return request; }; @@ -176,7 +189,7 @@ export const createClient = (config: Config = {}): Client => { }; let request = new Request(url, requestInit); - request = await applyRequestInterceptors(request, opts); + request = await applyRequestInterceptors(request, opts, networkBody); const finalUrl = request.url; // build ofetch options and perform the request (.raw keeps the Response) @@ -235,7 +248,7 @@ export const createClient = (config: Config = {}): Client => { method, onRequest: async (url, init) => { let request = new Request(url, init); - request = await applyRequestInterceptors(request, opts); + request = await applyRequestInterceptors(request, opts, networkBody); return request; }, serializedBody: networkBody as BodyInit | null | undefined, diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/base-url-number/client/client.gen.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/base-url-number/client/client.gen.ts index e0e3c2a417..66fae47f79 100644 --- a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/base-url-number/client/client.gen.ts +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/base-url-number/client/client.gen.ts @@ -125,6 +125,7 @@ export const createClient = (config: Config = {}): Client => { const applyRequestInterceptors = async ( request: Request, opts: ResolvedRequestOptions, + body: BodyInit | null | undefined, ) => { for (const fn of interceptors.request.fns) { if (fn) { @@ -138,6 +139,18 @@ export const createClient = (config: Config = {}): Client => { // body comes only from getValidRequestBody(options) // reflect signal if present opts.signal = (request as any).signal as AbortSignal | undefined; + + // When body is FormData, remove Content-Type header to avoid boundary mismatch. + // Note: We already delete Content-Type in resolveOptions for FormData, but the + // Request constructor (line 175) re-adds it with an auto-generated boundary. + // Since we pass the original FormData (not the Request's body) to ofetch, and + // ofetch will generate its own boundary, we must remove the Request's Content-Type + // to let ofetch set the correct one. Otherwise the boundary in the header won't + // match the boundary in the actual multipart body sent by ofetch. + if (typeof FormData !== 'undefined' && body instanceof FormData) { + opts.headers.delete('Content-Type'); + } + return request; }; @@ -176,7 +189,7 @@ export const createClient = (config: Config = {}): Client => { }; let request = new Request(url, requestInit); - request = await applyRequestInterceptors(request, opts); + request = await applyRequestInterceptors(request, opts, networkBody); const finalUrl = request.url; // build ofetch options and perform the request (.raw keeps the Response) @@ -235,7 +248,7 @@ export const createClient = (config: Config = {}): Client => { method, onRequest: async (url, init) => { let request = new Request(url, init); - request = await applyRequestInterceptors(request, opts); + request = await applyRequestInterceptors(request, opts, networkBody); return request; }, serializedBody: networkBody as BodyInit | null | undefined, diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/base-url-strict/client/client.gen.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/base-url-strict/client/client.gen.ts index e0e3c2a417..66fae47f79 100644 --- a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/base-url-strict/client/client.gen.ts +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/base-url-strict/client/client.gen.ts @@ -125,6 +125,7 @@ export const createClient = (config: Config = {}): Client => { const applyRequestInterceptors = async ( request: Request, opts: ResolvedRequestOptions, + body: BodyInit | null | undefined, ) => { for (const fn of interceptors.request.fns) { if (fn) { @@ -138,6 +139,18 @@ export const createClient = (config: Config = {}): Client => { // body comes only from getValidRequestBody(options) // reflect signal if present opts.signal = (request as any).signal as AbortSignal | undefined; + + // When body is FormData, remove Content-Type header to avoid boundary mismatch. + // Note: We already delete Content-Type in resolveOptions for FormData, but the + // Request constructor (line 175) re-adds it with an auto-generated boundary. + // Since we pass the original FormData (not the Request's body) to ofetch, and + // ofetch will generate its own boundary, we must remove the Request's Content-Type + // to let ofetch set the correct one. Otherwise the boundary in the header won't + // match the boundary in the actual multipart body sent by ofetch. + if (typeof FormData !== 'undefined' && body instanceof FormData) { + opts.headers.delete('Content-Type'); + } + return request; }; @@ -176,7 +189,7 @@ export const createClient = (config: Config = {}): Client => { }; let request = new Request(url, requestInit); - request = await applyRequestInterceptors(request, opts); + request = await applyRequestInterceptors(request, opts, networkBody); const finalUrl = request.url; // build ofetch options and perform the request (.raw keeps the Response) @@ -235,7 +248,7 @@ export const createClient = (config: Config = {}): Client => { method, onRequest: async (url, init) => { let request = new Request(url, init); - request = await applyRequestInterceptors(request, opts); + request = await applyRequestInterceptors(request, opts, networkBody); return request; }, serializedBody: networkBody as BodyInit | null | undefined, diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/base-url-string/client/client.gen.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/base-url-string/client/client.gen.ts index e0e3c2a417..66fae47f79 100644 --- a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/base-url-string/client/client.gen.ts +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/base-url-string/client/client.gen.ts @@ -125,6 +125,7 @@ export const createClient = (config: Config = {}): Client => { const applyRequestInterceptors = async ( request: Request, opts: ResolvedRequestOptions, + body: BodyInit | null | undefined, ) => { for (const fn of interceptors.request.fns) { if (fn) { @@ -138,6 +139,18 @@ export const createClient = (config: Config = {}): Client => { // body comes only from getValidRequestBody(options) // reflect signal if present opts.signal = (request as any).signal as AbortSignal | undefined; + + // When body is FormData, remove Content-Type header to avoid boundary mismatch. + // Note: We already delete Content-Type in resolveOptions for FormData, but the + // Request constructor (line 175) re-adds it with an auto-generated boundary. + // Since we pass the original FormData (not the Request's body) to ofetch, and + // ofetch will generate its own boundary, we must remove the Request's Content-Type + // to let ofetch set the correct one. Otherwise the boundary in the header won't + // match the boundary in the actual multipart body sent by ofetch. + if (typeof FormData !== 'undefined' && body instanceof FormData) { + opts.headers.delete('Content-Type'); + } + return request; }; @@ -176,7 +189,7 @@ export const createClient = (config: Config = {}): Client => { }; let request = new Request(url, requestInit); - request = await applyRequestInterceptors(request, opts); + request = await applyRequestInterceptors(request, opts, networkBody); const finalUrl = request.url; // build ofetch options and perform the request (.raw keeps the Response) @@ -235,7 +248,7 @@ export const createClient = (config: Config = {}): Client => { method, onRequest: async (url, init) => { let request = new Request(url, init); - request = await applyRequestInterceptors(request, opts); + request = await applyRequestInterceptors(request, opts, networkBody); return request; }, serializedBody: networkBody as BodyInit | null | undefined, diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/clean-false/client/client.gen.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/clean-false/client/client.gen.ts index e0e3c2a417..66fae47f79 100644 --- a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/clean-false/client/client.gen.ts +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/clean-false/client/client.gen.ts @@ -125,6 +125,7 @@ export const createClient = (config: Config = {}): Client => { const applyRequestInterceptors = async ( request: Request, opts: ResolvedRequestOptions, + body: BodyInit | null | undefined, ) => { for (const fn of interceptors.request.fns) { if (fn) { @@ -138,6 +139,18 @@ export const createClient = (config: Config = {}): Client => { // body comes only from getValidRequestBody(options) // reflect signal if present opts.signal = (request as any).signal as AbortSignal | undefined; + + // When body is FormData, remove Content-Type header to avoid boundary mismatch. + // Note: We already delete Content-Type in resolveOptions for FormData, but the + // Request constructor (line 175) re-adds it with an auto-generated boundary. + // Since we pass the original FormData (not the Request's body) to ofetch, and + // ofetch will generate its own boundary, we must remove the Request's Content-Type + // to let ofetch set the correct one. Otherwise the boundary in the header won't + // match the boundary in the actual multipart body sent by ofetch. + if (typeof FormData !== 'undefined' && body instanceof FormData) { + opts.headers.delete('Content-Type'); + } + return request; }; @@ -176,7 +189,7 @@ export const createClient = (config: Config = {}): Client => { }; let request = new Request(url, requestInit); - request = await applyRequestInterceptors(request, opts); + request = await applyRequestInterceptors(request, opts, networkBody); const finalUrl = request.url; // build ofetch options and perform the request (.raw keeps the Response) @@ -235,7 +248,7 @@ export const createClient = (config: Config = {}): Client => { method, onRequest: async (url, init) => { let request = new Request(url, init); - request = await applyRequestInterceptors(request, opts); + request = await applyRequestInterceptors(request, opts, networkBody); return request; }, serializedBody: networkBody as BodyInit | null | undefined, diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/default/client/client.gen.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/default/client/client.gen.ts index e0e3c2a417..66fae47f79 100644 --- a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/default/client/client.gen.ts +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/default/client/client.gen.ts @@ -125,6 +125,7 @@ export const createClient = (config: Config = {}): Client => { const applyRequestInterceptors = async ( request: Request, opts: ResolvedRequestOptions, + body: BodyInit | null | undefined, ) => { for (const fn of interceptors.request.fns) { if (fn) { @@ -138,6 +139,18 @@ export const createClient = (config: Config = {}): Client => { // body comes only from getValidRequestBody(options) // reflect signal if present opts.signal = (request as any).signal as AbortSignal | undefined; + + // When body is FormData, remove Content-Type header to avoid boundary mismatch. + // Note: We already delete Content-Type in resolveOptions for FormData, but the + // Request constructor (line 175) re-adds it with an auto-generated boundary. + // Since we pass the original FormData (not the Request's body) to ofetch, and + // ofetch will generate its own boundary, we must remove the Request's Content-Type + // to let ofetch set the correct one. Otherwise the boundary in the header won't + // match the boundary in the actual multipart body sent by ofetch. + if (typeof FormData !== 'undefined' && body instanceof FormData) { + opts.headers.delete('Content-Type'); + } + return request; }; @@ -176,7 +189,7 @@ export const createClient = (config: Config = {}): Client => { }; let request = new Request(url, requestInit); - request = await applyRequestInterceptors(request, opts); + request = await applyRequestInterceptors(request, opts, networkBody); const finalUrl = request.url; // build ofetch options and perform the request (.raw keeps the Response) @@ -235,7 +248,7 @@ export const createClient = (config: Config = {}): Client => { method, onRequest: async (url, init) => { let request = new Request(url, init); - request = await applyRequestInterceptors(request, opts); + request = await applyRequestInterceptors(request, opts, networkBody); return request; }, serializedBody: networkBody as BodyInit | null | undefined, diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/import-file-extension-ts/client/client.gen.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/import-file-extension-ts/client/client.gen.ts index 952a4d0c2b..ebee86e7a9 100644 --- a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/import-file-extension-ts/client/client.gen.ts +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/import-file-extension-ts/client/client.gen.ts @@ -125,6 +125,7 @@ export const createClient = (config: Config = {}): Client => { const applyRequestInterceptors = async ( request: Request, opts: ResolvedRequestOptions, + body: BodyInit | null | undefined, ) => { for (const fn of interceptors.request.fns) { if (fn) { @@ -138,6 +139,18 @@ export const createClient = (config: Config = {}): Client => { // body comes only from getValidRequestBody(options) // reflect signal if present opts.signal = (request as any).signal as AbortSignal | undefined; + + // When body is FormData, remove Content-Type header to avoid boundary mismatch. + // Note: We already delete Content-Type in resolveOptions for FormData, but the + // Request constructor (line 175) re-adds it with an auto-generated boundary. + // Since we pass the original FormData (not the Request's body) to ofetch, and + // ofetch will generate its own boundary, we must remove the Request's Content-Type + // to let ofetch set the correct one. Otherwise the boundary in the header won't + // match the boundary in the actual multipart body sent by ofetch. + if (typeof FormData !== 'undefined' && body instanceof FormData) { + opts.headers.delete('Content-Type'); + } + return request; }; @@ -176,7 +189,7 @@ export const createClient = (config: Config = {}): Client => { }; let request = new Request(url, requestInit); - request = await applyRequestInterceptors(request, opts); + request = await applyRequestInterceptors(request, opts, networkBody); const finalUrl = request.url; // build ofetch options and perform the request (.raw keeps the Response) @@ -235,7 +248,7 @@ export const createClient = (config: Config = {}): Client => { method, onRequest: async (url, init) => { let request = new Request(url, init); - request = await applyRequestInterceptors(request, opts); + request = await applyRequestInterceptors(request, opts, networkBody); return request; }, serializedBody: networkBody as BodyInit | null | undefined, diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/sdk-client-optional/client/client.gen.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/sdk-client-optional/client/client.gen.ts index e0e3c2a417..66fae47f79 100644 --- a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/sdk-client-optional/client/client.gen.ts +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/sdk-client-optional/client/client.gen.ts @@ -125,6 +125,7 @@ export const createClient = (config: Config = {}): Client => { const applyRequestInterceptors = async ( request: Request, opts: ResolvedRequestOptions, + body: BodyInit | null | undefined, ) => { for (const fn of interceptors.request.fns) { if (fn) { @@ -138,6 +139,18 @@ export const createClient = (config: Config = {}): Client => { // body comes only from getValidRequestBody(options) // reflect signal if present opts.signal = (request as any).signal as AbortSignal | undefined; + + // When body is FormData, remove Content-Type header to avoid boundary mismatch. + // Note: We already delete Content-Type in resolveOptions for FormData, but the + // Request constructor (line 175) re-adds it with an auto-generated boundary. + // Since we pass the original FormData (not the Request's body) to ofetch, and + // ofetch will generate its own boundary, we must remove the Request's Content-Type + // to let ofetch set the correct one. Otherwise the boundary in the header won't + // match the boundary in the actual multipart body sent by ofetch. + if (typeof FormData !== 'undefined' && body instanceof FormData) { + opts.headers.delete('Content-Type'); + } + return request; }; @@ -176,7 +189,7 @@ export const createClient = (config: Config = {}): Client => { }; let request = new Request(url, requestInit); - request = await applyRequestInterceptors(request, opts); + request = await applyRequestInterceptors(request, opts, networkBody); const finalUrl = request.url; // build ofetch options and perform the request (.raw keeps the Response) @@ -235,7 +248,7 @@ export const createClient = (config: Config = {}): Client => { method, onRequest: async (url, init) => { let request = new Request(url, init); - request = await applyRequestInterceptors(request, opts); + request = await applyRequestInterceptors(request, opts, networkBody); return request; }, serializedBody: networkBody as BodyInit | null | undefined, diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/sdk-client-required/client/client.gen.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/sdk-client-required/client/client.gen.ts index e0e3c2a417..66fae47f79 100644 --- a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/sdk-client-required/client/client.gen.ts +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/sdk-client-required/client/client.gen.ts @@ -125,6 +125,7 @@ export const createClient = (config: Config = {}): Client => { const applyRequestInterceptors = async ( request: Request, opts: ResolvedRequestOptions, + body: BodyInit | null | undefined, ) => { for (const fn of interceptors.request.fns) { if (fn) { @@ -138,6 +139,18 @@ export const createClient = (config: Config = {}): Client => { // body comes only from getValidRequestBody(options) // reflect signal if present opts.signal = (request as any).signal as AbortSignal | undefined; + + // When body is FormData, remove Content-Type header to avoid boundary mismatch. + // Note: We already delete Content-Type in resolveOptions for FormData, but the + // Request constructor (line 175) re-adds it with an auto-generated boundary. + // Since we pass the original FormData (not the Request's body) to ofetch, and + // ofetch will generate its own boundary, we must remove the Request's Content-Type + // to let ofetch set the correct one. Otherwise the boundary in the header won't + // match the boundary in the actual multipart body sent by ofetch. + if (typeof FormData !== 'undefined' && body instanceof FormData) { + opts.headers.delete('Content-Type'); + } + return request; }; @@ -176,7 +189,7 @@ export const createClient = (config: Config = {}): Client => { }; let request = new Request(url, requestInit); - request = await applyRequestInterceptors(request, opts); + request = await applyRequestInterceptors(request, opts, networkBody); const finalUrl = request.url; // build ofetch options and perform the request (.raw keeps the Response) @@ -235,7 +248,7 @@ export const createClient = (config: Config = {}): Client => { method, onRequest: async (url, init) => { let request = new Request(url, init); - request = await applyRequestInterceptors(request, opts); + request = await applyRequestInterceptors(request, opts, networkBody); return request; }, serializedBody: networkBody as BodyInit | null | undefined, diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/tsconfig-nodenext-sdk/client/client.gen.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/tsconfig-nodenext-sdk/client/client.gen.ts index 3e1cf20537..8798e2a0e0 100644 --- a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/tsconfig-nodenext-sdk/client/client.gen.ts +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/clients/@hey-api/client-ofetch/tsconfig-nodenext-sdk/client/client.gen.ts @@ -125,6 +125,7 @@ export const createClient = (config: Config = {}): Client => { const applyRequestInterceptors = async ( request: Request, opts: ResolvedRequestOptions, + body: BodyInit | null | undefined, ) => { for (const fn of interceptors.request.fns) { if (fn) { @@ -138,6 +139,18 @@ export const createClient = (config: Config = {}): Client => { // body comes only from getValidRequestBody(options) // reflect signal if present opts.signal = (request as any).signal as AbortSignal | undefined; + + // When body is FormData, remove Content-Type header to avoid boundary mismatch. + // Note: We already delete Content-Type in resolveOptions for FormData, but the + // Request constructor (line 175) re-adds it with an auto-generated boundary. + // Since we pass the original FormData (not the Request's body) to ofetch, and + // ofetch will generate its own boundary, we must remove the Request's Content-Type + // to let ofetch set the correct one. Otherwise the boundary in the header won't + // match the boundary in the actual multipart body sent by ofetch. + if (typeof FormData !== 'undefined' && body instanceof FormData) { + opts.headers.delete('Content-Type'); + } + return request; }; @@ -176,7 +189,7 @@ export const createClient = (config: Config = {}): Client => { }; let request = new Request(url, requestInit); - request = await applyRequestInterceptors(request, opts); + request = await applyRequestInterceptors(request, opts, networkBody); const finalUrl = request.url; // build ofetch options and perform the request (.raw keeps the Response) @@ -235,7 +248,7 @@ export const createClient = (config: Config = {}): Client => { method, onRequest: async (url, init) => { let request = new Request(url, init); - request = await applyRequestInterceptors(request, opts); + request = await applyRequestInterceptors(request, opts, networkBody); return request; }, serializedBody: networkBody as BodyInit | null | undefined, diff --git a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/sse-ofetch/client/client.gen.ts b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/sse-ofetch/client/client.gen.ts index e0e3c2a417..66fae47f79 100644 --- a/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/sse-ofetch/client/client.gen.ts +++ b/packages/openapi-ts-tests/main/test/__snapshots__/3.1.x/sse-ofetch/client/client.gen.ts @@ -125,6 +125,7 @@ export const createClient = (config: Config = {}): Client => { const applyRequestInterceptors = async ( request: Request, opts: ResolvedRequestOptions, + body: BodyInit | null | undefined, ) => { for (const fn of interceptors.request.fns) { if (fn) { @@ -138,6 +139,18 @@ export const createClient = (config: Config = {}): Client => { // body comes only from getValidRequestBody(options) // reflect signal if present opts.signal = (request as any).signal as AbortSignal | undefined; + + // When body is FormData, remove Content-Type header to avoid boundary mismatch. + // Note: We already delete Content-Type in resolveOptions for FormData, but the + // Request constructor (line 175) re-adds it with an auto-generated boundary. + // Since we pass the original FormData (not the Request's body) to ofetch, and + // ofetch will generate its own boundary, we must remove the Request's Content-Type + // to let ofetch set the correct one. Otherwise the boundary in the header won't + // match the boundary in the actual multipart body sent by ofetch. + if (typeof FormData !== 'undefined' && body instanceof FormData) { + opts.headers.delete('Content-Type'); + } + return request; }; @@ -176,7 +189,7 @@ export const createClient = (config: Config = {}): Client => { }; let request = new Request(url, requestInit); - request = await applyRequestInterceptors(request, opts); + request = await applyRequestInterceptors(request, opts, networkBody); const finalUrl = request.url; // build ofetch options and perform the request (.raw keeps the Response) @@ -235,7 +248,7 @@ export const createClient = (config: Config = {}): Client => { method, onRequest: async (url, init) => { let request = new Request(url, init); - request = await applyRequestInterceptors(request, opts); + request = await applyRequestInterceptors(request, opts, networkBody); return request; }, serializedBody: networkBody as BodyInit | null | undefined, diff --git a/packages/openapi-ts/src/plugins/@hey-api/client-ofetch/__tests__/client.test.ts b/packages/openapi-ts/src/plugins/@hey-api/client-ofetch/__tests__/client.test.ts index c31912576d..1d8589bd6d 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/client-ofetch/__tests__/client.test.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/client-ofetch/__tests__/client.test.ts @@ -404,5 +404,118 @@ describe('request interceptor', () => { ); }); +describe('FormData boundary handling', () => { + const client = createClient({ baseUrl: 'https://example.com' }); + + it('should not include Content-Type header for FormData body to avoid boundary mismatch', async () => { + const mockResponse = new Response(JSON.stringify({ success: true }), { + headers: { + 'Content-Type': 'application/json', + }, + status: 200, + }); + + const mockOfetch = makeMockOfetch(mockResponse); + + const formData = new FormData(); + formData.append('field1', 'value1'); + formData.append('field2', 'value2'); + + await client.post({ + body: formData, + bodySerializer: null, + ofetch: mockOfetch as any, + url: '/upload', + }); + + // Verify that ofetch.raw was called + expect(mockOfetch.raw).toHaveBeenCalledOnce(); + + // Get the options passed to ofetch.raw + const call = (mockOfetch.raw as any).mock.calls[0]; + const opts = call[1]; + + // Verify that FormData is passed as body + expect(opts.body).toBeInstanceOf(FormData); + + // Verify that Content-Type header is NOT set (so ofetch can set its own boundary) + expect(opts.headers.get('Content-Type')).toBeNull(); + }); + + it('should preserve Content-Type header for non-FormData bodies', async () => { + const mockResponse = new Response(JSON.stringify({ success: true }), { + headers: { + 'Content-Type': 'application/json', + }, + status: 200, + }); + + const mockOfetch = makeMockOfetch(mockResponse); + + await client.post({ + body: { test: 'data' }, + ofetch: mockOfetch as any, + url: '/api', + }); + + // Verify that ofetch.raw was called + expect(mockOfetch.raw).toHaveBeenCalledOnce(); + + // Get the options passed to ofetch.raw + const call = (mockOfetch.raw as any).mock.calls[0]; + const opts = call[1]; + + // Verify that Content-Type header IS set for JSON + expect(opts.headers.get('Content-Type')).toBe('application/json'); + }); + + it('should handle FormData with interceptors correctly', async () => { + const mockResponse = new Response(JSON.stringify({ success: true }), { + headers: { + 'Content-Type': 'application/json', + }, + status: 200, + }); + + const mockOfetch = makeMockOfetch(mockResponse); + + const formData = new FormData(); + formData.append('field1', 'value1'); + + const mockRequestInterceptor = vi + .fn() + .mockImplementation((request: Request) => { + // Interceptor can modify headers but we should still remove Content-Type for FormData + request.headers.set('X-Custom-Header', 'custom-value'); + return request; + }); + + const interceptorId = client.interceptors.request.use( + mockRequestInterceptor, + ); + + await client.post({ + body: formData, + bodySerializer: null, + ofetch: mockOfetch as any, + url: '/upload', + }); + + expect(mockRequestInterceptor).toHaveBeenCalledOnce(); + + // Get the options passed to ofetch.raw + const call = (mockOfetch.raw as any).mock.calls[0]; + const opts = call[1]; + + // Verify that Content-Type is NOT set even after interceptor + expect(opts.headers.get('Content-Type')).toBeNull(); + + // Verify that custom header from interceptor IS preserved + expect(opts.headers.get('X-Custom-Header')).toBe('custom-value'); + + client.interceptors.request.eject(interceptorId); + }); +}); + // Note: дополнительные проверки поведения ofetch (responseType/responseStyle/retry) // не дублируем, чтобы набор тестов оставался сопоставим с другими клиентами. diff --git a/packages/openapi-ts/src/plugins/@hey-api/client-ofetch/bundle/client.ts b/packages/openapi-ts/src/plugins/@hey-api/client-ofetch/bundle/client.ts index 5d331e015c..3cb2c6ffc4 100644 --- a/packages/openapi-ts/src/plugins/@hey-api/client-ofetch/bundle/client.ts +++ b/packages/openapi-ts/src/plugins/@hey-api/client-ofetch/bundle/client.ts @@ -123,6 +123,7 @@ export const createClient = (config: Config = {}): Client => { const applyRequestInterceptors = async ( request: Request, opts: ResolvedRequestOptions, + body: BodyInit | null | undefined, ) => { for (const fn of interceptors.request.fns) { if (fn) { @@ -136,6 +137,18 @@ export const createClient = (config: Config = {}): Client => { // body comes only from getValidRequestBody(options) // reflect signal if present opts.signal = (request as any).signal as AbortSignal | undefined; + + // When body is FormData, remove Content-Type header to avoid boundary mismatch. + // Note: We already delete Content-Type in resolveOptions for FormData, but the + // Request constructor (line 175) re-adds it with an auto-generated boundary. + // Since we pass the original FormData (not the Request's body) to ofetch, and + // ofetch will generate its own boundary, we must remove the Request's Content-Type + // to let ofetch set the correct one. Otherwise the boundary in the header won't + // match the boundary in the actual multipart body sent by ofetch. + if (typeof FormData !== 'undefined' && body instanceof FormData) { + opts.headers.delete('Content-Type'); + } + return request; }; @@ -174,7 +187,7 @@ export const createClient = (config: Config = {}): Client => { }; let request = new Request(url, requestInit); - request = await applyRequestInterceptors(request, opts); + request = await applyRequestInterceptors(request, opts, networkBody); const finalUrl = request.url; // build ofetch options and perform the request (.raw keeps the Response) @@ -233,7 +246,7 @@ export const createClient = (config: Config = {}): Client => { method, onRequest: async (url, init) => { let request = new Request(url, init); - request = await applyRequestInterceptors(request, opts); + request = await applyRequestInterceptors(request, opts, networkBody); return request; }, serializedBody: networkBody as BodyInit | null | undefined,