Skip to content

Commit fa8c688

Browse files
committed
Ensure form posting works with files
1 parent d220f34 commit fa8c688

File tree

2 files changed

+113
-5
lines changed

2 files changed

+113
-5
lines changed

src/FetchClient.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1090,6 +1090,110 @@ Deno.test("can use per-domain rate limiting with auto-update from headers", asyn
10901090
assertEquals(slowApiOptions.maxRequests, 5); // Updated from headers
10911091
});
10921092

1093+
Deno.test("can post FormData multipart", async () => {
1094+
const controller = new AbortController();
1095+
const port = 48081;
1096+
1097+
const server = Deno.serve(
1098+
{ port, signal: controller.signal },
1099+
async (req) => {
1100+
if (req.method === "POST" && new URL(req.url).pathname === "/upload") {
1101+
try {
1102+
const contentType = req.headers.get("content-type") ?? "";
1103+
const isMultipart = contentType.startsWith("multipart/form-data;");
1104+
const form = await req.formData();
1105+
const responseJson: Record<string, unknown> = { isMultipart };
1106+
for (const key of form.keys()) {
1107+
const value = form.get(key);
1108+
if (value instanceof File) {
1109+
const arrayBuf = await value.arrayBuffer();
1110+
const bytes = new Uint8Array(arrayBuf);
1111+
const base64 = btoa(String.fromCharCode(...bytes));
1112+
responseJson[key] = {
1113+
name: value.name,
1114+
size: value.size,
1115+
type: value.type,
1116+
base64,
1117+
};
1118+
} else {
1119+
responseJson[key] = value;
1120+
}
1121+
}
1122+
1123+
return new Response(JSON.stringify(responseJson), {
1124+
status: 200,
1125+
headers: { "Content-Type": "application/json" },
1126+
});
1127+
} catch (err) {
1128+
return new Response(JSON.stringify({ error: String(err) }), {
1129+
status: 500,
1130+
headers: { "Content-Type": "application/json" },
1131+
});
1132+
}
1133+
}
1134+
return new Response(null, { status: 404 });
1135+
},
1136+
);
1137+
1138+
const client = new FetchClient();
1139+
const fd = new FormData();
1140+
fd.append("field1", "value1");
1141+
fd.append("count", "42");
1142+
// Binary content (PNG header bytes) to ensure we don't corrupt binary uploads
1143+
const binaryBytes = new Uint8Array([0x89, 0x50, 0x4E, 0x47]);
1144+
fd.append(
1145+
"file",
1146+
new File(["Hello Multipart"], "greeting.txt", { type: "text/plain" }),
1147+
);
1148+
fd.append(
1149+
"binary",
1150+
new File([binaryBytes], "image.png", { type: "application/octet-stream" }),
1151+
);
1152+
1153+
const res = await client.postJSON<Record<string, unknown>>(
1154+
`http://localhost:${port}/upload`,
1155+
fd,
1156+
{
1157+
expectedStatusCodes: [200],
1158+
},
1159+
);
1160+
1161+
controller.abort();
1162+
await server.finished;
1163+
1164+
assertEquals(res.status, 200);
1165+
assert(res.ok);
1166+
assert(res.data);
1167+
assertEquals(res.data.field1, "value1");
1168+
assertEquals(res.data.count, "42");
1169+
assert(res.data.isMultipart);
1170+
const fileInfo = res.data.file as {
1171+
name: string;
1172+
size: number;
1173+
type: string;
1174+
base64: string;
1175+
};
1176+
assertEquals(fileInfo.name, "greeting.txt");
1177+
assertEquals(fileInfo.type, "text/plain");
1178+
// "Hello Multipart" length check
1179+
assertEquals(fileInfo.size, "Hello Multipart".length);
1180+
const binaryInfo = res.data.binary as {
1181+
name: string;
1182+
size: number;
1183+
type: string;
1184+
base64: string;
1185+
};
1186+
assertEquals(binaryInfo.name, "image.png");
1187+
assertEquals(binaryInfo.type, "application/octet-stream");
1188+
assertEquals(binaryInfo.size, 4);
1189+
// 0x89 50 4E 47 -> base64 iVBORw== (first 4 bytes of PNG yield iVBORw0KGgo but with only 4 bytes shorter)
1190+
// Let's compute expected base64 for [0x89,0x50,0x4E,0x47]
1191+
const expectedBinaryBase64 = btoa(
1192+
String.fromCharCode(0x89, 0x50, 0x4E, 0x47),
1193+
);
1194+
assertEquals(binaryInfo.base64, expectedBinaryBase64);
1195+
});
1196+
10931197
function delay(time: number): Promise<void> {
10941198
return new Promise((resolve) => setTimeout(resolve, time));
10951199
}

src/FetchClient.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,10 @@ export class FetchClient {
381381
}
382382
}
383383

384-
if (init?.body && typeof init.body === "object") {
384+
if (
385+
init?.body && typeof init.body === "object" &&
386+
!(init.body instanceof FormData)
387+
) {
385388
init.body = JSON.stringify(init.body);
386389
}
387390

@@ -619,12 +622,13 @@ export class FetchClient {
619622
body: object | string | FormData | undefined,
620623
options: RequestOptions | undefined,
621624
): RequestInitWithObjectBody {
622-
const isDefinitelyJsonBody = body !== undefined &&
623-
body !== null &&
624-
typeof body === "object";
625+
const isFormData = typeof FormData !== "undefined" &&
626+
body instanceof FormData;
627+
const isJsonLikeObject = body !== undefined && body !== null &&
628+
typeof body === "object" && !isFormData;
625629

626630
const headers: Record<string, string> = {};
627-
if (isDefinitelyJsonBody) {
631+
if (isJsonLikeObject) {
628632
headers["Content-Type"] = "application/json";
629633
}
630634

0 commit comments

Comments
 (0)