diff --git a/package-lock.json b/package-lock.json index 01d138d..0a9c491 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,10 +26,12 @@ "abort-controller": "^3.0.0", "apollo-datasource-rest": "^3.5.1", "ava": "^3.15.0", + "form-data": "^4.0.0", "graphql": "^16.3.0", "h2url": "^0.2.0", "nock": "^13.2.4", "nyc": "^15.1.0", + "parse-multipart-data": "^1.4.0", "prettier": "^2.5.1", "release-it": "^14.12.4", "ts-node": "^10.5.0", @@ -4653,6 +4655,12 @@ "node": ">=6" } }, + "node_modules/parse-multipart-data": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/parse-multipart-data/-/parse-multipart-data-1.4.0.tgz", + "integrity": "sha512-CoF9FXHi6ICz/VFPjunENhVtT8yKi9FtkkmDqCbjyJ2R2GSYZys56e+DiueFFdncWyvttQz1yUxVcXiRvGEPiA==", + "dev": true + }, "node_modules/parse-path": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/parse-path/-/parse-path-4.0.3.tgz", @@ -10018,6 +10026,12 @@ "integrity": "sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==", "dev": true }, + "parse-multipart-data": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/parse-multipart-data/-/parse-multipart-data-1.4.0.tgz", + "integrity": "sha512-CoF9FXHi6ICz/VFPjunENhVtT8yKi9FtkkmDqCbjyJ2R2GSYZys56e+DiueFFdncWyvttQz1yUxVcXiRvGEPiA==", + "dev": true + }, "parse-path": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/parse-path/-/parse-path-4.0.3.tgz", diff --git a/package.json b/package.json index 176d65f..264d22f 100644 --- a/package.json +++ b/package.json @@ -69,10 +69,12 @@ "abort-controller": "^3.0.0", "apollo-datasource-rest": "^3.5.1", "ava": "^3.15.0", + "form-data": "^4.0.0", "graphql": "^16.3.0", "h2url": "^0.2.0", "nock": "^13.2.4", "nyc": "^15.1.0", + "parse-multipart-data": "^1.4.0", "prettier": "^2.5.1", "release-it": "^14.12.4", "ts-node": "^10.5.0", diff --git a/src/http-data-source.ts b/src/http-data-source.ts index be3ab2d..c2d9212 100644 --- a/src/http-data-source.ts +++ b/src/http-data-source.ts @@ -46,6 +46,9 @@ export type Request = { query: Dictionary body: T signal?: AbortSignal | EventEmitter | null + /** + * Skips JSON.stringify coersion of Request.body + */ json?: boolean origin: string path: string @@ -299,8 +302,10 @@ export abstract class HTTPDataSource extends DataSource { cacheKey: string, ): Promise> { try { - // in case of JSON set appropriate content-type header - if (request.body !== null && typeof request.body === 'object') { + if (request.json === false) { + // skip coercing to json + } else if (request.body !== null && typeof request.body === 'object') { + // in case of JSON set appropriate content-type header if (request.headers['content-type'] === undefined) { request.headers['content-type'] = 'application/json; charset=utf-8' } diff --git a/test/http-data-source.test.ts b/test/http-data-source.test.ts index f820fcf..f56f4d7 100644 --- a/test/http-data-source.test.ts +++ b/test/http-data-source.test.ts @@ -1,8 +1,10 @@ import anyTest, { TestInterface } from 'ava' import http from 'http' import { createGzip, createDeflate, createBrotliCompress } from 'zlib' -import { Readable } from 'stream'; +import { Readable } from 'stream' import { setGlobalDispatcher, Agent, Pool } from 'undici' +import FormData from 'form-data' +import { parse } from 'parse-multipart-data' import AbortController from 'abort-controller' import querystring from 'querystring' import { HTTPDataSource, Request, Response, RequestError } from '../src' @@ -147,6 +149,71 @@ test('Should be able to make a simple POST with JSON body', async (t) => { t.deepEqual(response.body, { name: 'foo' }) }) +test('Should be able to make a simple POST with FormData body', async (t) => { + t.plan(5) + + const path = '/' + + const wanted = { name: 'foo' } + + const server = http.createServer((req, res) => { + const contentType = req.headers['content-type'] + t.is(req.method, 'POST') + t.truthy(contentType) + t.regex( + // @ts-expect-error asserting truthy above + contentType, + /multipart\/form\-data/, + ) + + let raw = '' + + req.on('data', (chunk) => { + raw += chunk + }) + req.on('end', () => { + const data = parse( + Buffer.from(raw), + // @ts-expect-error asserting truthy above + contentType.replace('multipart/form-data; boundary=', ''), + ).map((part) => ({ name: part.name, data: part.data.toString('utf8') })) + + t.deepEqual(data, [{ name: 'foo', data: 'bar' }]) + res.writeHead(200, { + 'content-type': 'application/json', + }) + res.write(JSON.stringify(wanted)) + res.end() + res.socket?.unref() + }) + }) + + t.teardown(server.close.bind(server)) + + server.listen() + + const baseURL = getBaseUrlOf(server) + + const dataSource = new (class extends HTTPDataSource { + constructor() { + super(baseURL) + } + postFoo() { + const form = new FormData() + form.append('foo', 'bar') + return this.post(path, { + headers: form.getHeaders(), + json: false, + body: form.getBuffer(), + }) + } + })() + + const response = await dataSource.postFoo() + + t.deepEqual(response.body, { name: 'foo' }) +}) + test('Should be able to make a simple DELETE call', async (t) => { t.plan(2)