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": {