Skip to content
Merged
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
71 changes: 71 additions & 0 deletions ui/src/lib/__tests__/api-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,77 @@ describe("apiFetch", () => {
spy.mockRestore();
});

it("does NOT default Content-Type for FormData (browser must set the multipart boundary)", async () => {
const spy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("{}", { status: 200, headers: { "content-type": "application/json" } }),
);
try {
const fd = new FormData();
fd.append("files", new Blob(["hello"], { type: "text/plain" }), "hello.txt");
await apiFetch("/api/upload", { method: "POST", body: fd });
const init = (spy.mock.calls[0][1] ?? {}) as RequestInit;
const hdrs = new Headers(init.headers);
expect(hdrs.has("Content-Type")).toBe(false);
// The exact same FormData instance must reach fetch — passing through
// the spread/clone path or being re-serialized would also break uploads.
expect(init.body).toBe(fd);
} finally {
spy.mockRestore();
}
});

it("does NOT default Content-Type for Blob, URLSearchParams, ArrayBuffer, or typed arrays", async () => {
const spy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new HttpResponse(null, { status: 204 }));
try {
const bodies: BodyInit[] = [
new Blob(["x"], { type: "application/octet-stream" }),
new URLSearchParams({ a: "1" }),
new ArrayBuffer(4),
new Uint8Array([1, 2, 3]),
];
for (const body of bodies) {
await apiFetch("/api/raw", { method: "POST", body });
}
for (let i = 0; i < bodies.length; i++) {
const init = (spy.mock.calls[i][1] ?? {}) as RequestInit;
const hdrs = new Headers(init.headers);
expect(hdrs.has("Content-Type")).toBe(false);
}
} finally {
spy.mockRestore();
}
});

it("defaults Content-Type to application/json for string bodies", async () => {
const spy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("{}", { status: 200, headers: { "content-type": "application/json" } }),
);
try {
await apiFetch("/api/json", { method: "POST", body: JSON.stringify({ x: 1 }) });
const hdrs = new Headers(((spy.mock.calls[0][1] ?? {}) as RequestInit).headers);
expect(hdrs.get("Content-Type")).toBe("application/json");
} finally {
spy.mockRestore();
}
});

it("respects a caller-provided Content-Type and never overrides it", async () => {
const spy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response("{}", { status: 200, headers: { "content-type": "application/json" } }),
);
try {
await apiFetch("/api/csv", {
method: "POST",
body: "a,b,c",
headers: { "Content-Type": "text/csv" },
});
const hdrs = new Headers(((spy.mock.calls[0][1] ?? {}) as RequestInit).headers);
expect(hdrs.get("Content-Type")).toBe("text/csv");
} finally {
spy.mockRestore();
}
});

it("does not set Authorization header on data-path fetch even when a key exists in a meta tag", async () => {
const meta = document.createElement("meta");
meta.setAttribute("name", "docsiq-api-key");
Expand Down
16 changes: 15 additions & 1 deletion ui/src/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,27 @@ export class ApiErrorResponse extends Error {
}
}

// FormData/Blob/URLSearchParams/streams/buffers carry their own framing —
// the browser sets Content-Type (with the multipart boundary, etc.) when
// fetch builds the request. Defaulting to application/json here would clobber
// that boundary and produce an unparseable body.
function isBrowserManagedBody(body: BodyInit): boolean {
if (typeof FormData !== "undefined" && body instanceof FormData) return true;
if (typeof Blob !== "undefined" && body instanceof Blob) return true;
if (typeof URLSearchParams !== "undefined" && body instanceof URLSearchParams) return true;
if (typeof ReadableStream !== "undefined" && body instanceof ReadableStream) return true;
if (body instanceof ArrayBuffer) return true;
if (ArrayBuffer.isView(body)) return true;
return false;
}

export async function apiFetch<T>(
path: string,
init: RequestInit = {},
): Promise<T> {
if (sessionReady) await sessionReady;
const headers = new Headers(init.headers);
if (init.body && !headers.has("Content-Type")) {
if (init.body && !headers.has("Content-Type") && !isBrowserManagedBody(init.body)) {
headers.set("Content-Type", "application/json");
}
const res = await fetch(path, { ...init, headers, credentials: "include" });
Expand Down
Loading