diff --git a/src/types.ts b/src/types.ts index fc8decb30..9dddde58e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -181,11 +181,32 @@ export interface CompletedBody { getJson(): Promise; /** - * 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 | undefined>; } // Internal & external representation of an initiated (no body yet received) HTTP request. diff --git a/src/util/request-utils.ts b/src/util/request-utils.ts index ceff54d3e..63513e039 100644 --- a/src/util/request-utils.ts +++ b/src/util/request-utils.ts @@ -236,43 +236,61 @@ export const buildBodyReader = (body: Buffer, headers: Headers): CompletedBody = JSON.parse((await completedBody.getText())!) ) }, - async getFormData(): Promise { + 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 { + 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(); } }); } diff --git a/test/integration/form-data.spec.ts b/test/integration/form-data.spec.ts index 000e55860..41c40b521 100644 --- a/test/integration/form-data.spec.ts +++ b/test/integration/form-data.spec.ts @@ -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"), { @@ -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(); @@ -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); + }); }); }); \ No newline at end of file