Skip to content

Commit

Permalink
Add support for FormData request body (#1835)
Browse files Browse the repository at this point in the history
Co-authored-by: Szymon Marczak <36894700+szmarczak@users.noreply.github.com>
Co-authored-by: Sindre Sorhus <sindresorhus@gmail.com>
  • Loading branch information
3 people committed Aug 21, 2021
1 parent 108c4ef commit 0fb6ec6
Show file tree
Hide file tree
Showing 6 changed files with 126 additions and 6 deletions.
20 changes: 19 additions & 1 deletion documentation/2-options.md
Expand Up @@ -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.

Expand All @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions package.json
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
16 changes: 15 additions & 1 deletion source/core/index.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -129,7 +130,7 @@ const proxiedRequestEvents = [
'upgrade',
] as const;

const noop = () => {};
const noop = (): void => {};

type UrlType = ConstructorParameters<typeof Options>[0];
type OptionsType = ConstructorParameters<typeof Options>[1];
Expand Down Expand Up @@ -572,6 +573,19 @@ export default class Request extends Duplex implements RequestEvents<Request> {
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()}`;
Expand Down
10 changes: 6 additions & 4 deletions source/core/options.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
38 changes: 38 additions & 0 deletions test/headers.ts
Expand Up @@ -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';

Expand Down Expand Up @@ -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);

Expand Down
45 changes: 45 additions & 0 deletions test/post.ts
Expand Up @@ -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';
Expand All @@ -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<Promise<[BodyEntryPath, BodyEntryRawValue]>>(
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);

Expand Down Expand Up @@ -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<typeof expected>();
t.deepEqual(body, expected);
});

test('throws on upload error', withServer, async (t, server, got) => {
server.post('/', defaultEndpoint);

Expand Down

0 comments on commit 0fb6ec6

Please sign in to comment.