Skip to content

Commit

Permalink
feat: support complex multipart requests
Browse files Browse the repository at this point in the history
- complex data structures
- multiple files under same name

fix #233
ref #472
  • Loading branch information
Xiphe committed Sep 12, 2023
1 parent 591323b commit 928de25
Show file tree
Hide file tree
Showing 2 changed files with 101 additions and 14 deletions.
80 changes: 69 additions & 11 deletions demo/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,22 +335,80 @@ describe("--optimistic", () => {
});
});

describe("upload files", () => {
describe("multipart", () => {
it("is able to upload multiple files along with complex data", async () => {
const res = await api.uploadFiles(5, {
files: [emptyPng, emptyPng],
imageMeta: [
{
name: "foto1.png",
},
{
name: "foto2.png",
description: "Not much to see here",
/* Test was flaky when hitting the mock server, so we're mocking the fetch */
const customFetch = jest.fn((url, init) => {
return {
headers: new Headers({
"content-type": "application/json",
}),
status: 200,
ok: true,
text() {
return JSON.stringify({});
},
],
};
});

const res = await api.uploadFiles(
5,
{
files: [
emptyPng,
emptyPng,
emptyPng,
emptyPng,
emptyPng,
emptyPng,
emptyPng,
emptyPng,
emptyPng,
emptyPng,
],
imageMeta: [
{
name: "foto1.png",
},
{
name: "foto2.png",
description: "Not much to see here",
},
],
},
{
fetch: customFetch as any,
},
);

expect(res.status).toBe(200);

expect(customFetch).toHaveBeenCalledWith(
"http://localhost:8000/v2/pet/5/uploadImage",
expect.objectContaining({
body: expect.any(FormData),
headers: {
Accept: "application/json",
"Content-Type": "multipart/form-data",
},
method: "POST",
}),
);
const formData = customFetch.mock.calls[0][1].body as FormData;
expect(formData.getAll("files").length).toBe(10);
const meta = formData.getAll("imageMeta") as Blob[];
expect(meta.length).toEqual(2);
expect(meta[0]).toEqual(expect.any(Blob));
expect(meta[0].type).toBe("application/json");
expect(JSON.parse(await meta[0].text())).toEqual({
name: "foto1.png",
});
expect(meta[1]).toEqual(expect.any(Blob));
expect(meta[1].type).toBe("application/json");
expect(JSON.parse(await meta[1].text())).toEqual({
name: "foto2.png",
description: "Not much to see here",
});
});
});

Expand Down
35 changes: 32 additions & 3 deletions src/runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export type ApiResponse = { status: number; data?: any };
export type WithHeaders<T extends ApiResponse> = T & { headers: Headers };

type MultipartRequestOpts = RequestOpts & {
body?: Record<string, string | Blob | undefined | any>;
body?: Record<string, unknown>;
};

export function runtime(defaults: RequestOpts) {
Expand Down Expand Up @@ -114,6 +114,12 @@ export function runtime(defaults: RequestOpts) {
: "application/x-www-form-urlencoded";
}

function ensureMultipartContentType(contentTypeHeader: string) {
return contentTypeHeader?.startsWith("multipart/form-data")
? contentTypeHeader
: "multipart/form-data";
}

return {
ok,
fetchText,
Expand Down Expand Up @@ -146,17 +152,40 @@ export function runtime(defaults: RequestOpts) {
};
},

multipart({ body, ...req }: MultipartRequestOpts) {
multipart({ body, headers, ...req }: MultipartRequestOpts) {
if (body == null) return req;
const data = new (defaults.formDataConstructor ||
req.formDataConstructor ||
FormData)();

const append = (name: string, value: unknown) => {
if (typeof value === "string" || value instanceof Blob) {
data.append(name, value);
} else {
data.append(
name,
new Blob([JSON.stringify(value)], { type: "application/json" }),
);
}
};

Object.entries(body).forEach(([name, value]) => {
data.append(name, value);
if (Array.isArray(value)) {
value.forEach((v) => append(name, v));
} else {
append(name, value);
}
});

return {
...req,
body: data,
headers: {
...headers,
"Content-Type": ensureMultipartContentType(
String(headers?.["Content-Type"]),
),
},
};
},
};
Expand Down

0 comments on commit 928de25

Please sign in to comment.