Skip to content

Commit

Permalink
Add explicit getUrlEncodedFormData and getMultipartFormData methods
Browse files Browse the repository at this point in the history
These work in addition to the existing getFormData method, which
automatically detects between the two types and parses appropriately
into a single common (simplified, in the multipart case) format.
  • Loading branch information
pimterry committed Dec 6, 2023
1 parent cc788c7 commit f4f0da9
Show file tree
Hide file tree
Showing 3 changed files with 142 additions and 29 deletions.
27 changes: 24 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,11 +181,32 @@ export interface CompletedBody {
getJson(): Promise<object | undefined>;

/**
* The contents of the response, decoded, parsed as UTF-8 string, and
* then parsed form-encoded data. The response is decoded and returned
* asynchronously as a Promise.
* The contents of the response, decoded, and then parsed automatically as
* either one of the form encoding types (either URL-encoded or multipart),
* determined automatically from the message content-type header.
*
* This method is convenient and offers a single mechanism to parse both
* formats, but you may want to consider parsing on format explicitly with
* the `getUrlEncodedFormData()` or `getMultipartFormData()` methods instead.
*
* After parsing & decoding, the result is returned asynchronously as a
* Promise for a key-value(s) object.
*/
getFormData(): Promise<{ [key: string]: string | string[] | undefined } | undefined>;

/**
* The contents of the response, decoded, parsed as UTF-8 string, and then
* parsed as URL-encoded form data. After parsing & decoding, the result is
* returned asynchronously as a Promise for a key-value(s) object.
*/
getUrlEncodedFormData(): Promise<{ [key: string]: string | string[] | undefined } | undefined>;

/**
* The contents of the response, decoded, and then parsed as multi-part
* form data. The response is result is returned asynchronously as a
* Promise for an array of parts with their names, data and metadata.
*/
getMultipartFormData(): Promise<Array<{ name?: string, filename?: string, type?: string, data: Buffer }> | undefined>;
}

