diff --git a/documentation/2-options.md b/documentation/2-options.md index 0ac3faacb..58a769293 100644 --- a/documentation/2-options.md +++ b/documentation/2-options.md @@ -271,7 +271,7 @@ stream.on('data', console.log); ### `body` -**Type: `string | Buffer | stream.Readable | Generator | AsyncGenerator` or [`form-data` instance](https://github.com/form-data/form-data)** +**Type: `string | Buffer | stream.Readable | Generator | AsyncGenerator | FormData` or [`form-data` instance](https://github.com/form-data/form-data)** The payload to send. @@ -290,6 +290,24 @@ console.log(data); //=> 'Hello, world!' ``` +Since Got 12, you can use spec-compliant [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) objects as request body, such as [`formdata-node`](https://github.com/octet-stream/form-data) or [`formdata-polyfill`](https://github.com/jimmywarting/FormData): + +```js +import got from 'got'; +import {FormData} from 'formdata-node'; // or: +// import {FormData} from 'formdata-polyfill/esm.min.js'; + +const form = new FormData(); +form.set('greeting', 'Hello, world!'); + +const data = await got.post('https://httpbin.org/post', { + body: form +}).json(); + +console.log(data.form.greeting); +//=> 'Hello, world!' +``` + #### **Note:** > - If `body` is specified, then the `json` or `form` option cannot be used. diff --git a/package.json b/package.json index 61d0b5779..2bb86eded 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "cacheable-lookup": "^6.0.1", "cacheable-request": "^7.0.2", "decompress-response": "^6.0.0", + "form-data-encoder": "^1.4.3", "get-stream": "^6.0.1", "http2-wrapper": "^2.1.3", "lowercase-keys": "^2.0.0", @@ -81,6 +82,7 @@ "delay": "^5.0.0", "express": "^4.17.1", "form-data": "^4.0.0", + "formdata-node": "^4.0.0", "nock": "^13.1.2", "node-fetch": "^2.6.1", "np": "^7.5.0", @@ -92,6 +94,7 @@ "request": "^2.88.2", "sinon": "^11.1.2", "slow-stream": "0.0.4", + "then-busboy": "^5.0.0", "tempy": "^2.0.0", "to-readable-stream": "^3.0.0", "tough-cookie": "^4.0.0", diff --git a/source/core/index.ts b/source/core/index.ts index b3eeee958..9caf9f948 100644 --- a/source/core/index.ts +++ b/source/core/index.ts @@ -10,6 +10,7 @@ import CacheableRequest from 'cacheable-request'; import decompressResponse from 'decompress-response'; import is from '@sindresorhus/is'; import {buffer as getBuffer} from 'get-stream'; +import {FormDataEncoder, isFormDataLike} from 'form-data-encoder'; import type {ClientRequestWithTimings, Timings, IncomingMessageWithTimings} from '@szmarczak/http-timer'; import type ResponseLike from 'responselike'; import getBodySize from './utils/get-body-size.js'; @@ -129,7 +130,7 @@ const proxiedRequestEvents = [ 'upgrade', ] as const; -const noop = () => {}; +const noop = (): void => {}; type UrlType = ConstructorParameters[0]; type OptionsType = ConstructorParameters[1]; @@ -572,6 +573,19 @@ export default class Request extends Duplex implements RequestEvents { const noContentType = !is.string(headers['content-type']); if (isBody) { + // Body is spec-compliant FormData + if (isFormDataLike(options.body)) { + const encoder = new FormDataEncoder(options.body); + + if (noContentType) { + headers['content-type'] = encoder.headers['Content-Type']; + } + + headers['content-length'] = encoder.headers['Content-Length']; + + options.body = encoder.encode(); + } + // Special case for https://github.com/form-data/form-data if (isFormData(options.body) && noContentType) { headers['content-type'] = `multipart/form-data; boundary=${options.body.getBoundary()}`; diff --git a/source/core/options.ts b/source/core/options.ts index 2895d3fa9..e21d79e01 100644 --- a/source/core/options.ts +++ b/source/core/options.ts @@ -22,6 +22,8 @@ import is, {assert} from '@sindresorhus/is'; import lowercaseKeys from 'lowercase-keys'; import CacheableLookup from 'cacheable-lookup'; import http2wrapper, {ClientHttp2Session} from 'http2-wrapper'; +import {isFormDataLike} from 'form-data-encoder'; +import type {FormDataLike} from 'form-data-encoder'; import type CacheableRequest from 'cacheable-request'; import type ResponseLike from 'responselike'; import type {IncomingMessageWithTimings} from '@szmarczak/http-timer'; @@ -1178,16 +1180,16 @@ export default class Options { __Note #4__: This option is not enumerable and will not be merged with the instance defaults. - The `content-length` header will be automatically set if `body` is a `string` / `Buffer` / [`form-data` instance](https://github.com/form-data/form-data), and `content-length` and `transfer-encoding` are not manually set in `options.headers`. + The `content-length` header will be automatically set if `body` is a `string` / `Buffer` / [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) / [`form-data` instance](https://github.com/form-data/form-data), and `content-length` and `transfer-encoding` are not manually set in `options.headers`. Since Got 12, the `content-length` is not automatically set when `body` is a `fs.createReadStream`. */ - get body(): string | Buffer | Readable | Generator | AsyncGenerator | undefined { + get body(): string | Buffer | Readable | Generator | AsyncGenerator | FormDataLike | undefined { return this._internals.body; } - set body(value: string | Buffer | Readable | Generator | AsyncGenerator | undefined) { - assert.any([is.string, is.buffer, is.nodeStream, is.generator, is.asyncGenerator, is.undefined], value); + set body(value: string | Buffer | Readable | Generator | AsyncGenerator | FormDataLike | undefined) { + assert.any([is.string, is.buffer, is.nodeStream, is.generator, is.asyncGenerator, isFormDataLike, is.undefined], value); if (is.nodeStream(value)) { assert.truthy(value.readable); diff --git a/test/headers.ts b/test/headers.ts index 47cf7e71c..41262fdcd 100644 --- a/test/headers.ts +++ b/test/headers.ts @@ -5,6 +5,8 @@ import path from 'path'; import test from 'ava'; import {Handler} from 'express'; import FormData from 'form-data'; +import {FormDataEncoder} from 'form-data-encoder'; +import {FormData as FormDataNode} from 'formdata-node'; import got, {Headers} from '../source/index.js'; import withServer from './helpers/with-server.js'; @@ -175,6 +177,42 @@ test('form-data sets `content-length` header', withServer, async (t, server, got t.is(headers['content-length'], '157'); }); +test('sets `content-type` header for spec-compliant FormData', withServer, async (t, server, got) => { + server.post('/', echoHeaders); + + const form = new FormDataNode(); + form.set('a', 'b'); + const {body} = await got.post({body: form}); + const headers = JSON.parse(body); + t.true((headers['content-type'] as string).startsWith('multipart/form-data')); +}); + +test('sets `content-length` header for spec-compliant FormData', withServer, async (t, server, got) => { + server.post('/', echoHeaders); + + const form = new FormDataNode(); + form.set('a', 'b'); + const encoder = new FormDataEncoder(form); + const {body} = await got.post({body: form}); + const headers = JSON.parse(body); + t.is(headers['content-length'], encoder.headers['Content-Length']); +}); + +test('manual `content-type` header should be allowed with spec-compliant FormData', withServer, async (t, server, got) => { + server.post('/', echoHeaders); + + const form = new FormDataNode(); + form.set('a', 'b'); + const {body} = await got.post({ + headers: { + 'content-type': 'custom', + }, + body: form, + }); + const headers = JSON.parse(body); + t.is(headers['content-type'], 'custom'); +}); + test('stream as `options.body` does not set `content-length` header', withServer, async (t, server, got) => { server.post('/', echoHeaders); diff --git a/test/post.ts b/test/post.ts index 19ca1dda9..164b61143 100644 --- a/test/post.ts +++ b/test/post.ts @@ -3,11 +3,15 @@ import {Buffer} from 'buffer'; import {promisify} from 'util'; import stream from 'stream'; import fs from 'fs'; +import fsPromises from 'fs/promises'; import path from 'path'; import test from 'ava'; import delay from 'delay'; import pEvent from 'p-event'; import {Handler} from 'express'; +import {parse, Body, BodyEntryPath, BodyEntryRawValue, isBodyFile} from 'then-busboy'; +import {FormData as FormDataNode, Blob, File} from 'formdata-node'; +import {fileFromPath} from 'formdata-node/file-from-path'; import getStream from 'get-stream'; import FormData from 'form-data'; import toReadableStream from 'to-readable-stream'; @@ -25,6 +29,17 @@ const echoHeaders: Handler = (request, response) => { response.end(JSON.stringify(request.headers)); }; +const echoMultipartBody: Handler = async (request, response) => { + const body = await parse(request); + const entries = await Promise.all( + [...body.entries()].map>( + async ([name, value]) => [name, isBodyFile(value) ? await value.text() : value], + ), + ); + + response.json(Body.json(entries)); +}; + test('GET cannot have body without the `allowGetBody` option', withServer, async (t, server, got) => { server.post('/', defaultEndpoint); @@ -316,6 +331,36 @@ test('body - file read stream, wait for `ready` event', withServer, async (t, se t.is(toSend, body); }); +test('body - sends spec-compliant FormData', withServer, async (t, server, got) => { + server.post('/', echoMultipartBody); + + const form = new FormDataNode(); + form.set('a', 'b'); + const body = await got.post({body: form}).json<{a: string}>(); + t.is(body.a, 'b'); +}); + +test('body - sends files with spec-compliant FormData', withServer, async (t, server, got) => { + server.post('/', echoMultipartBody); + + const fullPath = path.resolve('test/fixtures/ok'); + const blobContent = 'Blob content'; + const fileContent = 'File content'; + const anotherFileContent = await fsPromises.readFile(fullPath, 'utf-8'); + const expected = { + blob: blobContent, + file: fileContent, + anotherFile: anotherFileContent, + }; + + const form = new FormDataNode(); + form.set('blob', new Blob([blobContent])); + form.set('file', new File([fileContent], 'file.txt', {type: 'text/plain'})); + form.set('anotherFile', await fileFromPath(fullPath, {type: 'text/plain'})); + const body = await got.post({body: form}).json(); + t.deepEqual(body, expected); +}); + test('throws on upload error', withServer, async (t, server, got) => { server.post('/', defaultEndpoint);