Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/solid-flies-agree.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 31 additions & 0 deletions packages/api-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <input type="file">
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 <BodyType>` 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.
Expand Down
17 changes: 6 additions & 11 deletions packages/api-client/src/createAPIClient.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { defu } from "defu";
import { createHooks } from "hookable";
import {
type FetchContext,
Expand All @@ -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, K extends string | number | symbol> = T extends unknown
Expand Down Expand Up @@ -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<CURRENT_OPERATION, "response">
Expand Down
15 changes: 12 additions & 3 deletions packages/api-client/src/createAdminAPIClient.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { defu } from "defu";
import { createHooks } from "hookable";
import {
type FetchOptions,
Expand All @@ -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, K extends string | number | symbol> = T extends unknown
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -239,13 +242,19 @@ export function createAdminAPIClient<
...(currentParams.fetchOptions || {}),
};

const mergedHeaders = resolveRequestHeaders(
currentParams.headers,
defaultHeaders,
currentParams.body,
);

const resp = await apiFetch.raw<
SimpleUnionPick<CURRENT_OPERATION, "response">
>(requestPathWithParams, {
...fetchOptions,
method,
body: currentParams.body,
headers: defu(currentParams.headers, defaultHeaders) as HeadersInit,
headers: mergedHeaders as HeadersInit,
query: currentParams.query,
});

Expand Down
42 changes: 42 additions & 0 deletions packages/api-client/src/createAdminApiClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<operations>({
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=/,
);
});
});
28 changes: 16 additions & 12 deletions packages/api-client/src/createApiClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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);
Expand All @@ -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 () => {
Expand Down Expand Up @@ -443,8 +444,11 @@ describe("createAPIClient", () => {

controller.abort();

await expect(request).rejects.toThrowErrorMatchingInlineSnapshot(
`[FetchError: [GET] "${baseURL}context": <no response> 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": <no response>.*aborted`, "i"),
);
Comment on lines +450 to 452
});

Expand Down
166 changes: 166 additions & 0 deletions packages/api-client/src/resolveRequestHeaders.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Comment thread
mkucmus marked this conversation as resolved.
Loading