Skip to content

Commit 80b7907

Browse files
authored
Multipart to FormData (#2)
2 parents 888538e + 586053e commit 80b7907

File tree

3 files changed

+147
-16
lines changed

3 files changed

+147
-16
lines changed

src/Multipart.ts

Lines changed: 116 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,8 @@ export class Multipart implements Part {
184184
const parts: Component[] = [];
185185

186186
for (const [key, value] of formData.entries()) {
187-
if (typeof value === "string") parts.push(new Component({"Content-Disposition": `form-data; name="${key}"`}, new TextEncoder().encode(value))); else {
187+
if (typeof value === "string") parts.push(new Component({"Content-Disposition": `form-data; name="${key}"`}, new TextEncoder().encode(value)));
188+
else {
188189
const part = await Component.file(value);
189190
part.headers.set("Content-Disposition", `form-data; name="${key}"; filename="${value.name}"`);
190191
parts.push(part);
@@ -213,26 +214,128 @@ export class Multipart implements Part {
213214
return -1;
214215
}
215216

217+
/**
218+
* Parse header params in the format `key=value;foo = "bar"; baz`
219+
*/
220+
private static parseHeaderParams(input: string): Map<string, string> {
221+
const params = new Map();
222+
let currentKey = "";
223+
let currentValue = "";
224+
let insideQuotes = false;
225+
let escaping = false;
226+
let readingKey = true;
227+
let valueHasBegun = false;
228+
229+
for (const char of input) {
230+
if (escaping) {
231+
currentValue += char;
232+
escaping = false;
233+
continue;
234+
}
235+
236+
if (char === "\\") {
237+
if (!readingKey) escaping = true;
238+
continue;
239+
}
240+
241+
if (char === '"') {
242+
if (!readingKey) {
243+
if (valueHasBegun && !insideQuotes) currentValue += char;
244+
else {
245+
insideQuotes = !insideQuotes;
246+
valueHasBegun = true;
247+
}
248+
}
249+
else currentKey += char;
250+
continue;
251+
}
252+
253+
if (char === ";" && !insideQuotes) {
254+
currentKey = currentKey.trim();
255+
if (currentKey.length > 0) {
256+
if (readingKey)
257+
params.set(currentKey, "");
258+
params.set(currentKey, currentValue);
259+
}
260+
261+
currentKey = "";
262+
currentValue = "";
263+
readingKey = true;
264+
valueHasBegun = false;
265+
insideQuotes = false;
266+
continue;
267+
}
268+
269+
if (char === "=" && readingKey && !insideQuotes) {
270+
readingKey = false;
271+
continue;
272+
}
273+
274+
if (char === " " && !readingKey && !insideQuotes && !valueHasBegun)
275+
continue;
276+
277+
if (readingKey) currentKey += char;
278+
else {
279+
valueHasBegun = true;
280+
currentValue += char;
281+
}
282+
}
283+
284+
currentKey = currentKey.trim();
285+
if (currentKey.length > 0) {
286+
if (readingKey)
287+
params.set(currentKey, "");
288+
params.set(currentKey, currentValue);
289+
}
290+
291+
return params;
292+
}
293+
216294
/**
217295
* Extract media type and boundary from a `Content-Type` header
218296
*/
219297
private static parseContentType(contentType: string): { mediaType: string | null, boundary: string | null } {
220-
const parts = contentType.split(";");
298+
const firstSemicolonIndex = contentType.indexOf(";");
221299

222-
if (parts.length === 0) return {mediaType: null, boundary: null};
223-
const mediaType = parts[0]!.trim();
300+
if (firstSemicolonIndex === -1) return {mediaType: contentType, boundary: null};
301+
const mediaType = contentType.slice(0, firstSemicolonIndex);
302+
const params = Multipart.parseHeaderParams(contentType.slice(firstSemicolonIndex + 1));
303+
return {mediaType, boundary: params.get("boundary") ?? null};
304+
}
224305

225-
let boundary = null;
306+
/**
307+
* Extract name, filename and whether form-data from a `Content-Disposition` header
308+
*/
309+
private static parseContentDisposition(contentDisposition: string): {
310+
formData: boolean,
311+
name: string | null,
312+
filename: string | null,
313+
} {
314+
const params = Multipart.parseHeaderParams(contentDisposition);
315+
return {
316+
formData: params.has("form-data"),
317+
name: params.get("name") ?? null,
318+
filename: params.get("filename") ?? null,
319+
};
320+
}
226321

227-
for (const param of parts.slice(1)) {
228-
const equalsIndex = param.indexOf("=");
229-
if (equalsIndex === -1) continue;
230-
const key = param.slice(0, equalsIndex).trim();
231-
const value = param.slice(equalsIndex + 1).trim();
232-
if (key === "boundary" && value.length > 0) boundary = value;
322+
/**
323+
* Create FormData from this multipart.
324+
* Only parts that have `Content-Disposition` set to `form-data` and a non-empty `name` will be included.
325+
*/
326+
public formData(): FormData {
327+
const formData = new FormData();
328+
for (const part of this.parts) {
329+
if (!part.headers.has("Content-Disposition")) continue;
330+
const params = Multipart.parseContentDisposition(part.headers.get("Content-Disposition")!);
331+
if (!params.formData || params.name === null) continue;
332+
if (params.filename !== null) {
333+
const file: File = new File([part.body], params.filename, {type: part.headers.get("Content-Type") ?? void 0});
334+
formData.append(params.name, file);
335+
}
336+
else formData.append(params.name, new TextDecoder().decode(part.body));
233337
}
234-
235-
return {mediaType, boundary};
338+
return formData;
236339
}
237340

238341
/**

test/Component.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ describe("Component", () => {
6767
});
6868
});
6969

70-
describe("bytes", () => {
70+
describe("#bytes", () => {
7171
it("should return the bytes of a Component with headers and body", () => {
7272
const headersInit = {"Content-Type": "text/plain", "Content-Length": "3"};
7373
const body = new Uint8Array([1, 2, 3]);

test/Multipart.test.js

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,35 @@ describe("Multipart", function () {
126126
});
127127
});
128128

129-
describe("body", function () {
129+
describe("#formData", function () {
130+
it ("should correctly return the FormData of the Multipart", async function () {
131+
const formData = new FormData();
132+
formData.append("foo", "bar");
133+
formData.append("bar", "baz");
134+
formData.append("file", new Blob(["console.log('hello world');"], {type: "application/javascript"}), "hello.js");
135+
136+
const multipart = await Multipart.formData(formData);
137+
const parsedFormData = multipart.formData();
138+
139+
expect(parsedFormData).to.be.an.instanceof(FormData);
140+
expect(parsedFormData.get("foo")).to.equal("bar");
141+
expect(parsedFormData.get("bar")).to.equal("baz");
142+
const file = parsedFormData.get("file");
143+
expect(file).to.be.an.instanceof(File);
144+
expect(file.name).to.equal("hello.js");
145+
expect(file.type).to.equal("application/javascript");
146+
expect(new TextDecoder().decode(await file.arrayBuffer())).to.equal("console.log('hello world');");
147+
});
148+
149+
it("should handle empty FormData multipart", async function (){
150+
const multipart = await Multipart.formData(new FormData());
151+
const formData = multipart.formData();
152+
expect(formData).to.be.an.instanceof(FormData);
153+
expect(Object.keys(Object.fromEntries(formData.entries())).length).to.equal(0);
154+
});
155+
});
156+
157+
describe("#body", function () {
130158
it("should correctly return the body of the Multipart", function () {
131159
const boundary = "test-boundary";
132160
const component = new Component({ "content-type": "text/plain" }, new TextEncoder().encode("test body"));
@@ -164,7 +192,7 @@ describe("Multipart", function () {
164192
});
165193
});
166194

167-
describe("bytes", function () {
195+
describe("#bytes", function () {
168196
it("should correctly return the bytes of the Multipart", function () {
169197
const boundary = "test-boundary";
170198
const component = new Component({ "x-foo": "bar" }, new TextEncoder().encode("test content"));

0 commit comments

Comments
 (0)