diff --git a/.changeset/solid-flies-agree.md b/.changeset/solid-flies-agree.md new file mode 100644 index 000000000..0e0382b24 --- /dev/null +++ b/.changeset/solid-flies-agree.md @@ -0,0 +1,5 @@ +--- +"@shopware/api-client": patch +--- + +Fix file uploads and other binary requests. The client no longer forces the default `Content-Type: application/json` onto `FormData`, `Blob`/`File`, `URLSearchParams`, or binary/stream bodies, so the runtime can set the right content type itself (e.g. `multipart/form-data` with a boundary). Just pass the body and leave `Content-Type` alone. diff --git a/packages/api-client/README.md b/packages/api-client/README.md index cb317842f..422286816 100644 --- a/packages/api-client/README.md +++ b/packages/api-client/README.md @@ -379,6 +379,37 @@ async function loadMainNavigation() { } ``` +### Uploading files (`multipart/form-data`) and other binary bodies + +Some endpoints accept binary uploads sent as `multipart/form-data` - for example the Admin API `uploadV2 post /_action/media/upload`. For these requests, build a [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) instance and pass it as `body`: + +```typescript +const formData = new FormData(); +formData.append("file", file); // a `File` or `Blob`, e.g. from an +formData.append("fileName", "my-image"); + +await adminApiClient.invoke("uploadV2 post /_action/media/upload", { + // `contentType` / `accept` are type-level metadata on this operation and are + // ignored at runtime - the request Content-Type is derived from the body + contentType: "multipart/form-data", + accept: "application/json", + // pass the FormData directly; the typed object shape is only for guidance + body: formData as unknown as { file: Blob }, +}); +``` + +> [!IMPORTANT] +> Do not set the `Content-Type` header yourself, and pass a real body object, not a plain JSON object. +> +> A `multipart/form-data` request must carry a unique `boundary` parameter (`Content-Type: multipart/form-data; boundary=...`). Only the runtime - the browser's `fetch` or `undici` on the server - can generate it, and only when no `Content-Type` is present. A hard-coded `Content-Type: multipart/form-data` has no boundary, so the server cannot parse the payload. + +How the client handles this for you: + +- The client seeds a default `Content-Type: application/json` on every request. When the `body` is one the runtime must type itself - `FormData`, `Blob`/`File`, `URLSearchParams`, `ArrayBuffer`/typed arrays, or a stream - the client removes that default `Content-Type` so the runtime can set the correct one (a `multipart/form-data` boundary, the blob's MIME type, `application/x-www-form-urlencoded`, and so on). This applies to both the Store and Admin clients, in the browser and on the server. +- A `Content-Type` you set explicitly is preserved (for example an `image/png` for a typeless `Blob`, or a content type for a raw stream) - except a boundary-less `multipart/form-data`, which is always replaced. +- Always pass a real `FormData` / `Blob` / stream. A plain object is serialized to JSON (`{ file: Blob }` becomes `{"file":{}}`), which is not a valid upload. +- The generated operation types describe the body as a plain object (e.g. `{ file: Blob }`) for discoverability. At runtime you must provide the real body, so a cast like `body: formData as unknown as ` may be required depending on your setup. + ### Error handling Client is throwing `ApiClientError` with detailed information returned from the API. It will display clear message in the console or you can access `details` property to get raw information from the response. diff --git a/packages/api-client/src/createAPIClient.ts b/packages/api-client/src/createAPIClient.ts index 7297cf433..9a4d42211 100644 --- a/packages/api-client/src/createAPIClient.ts +++ b/packages/api-client/src/createAPIClient.ts @@ -1,4 +1,3 @@ -import { defu } from "defu"; import { createHooks } from "hookable"; import { type FetchContext, @@ -11,6 +10,7 @@ import { import type { operations } from "../api-types/storeApiTypes"; import { type ClientHeaders, createHeaders } from "./defaultHeaders"; import { errorInterceptor } from "./errorInterceptor"; +import { resolveRequestHeaders } from "./resolveRequestHeaders"; import { createPathWithParams } from "./transformPathToQuery"; type SimpleUnionOmit = T extends unknown @@ -187,16 +187,11 @@ export function createAPIClient< ...(currentParams.fetchOptions || {}), }; - let mergedHeaders = defu(currentParams.headers, defaultHeaders); - - if ( - mergedHeaders?.["Content-Type"]?.includes("multipart/form-data") && - typeof window !== "undefined" - ) { - // multipart/form-data must not be set manually when it's used by the browser - const { "Content-Type": _, ...headersWithoutContentType } = mergedHeaders; - mergedHeaders = headersWithoutContentType; - } + const mergedHeaders = resolveRequestHeaders( + currentParams.headers, + defaultHeaders, + currentParams.body, + ); const resp = await apiFetch.raw< SimpleUnionPick diff --git a/packages/api-client/src/createAdminAPIClient.ts b/packages/api-client/src/createAdminAPIClient.ts index 581a519f5..9dd804c51 100644 --- a/packages/api-client/src/createAdminAPIClient.ts +++ b/packages/api-client/src/createAdminAPIClient.ts @@ -1,4 +1,3 @@ -import { defu } from "defu"; import { createHooks } from "hookable"; import { type FetchOptions, @@ -12,6 +11,7 @@ import type { InvokeParameters } from "./createAPIClient"; import type { GlobalFetchOptions } from "./createAPIClient"; import { type ClientHeaders, createHeaders } from "./defaultHeaders"; import { errorInterceptor } from "./errorInterceptor"; +import { resolveRequestHeaders } from "./resolveRequestHeaders"; import { createPathWithParams } from "./transformPathToQuery"; type SimpleUnionOmit = T extends unknown @@ -154,7 +154,10 @@ export function createAdminAPIClient< refresh_token: sessionData.refreshToken, }; - // Access session expired, first we need to refresh it with refresh token + // Access session expired, first we need to refresh it with refresh token. + // The token body is always a plain JSON object, so this request + // intentionally uses defaultHeaders directly and skips + // resolveRequestHeaders (which only matters for non-JSON bodies). await ofetch("/oauth/token", { baseURL: params.baseURL, method: "POST", @@ -239,13 +242,19 @@ export function createAdminAPIClient< ...(currentParams.fetchOptions || {}), }; + const mergedHeaders = resolveRequestHeaders( + currentParams.headers, + defaultHeaders, + currentParams.body, + ); + const resp = await apiFetch.raw< SimpleUnionPick >(requestPathWithParams, { ...fetchOptions, method, body: currentParams.body, - headers: defu(currentParams.headers, defaultHeaders) as HeadersInit, + headers: mergedHeaders as HeadersInit, query: currentParams.query, }); diff --git a/packages/api-client/src/createAdminApiClient.test.ts b/packages/api-client/src/createAdminApiClient.test.ts index 2560bd3c1..419a47130 100644 --- a/packages/api-client/src/createAdminApiClient.test.ts +++ b/packages/api-client/src/createAdminApiClient.test.ts @@ -725,4 +725,46 @@ describe("createAdminAPIClient", () => { ); }); }); + + it("should let the runtime set multipart/form-data with a boundary for FormData uploads", async () => { + const contentTypeSpy = vi.fn().mockImplementation(() => {}); + const app = createApp().use( + "/_action/media/upload", + eventHandler(async (event) => { + contentTypeSpy(getHeaders(event)); + return { id: "media-id" }; + }), + ); + + const baseURL = await createPortAndGetUrl(app); + + const client = createAdminAPIClient({ + baseURL, + sessionData: { + accessToken: "Bearer my-access-token", + refreshToken: "my-refresh-token", + expirationTime: Date.now() + 1000 * 60, + }, + }); + + const formData = new FormData(); + formData.append("file", new Blob(["file-content"]), "file.txt"); + + await client.invoke("uploadV2 post /_action/media/upload", { + // contentType/accept are type-level metadata for this operation and are + // ignored at runtime; the request Content-Type is derived from the body + contentType: "multipart/form-data", + accept: "application/json", + // the typed body is a plain object; at runtime a FormData is required + body: formData as unknown as { file: Blob }, + }); + + expect(contentTypeSpy).toHaveBeenCalledTimes(1); + const headers = contentTypeSpy.mock.calls[0]?.[0]; + // The default application/json must be removed so the runtime can set + // multipart/form-data with a boundary. + expect(headers?.["content-type"]).toMatch( + /^multipart\/form-data; boundary=/, + ); + }); }); diff --git a/packages/api-client/src/createApiClient.test.ts b/packages/api-client/src/createApiClient.test.ts index 9def0c48f..c3036ed8e 100644 --- a/packages/api-client/src/createApiClient.test.ts +++ b/packages/api-client/src/createApiClient.test.ts @@ -315,9 +315,7 @@ describe("createAPIClient", () => { ); }); - it("should remove multipart/form-data headers in case of browser", async () => { - // @vitest-environment happy-dom - + it("should let the runtime set multipart/form-data with a boundary for FormData bodies", async () => { const contentTypeSpy = vi.fn().mockImplementation(() => {}); const app = createApp().use( "/core/upload", @@ -336,11 +334,16 @@ describe("createAPIClient", () => { baseURL, }); + const formData = new FormData(); + formData.append("file", new Blob(["file-content"]), "file.txt"); + // @ts-expect-error this endpoint does not exist await client.invoke("fileUpload post /core/upload", { + // a manually set multipart/form-data has no boundary and must be replaced headers: { "Content-Type": "multipart/form-data", }, + body: formData, }); expect(contentTypeSpy).toHaveBeenCalledTimes(1); @@ -351,13 +354,11 @@ describe("createAPIClient", () => { "sw-access-key": "123", "sw-context-token": "456", }); - // Content-Type header should be removed when multipart/form-data in browser - expect(headers?.["content-type"]).toBeUndefined(); - // Verify multipart/form-data is not present in any header value - const headerValues = Object.values(headers || {}).join(" "); - expect(headerValues).not.toContain("multipart/form-data"); - // User-agent should exist (platform-specific, so just check presence) - expect(headers?.["user-agent"]).toBeTruthy(); + // The default application/json (and the boundary-less multipart) must be + // gone, replaced by a runtime-generated multipart/form-data + boundary. + expect(headers?.["content-type"]).toMatch( + /^multipart\/form-data; boundary=/, + ); }); it("should trigger success callback", async () => { @@ -443,8 +444,11 @@ describe("createAPIClient", () => { controller.abort(); - await expect(request).rejects.toThrowErrorMatchingInlineSnapshot( - `[FetchError: [GET] "${baseURL}context": signal is aborted without reason]`, + // The exact abort reason ("signal is aborted without reason" vs "This + // operation was aborted") depends on the runtime and on the abort race, so + // assert the stable parts: a FetchError for the aborted GET /context. + await expect(request).rejects.toThrow( + new RegExp(`\\[GET\\] "${baseURL}context": .*aborted`, "i"), ); }); diff --git a/packages/api-client/src/resolveRequestHeaders.test.ts b/packages/api-client/src/resolveRequestHeaders.test.ts new file mode 100644 index 000000000..e1b5ff3d0 --- /dev/null +++ b/packages/api-client/src/resolveRequestHeaders.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from "vitest"; + +import type { ClientHeaders } from "./defaultHeaders"; +import { resolveRequestHeaders } from "./resolveRequestHeaders"; + +const DEFAULTS: ClientHeaders = { + "Content-Type": "application/json", + accept: "application/json", +}; + +function contentTypeOf(headers: ClientHeaders): string | undefined { + return Object.entries(headers).find( + ([key]) => key.toLowerCase() === "content-type", + )?.[1]; +} + +describe("resolveRequestHeaders", () => { + it("keeps the default application/json for a plain object body", () => { + const headers = resolveRequestHeaders(undefined, DEFAULTS, { id: "1" }); + expect(contentTypeOf(headers)).toBe("application/json"); + expect(headers.accept).toBe("application/json"); + }); + + it("keeps the default when there is no body", () => { + const headers = resolveRequestHeaders(undefined, DEFAULTS, undefined); + expect(contentTypeOf(headers)).toBe("application/json"); + }); + + it("returns the merged headers when no Content-Type is present at all", () => { + const headers = resolveRequestHeaders( + { "sw-access-key": "key" }, + { accept: "application/json" }, + new FormData(), + ); + expect(contentTypeOf(headers)).toBeUndefined(); + expect(headers).toEqual({ + "sw-access-key": "key", + accept: "application/json", + }); + }); + + it("drops the default Content-Type for a FormData body", () => { + const body = new FormData(); + body.append("file", new Blob(["x"]), "f.txt"); + const headers = resolveRequestHeaders(undefined, DEFAULTS, body); + expect(contentTypeOf(headers)).toBeUndefined(); + expect(headers.accept).toBe("application/json"); + }); + + it("drops a manually set multipart/form-data (no boundary) for FormData", () => { + const body = new FormData(); + const headers = resolveRequestHeaders( + { "Content-Type": "multipart/form-data" }, + DEFAULTS, + body, + ); + expect(contentTypeOf(headers)).toBeUndefined(); + }); + + it("preserves a caller multipart/form-data that already carries a boundary (pre-encoded body)", () => { + const headers = resolveRequestHeaders( + { "Content-Type": "multipart/form-data; boundary=abc123" }, + DEFAULTS, + "--abc123\r\nContent-Disposition: form-data; name=a\r\n\r\n1\r\n--abc123--", + ); + expect(contentTypeOf(headers)).toBe("multipart/form-data; boundary=abc123"); + }); + + it("still drops a manual boundary for a FormData body (the runtime regenerates it)", () => { + const headers = resolveRequestHeaders( + { "Content-Type": "multipart/form-data; boundary=stale" }, + DEFAULTS, + new FormData(), + ); + expect(contentTypeOf(headers)).toBeUndefined(); + }); + + it("drops a multipart/form-data with an empty/malformed boundary", () => { + for (const value of [ + "multipart/form-data; boundary=", + "multipart/form-data; boundary=;x", + ]) { + const headers = resolveRequestHeaders( + { "Content-Type": value }, + DEFAULTS, + "raw-body", + ); + expect(contentTypeOf(headers)).toBeUndefined(); + } + }); + + it("preserves a non-JSON client-level default for a runtime-managed body", () => { + // e.g. client.defaultHeaders.apply({ "Content-Type": "application/octet-stream" }) + const clientDefaults: ClientHeaders = { + "Content-Type": "application/octet-stream", + accept: "application/json", + }; + expect( + contentTypeOf( + resolveRequestHeaders( + undefined, + clientDefaults, + new Uint8Array([1, 2]), + ), + ), + ).toBe("application/octet-stream"); + expect( + contentTypeOf( + resolveRequestHeaders(undefined, clientDefaults, { pipe() {} }), + ), + ).toBe("application/octet-stream"); + }); + + it("drops the default Content-Type for a URLSearchParams body", () => { + const headers = resolveRequestHeaders( + undefined, + DEFAULTS, + new URLSearchParams({ a: "1" }), + ); + expect(contentTypeOf(headers)).toBeUndefined(); + }); + + it("drops the default Content-Type for a Blob body", () => { + const headers = resolveRequestHeaders( + undefined, + DEFAULTS, + new Blob(["x"], { type: "image/png" }), + ); + expect(contentTypeOf(headers)).toBeUndefined(); + }); + + it("drops the default Content-Type for binary bodies (ArrayBuffer / typed array)", () => { + const view = new Uint8Array([1, 2, 3]); + expect( + contentTypeOf(resolveRequestHeaders(undefined, DEFAULTS, view)), + ).toBeUndefined(); + expect( + contentTypeOf(resolveRequestHeaders(undefined, DEFAULTS, view.buffer)), + ).toBeUndefined(); + }); + + it("drops the default Content-Type for a stream-like body", () => { + const stream = { pipe() {} }; + expect( + contentTypeOf(resolveRequestHeaders(undefined, DEFAULTS, stream)), + ).toBeUndefined(); + }); + + it("preserves a Content-Type the caller set explicitly for a Blob", () => { + const headers = resolveRequestHeaders( + { "Content-Type": "image/png" }, + DEFAULTS, + new Blob(["x"]), + ); + expect(contentTypeOf(headers)).toBe("image/png"); + }); + + it("preserves a caller Content-Type set with a lowercase key", () => { + const headers = resolveRequestHeaders( + { "content-type": "application/pdf" }, + DEFAULTS, + new Blob(["x"]), + ); + expect(contentTypeOf(headers)).toBe("application/pdf"); + }); +}); diff --git a/packages/api-client/src/resolveRequestHeaders.ts b/packages/api-client/src/resolveRequestHeaders.ts new file mode 100644 index 000000000..ac676458b --- /dev/null +++ b/packages/api-client/src/resolveRequestHeaders.ts @@ -0,0 +1,110 @@ +import { defu } from "defu"; + +import type { ClientHeaders } from "./defaultHeaders"; + +const CONTENT_TYPE = "content-type"; + +function findContentType(headers: ClientHeaders): string | undefined { + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === CONTENT_TYPE) return value; + } + return undefined; +} + +function contentTypeKeyCount(headers: ClientHeaders): number { + let count = 0; + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === CONTENT_TYPE) count++; + } + return count; +} + +function withoutContentType(headers: ClientHeaders): ClientHeaders { + return Object.fromEntries( + Object.entries(headers).filter( + ([key]) => key.toLowerCase() !== CONTENT_TYPE, + ), + ); +} + +/** + * Bodies whose `Content-Type` must be set by the runtime (browser `fetch` / + * `undici`), not by the client. `ofetch` does not serialize these and does not + * add a `Content-Type`, so the client's default `application/json` would be + * sent incorrectly: + * - `FormData` needs `multipart/form-data` with a generated boundary + * - `URLSearchParams` needs `application/x-www-form-urlencoded` + * - `Blob`/`File` carries its own MIME type + * - `ArrayBuffer`/typed arrays/streams are raw binary payloads + */ +function isRuntimeManagedBody(body: unknown): boolean { + if (!body || typeof body !== "object") return false; + return ( + (typeof FormData !== "undefined" && body instanceof FormData) || + (typeof URLSearchParams !== "undefined" && + body instanceof URLSearchParams) || + (typeof Blob !== "undefined" && body instanceof Blob) || + body instanceof ArrayBuffer || + ArrayBuffer.isView(body) || + typeof (body as { pipe?: unknown }).pipe === "function" || + typeof (body as { pipeTo?: unknown }).pipeTo === "function" + ); +} + +/** + * Merge request headers with the client defaults, then drop a `Content-Type` + * the runtime must set itself. + * + * The default `Content-Type: application/json` seeded for every client is wrong + * for bodies the runtime types on its own. The header is removed when: + * - the body is runtime-managed (`FormData`, `Blob`/`File`, `URLSearchParams`, + * binary, stream) and the `Content-Type` is the seeded `application/json`, or + * a manual `multipart/form-data` (a `FormData` always needs a freshly + * generated boundary, so any manual one is replaced) + * - the `Content-Type` is a `multipart/form-data` without a usable `boundary`, + * which is incomplete and unusable as-is + * + * Any other `Content-Type` is preserved: a per-request header, a non-JSON + * client-level default (e.g. `application/octet-stream` applied via + * `defaultHeaders`), a typeless `Blob`'s MIME type, or a pre-encoded multipart + * body that already carries its own `boundary`. + */ +export function resolveRequestHeaders( + callerHeaders: ClientHeaders | undefined, + defaultHeaders: ClientHeaders, + body: unknown, +): ClientHeaders { + const mergedHeaders = defu(callerHeaders, defaultHeaders); + + // The caller's Content-Type wins over the default, regardless of header + // casing (`defu` merges case-sensitively, so both could otherwise survive). + const callerContentType = callerHeaders && findContentType(callerHeaders); + const contentType = callerContentType || findContentType(mergedHeaders); + if (!contentType) return mergedHeaders; + + const normalized = contentType.toLowerCase(); + const isMultipart = normalized.includes("multipart/form-data"); + // A boundary needs a non-empty value; `boundary=` (or `boundary=;`) is malformed. + const hasBoundary = /boundary=[^\s;]/.test(normalized); + const runtimeManaged = isRuntimeManagedBody(body); + // The only Content-Type the client injects on its own is `application/json`. + // Anything else - per-request or a client-level default - was set on purpose. + const isSeededDefault = + !callerContentType && normalized === "application/json"; + + const shouldDrop = + // a multipart/form-data without a usable boundary is incomplete + (isMultipart && !hasBoundary) || + // a runtime-managed body types itself: drop the seeded application/json and + // any manual multipart (its boundary is stale), but keep an explicit + // non-JSON type (e.g. a Blob's image/png or an octet-stream default) + (runtimeManaged && (isMultipart || isSeededDefault)); + + if (shouldDrop) return withoutContentType(mergedHeaders); + + // Nothing to drop: leave the merged headers untouched unless the caller's + // Content-Type collided with the default under a different casing, leaving + // two Content-Type keys. In that case keep a single, canonical one. + if (contentTypeKeyCount(mergedHeaders) < 2) return mergedHeaders; + return { ...withoutContentType(mergedHeaders), "Content-Type": contentType }; +} diff --git a/templates/vue-starter-template-extended/package.json b/templates/vue-starter-template-extended/package.json index 139f88288..d62d2cbce 100644 --- a/templates/vue-starter-template-extended/package.json +++ b/templates/vue-starter-template-extended/package.json @@ -18,7 +18,7 @@ "preview": "nuxt preview", "lint": "oxlint . && oxfmt --check .", "lint:fix": "oxlint --fix . && oxfmt --write . && pnpm run typecheck", - "typecheck": "nuxt prepare && vue-tsc -b --noEmit", + "typecheck": "nuxt prepare && nuxi typecheck", "generate-types": "shopware-api-gen generate --apiType=store" }, "dependencies": { diff --git a/templates/vue-starter-template/nuxt.config.ts b/templates/vue-starter-template/nuxt.config.ts index 8928bd3e7..b638179d0 100644 --- a/templates/vue-starter-template/nuxt.config.ts +++ b/templates/vue-starter-template/nuxt.config.ts @@ -56,7 +56,7 @@ export default defineNuxtConfig({ defaultLocale: "en-GB", detectBrowserLanguage: false, langDir: "./src/langs/", - vueI18n: resolve("./config"), + vueI18n: "./config", locales: [ { code: "en-GB", diff --git a/templates/vue-starter-template/package.json b/templates/vue-starter-template/package.json index 346ae48d9..66056093b 100644 --- a/templates/vue-starter-template/package.json +++ b/templates/vue-starter-template/package.json @@ -18,7 +18,7 @@ "preview": "nuxt preview", "lint": "oxlint . && oxfmt --check .", "lint:fix": "oxlint --fix . && oxfmt --write . && pnpm run typecheck", - "typecheck": "nuxt prepare && vue-tsc -b --noEmit", + "typecheck": "nuxt prepare && nuxi typecheck", "generate-types": "shopware-api-gen generate --apiType=store" }, "dependencies": {