Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Multipart to FormData #2

Merged
merged 4 commits into from
Aug 4, 2024
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
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