Skip to content

Commit

Permalink
Multipart to FormData (#2)
Browse files Browse the repository at this point in the history
  • Loading branch information
zefir-git authored Aug 4, 2024
2 parents 888538e + 586053e commit 80b7907
Show file tree
Hide file tree
Showing 3 changed files with 147 additions and 16 deletions.
129 changes: 116 additions & 13 deletions src/Multipart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,8 @@ export class Multipart implements Part {
const parts: Component[] = [];

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

/**
* Parse header params in the format `key=value;foo = "bar"; baz`
*/
private static parseHeaderParams(input: string): Map<string, string> {
const params = new Map();
let currentKey = "";
let currentValue = "";
let insideQuotes = false;
let escaping = false;
let readingKey = true;
let valueHasBegun = false;

for (const char of input) {
if (escaping) {
currentValue += char;
escaping = false;
continue;
}

if (char === "\\") {
if (!readingKey) escaping = true;
continue;
}

if (char === '"') {
if (!readingKey) {
if (valueHasBegun && !insideQuotes) currentValue += char;
else {
insideQuotes = !insideQuotes;
valueHasBegun = true;
}
}
else currentKey += char;
continue;
}

if (char === ";" && !insideQuotes) {
currentKey = currentKey.trim();
if (currentKey.length > 0) {
if (readingKey)
params.set(currentKey, "");
params.set(currentKey, currentValue);
}

currentKey = "";
currentValue = "";
readingKey = true;
valueHasBegun = false;
insideQuotes = false;
continue;
}

if (char === "=" && readingKey && !insideQuotes) {
readingKey = false;
continue;
}

if (char === " " && !readingKey && !insideQuotes && !valueHasBegun)
continue;

if (readingKey) currentKey += char;
else {
valueHasBegun = true;
currentValue += char;
}
}

currentKey = currentKey.trim();
if (currentKey.length > 0) {
if (readingKey)
params.set(currentKey, "");
params.set(currentKey, currentValue);
}

return params;
}

/**
* Extract media type and boundary from a `Content-Type` header
*/
private static parseContentType(contentType: string): { mediaType: string | null, boundary: string | null } {
const parts = contentType.split(";");
const firstSemicolonIndex = contentType.indexOf(";");

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

let boundary = null;
/**
* Extract name, filename and whether form-data from a `Content-Disposition` header
*/
private static parseContentDisposition(contentDisposition: string): {
formData: boolean,
name: string | null,
filename: string | null,
} {
const params = Multipart.parseHeaderParams(contentDisposition);
return {
formData: params.has("form-data"),
name: params.get("name") ?? null,
filename: params.get("filename") ?? null,
};
}

for (const param of parts.slice(1)) {
const equalsIndex = param.indexOf("=");
if (equalsIndex === -1) continue;
const key = param.slice(0, equalsIndex).trim();
const value = param.slice(equalsIndex + 1).trim();
if (key === "boundary" && value.length > 0) boundary = value;
/**
* Create FormData from this multipart.
* Only parts that have `Content-Disposition` set to `form-data` and a non-empty `name` will be included.
*/
public formData(): FormData {
const formData = new FormData();
for (const part of this.parts) {
if (!part.headers.has("Content-Disposition")) continue;
const params = Multipart.parseContentDisposition(part.headers.get("Content-Disposition")!);
if (!params.formData || params.name === null) continue;
if (params.filename !== null) {
const file: File = new File([part.body], params.filename, {type: part.headers.get("Content-Type") ?? void 0});
formData.append(params.name, file);
}
else formData.append(params.name, new TextDecoder().decode(part.body));
}

return {mediaType, boundary};
return formData;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion test/Component.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ describe("Component", () => {
});
});

describe("bytes", () => {
describe("#bytes", () => {
it("should return the bytes of a Component with headers and body", () => {
const headersInit = {"Content-Type": "text/plain", "Content-Length": "3"};
const body = new Uint8Array([1, 2, 3]);
Expand Down
32 changes: 30 additions & 2 deletions test/Multipart.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,35 @@ describe("Multipart", function () {
});
});

describe("body", function () {
describe("#formData", function () {
it ("should correctly return the FormData of the Multipart", async function () {
const formData = new FormData();
formData.append("foo", "bar");
formData.append("bar", "baz");
formData.append("file", new Blob(["console.log('hello world');"], {type: "application/javascript"}), "hello.js");

const multipart = await Multipart.formData(formData);
const parsedFormData = multipart.formData();

expect(parsedFormData).to.be.an.instanceof(FormData);
expect(parsedFormData.get("foo")).to.equal("bar");
expect(parsedFormData.get("bar")).to.equal("baz");
const file = parsedFormData.get("file");
expect(file).to.be.an.instanceof(File);
expect(file.name).to.equal("hello.js");
expect(file.type).to.equal("application/javascript");
expect(new TextDecoder().decode(await file.arrayBuffer())).to.equal("console.log('hello world');");
});

it("should handle empty FormData multipart", async function (){
const multipart = await Multipart.formData(new FormData());
const formData = multipart.formData();
expect(formData).to.be.an.instanceof(FormData);
expect(Object.keys(Object.fromEntries(formData.entries())).length).to.equal(0);
});
});

describe("#body", function () {
it("should correctly return the body of the Multipart", function () {
const boundary = "test-boundary";
const component = new Component({ "content-type": "text/plain" }, new TextEncoder().encode("test body"));
Expand Down Expand Up @@ -164,7 +192,7 @@ describe("Multipart", function () {
});
});

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

0 comments on commit 80b7907

Please sign in to comment.