// Internal & external representation of an initiated (no body yet received) HTTP request.
Expand Down
60 changes: 39 additions & 21 deletions src/util/request-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,43 +236,61 @@ export const buildBodyReader = (body: Buffer, headers: Headers): CompletedBody =
JSON.parse((await completedBody.getText())!)
)
},
async getFormData(): Promise<querystring.ParsedUrlQuery | undefined> {
async getUrlEncodedFormData() {
return runAsyncOrUndefined(async () => {
const contentType = headers["content-type"];
if (contentType?.includes("multipart/form-data")) {
const boundary = contentType.match(/;\s*boundary=(\S+)/);
if (!boundary) {
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type#boundary
// `boundary` is required for multipart entities.
// So let's ignore content without boundary, assuming invalid multipart data
return undefined;
}
const multipartBodyBuffer = asBuffer(await decodeBodyBuffer(this.buffer, headers));
const parsedBody = multipart.parse(multipartBodyBuffer, boundary[1]);
if (contentType?.includes("multipart/form-data")) return; // Actively ignore multipart data - won't work as expected

const text = await completedBody.getText();
return text ? querystring.parse(text) : undefined;
});
},
async getMultipartFormData() {
return runAsyncOrUndefined(async () => {
const contentType = headers["content-type"];
if (!contentType?.includes("multipart/form-data")) return;

const boundary = contentType.match(/;\s*boundary=(\S+)/);

// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type#boundary
// `boundary` is required for multipart entities.
if (!boundary) return;

const multipartBodyBuffer = asBuffer(await decodeBodyBuffer(this.buffer, headers));
return multipart.parse(multipartBodyBuffer, boundary[1]);
});
},
async getFormData(): Promise<querystring.ParsedUrlQuery | undefined> {
return runAsyncOrUndefined(async () => {
// Return multi-part data if present, or fallback to default URL-encoded
// parsing for all other cases. Data is returned in the same format regardless.
const multiPartBody = await completedBody.getMultipartFormData();
if (multiPartBody) {
const formData: querystring.ParsedUrlQuery = {};
parsedBody.forEach((part) => {

multiPartBody.forEach((part) => {
const name = part.name;
if (name === undefined) {
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition#as_a_header_for_a_multipart_body,
// The header must include `name` property to identify the field name.
// So let's ignore parts without a name, assuming invalid multipart data.
// So we ignore parts without a name, treating it as invalid multipart form data.
} else {
// We do not use `input.filename` here because return value of `getFormData` must be string or string array.
// We do not use `filename` or `type` here, because return value of `getFormData` must be string or string array.

const prevValue = formData[name];
if (typeof prevValue === "undefined") {
if (prevValue === undefined) {
formData[name] = part.data.toString();
} else if (typeof prevValue === "string") {
formData[name] = [prevValue, part.data.toString()];
} else {
} else if (Array.isArray(prevValue)) {
prevValue.push(part.data.toString());
} else {
formData[name] = [prevValue, part.data.toString()];
}
}
})
});

return formData;
} else {
const text = await completedBody.getText();
return text ? querystring.parse(text) : undefined;
return completedBody.getUrlEncodedFormData();
}
});
}
Expand Down
84 changes: 79 additions & 5 deletions test/integration/form-data.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ import {

const fetch = globalThis.fetch ?? fetchPolyfill;

describe("FormData", () => {
describe("Body getXFormData methods", () => {
let server = getLocal();

beforeEach(() => server.start());
afterEach(() => server.stop());

describe("application/x-www-form-urlencoded", () => {
it("should parse application/x-www-form-urlencoded", async () => {
describe("given application/x-www-form-urlencoded data", () => {
it("should automatically parse form data", async () => {
const endpoint = await server.forPost("/mocked-endpoint").thenReply(200);

await fetch(server.urlFor("/mocked-endpoint"), {
Expand All @@ -32,15 +32,44 @@ describe("FormData", () => {
order: "desc",
});
});

it("should explicitly parse as url-encoded form data", async () => {
const endpoint = await server.forPost("/mocked-endpoint").thenReply(200);

await fetch(server.urlFor("/mocked-endpoint"), {
method: "POST",
body: "id=123&id=456&id=789&order=desc"
});

const requests = await endpoint.getSeenRequests();
expect(requests.length).to.equal(1);
expect(await requests[0].body.getUrlEncodedFormData()).to.deep.equal({
id: ["123", "456", "789"],
order: "desc",
});
});

it("should fail to explicitly parse as multipart form data", async () => {
const endpoint = await server.forPost("/mocked-endpoint").thenReply(200);

await fetch(server.urlFor("/mocked-endpoint"), {
method: "POST",
body: "id=123&id=456&id=789&order=desc"
});

const requests = await endpoint.getSeenRequests();
expect(requests.length).to.equal(1);
expect(await requests[0].body.getMultipartFormData()).to.equal(undefined);
});
});

describe("multipart/form-data", () => {
describe("given multipart/form-data", () => {
before(function () {
// Polyfill fetch encodes polyfill FormData into "[object FormData]", which is not parsable
if (!semver.satisfies(process.version, NATIVE_FETCH_SUPPORTED)) this.skip();
});

it("should parse multipart/form-data", async () => {
it("should automatically parse as form data", async () => {
const endpoint = await server.forPost("/mocked-endpoint").thenReply(200);

const formData = new FormData();
Expand All @@ -62,5 +91,50 @@ describe("FormData", () => {
readme: "file content",
});
});

it("should explicitly parse as multipart form data", async () => {
const endpoint = await server.forPost("/mocked-endpoint").thenReply(200);

const formData = new FormData();
formData.append("id", "123");
formData.append("id", "456");
formData.append("id", "789");
formData.append("order", "desc");
formData.append("readme", new File(["file content"], "file.txt", {type: "text/plain"}));
await fetch(server.urlFor("/mocked-endpoint"), {
method: "POST",
body: formData,
});

const requests = await endpoint.getSeenRequests();
expect(requests.length).to.equal(1);
expect(await requests[0].body.getMultipartFormData()).to.deep.equal([
{ name: 'id', data: Buffer.from('123') },
{ name: 'id', data: Buffer.from('456') },
{ name: 'id', data: Buffer.from('789') },
{ name: 'order', data: Buffer.from('desc') },
{ name: 'readme', data: Buffer.from('file content'), filename: 'file.txt', type: 'text/plain' },
]);
});


it("should fail to explicitly parse as url-encoded form data", async () => {
const endpoint = await server.forPost("/mocked-endpoint").thenReply(200);

const formData = new FormData();
formData.append("id", "123");
formData.append("id", "456");
formData.append("id", "789");
formData.append("order", "desc");
formData.append("readme", new File(["file content"], "file.txt", {type: "text/plain"}));
await fetch(server.urlFor("/mocked-endpoint"), {
method: "POST",
body: formData,
});

const requests = await endpoint.getSeenRequests();
expect(requests.length).to.equal(1);
expect(await requests[0].body.getUrlEncodedFormData()).to.equal(undefined);
});
});
});

0 comments on commit f4f0da9

Please sign in to comment.