diff --git a/package.json b/package.json index 969c82f5..0a315022 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,6 @@ "tmp": "0.2.3", "ts-loader": "^8.0.0", "typescript": "^5.1.6", - "uuid": "^9.0.0", "webpack": "^5.35.0", "webpack-cli": "^4.0.0" }, @@ -90,6 +89,7 @@ "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "is-stream": "^2.0.0", - "node-fetch": "^2.6.9" + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" } } diff --git a/src/common.ts b/src/common.ts index cbeaf3b2..485a19c9 100644 --- a/src/common.ts +++ b/src/common.ts @@ -16,6 +16,7 @@ import {URL} from 'url'; import {pkg} from './util'; import extend from 'extend'; +import {Readable} from 'stream'; /** * Support `instanceof` operator for `GaxiosError`s in different versions of this library. @@ -135,6 +136,11 @@ export interface GaxiosResponse { request: GaxiosXMLHttpRequest; } +export interface GaxiosMultipartOptions { + headers: Headers; + content: string | Readable; +} + /** * Request options that are used to form the request. */ @@ -175,6 +181,7 @@ export interface GaxiosOptions { */ maxRedirects?: number; follow?: number; + multipart?: GaxiosMultipartOptions[]; params?: any; paramsSerializer?: (params: {[index: string]: string | number}) => string; timeout?: number; diff --git a/src/gaxios.ts b/src/gaxios.ts index 97b1c8a4..be9ffb0d 100644 --- a/src/gaxios.ts +++ b/src/gaxios.ts @@ -21,6 +21,7 @@ import {URL} from 'url'; import { FetchResponse, + GaxiosMultipartOptions, GaxiosError, GaxiosOptions, GaxiosPromise, @@ -29,8 +30,9 @@ import { defaultErrorRedactor, } from './common'; import {getRetryConfig} from './retry'; -import {Stream} from 'stream'; +import {PassThrough, Stream, pipeline} from 'stream'; import {HttpsProxyAgent as httpsProxyAgent} from 'https-proxy-agent'; +import {v4} from 'uuid'; /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -261,7 +263,7 @@ export class Gaxios { } opts.headers = opts.headers || {}; - if (opts.data) { + if (opts.multipart === undefined && opts.data) { const isFormData = typeof FormData === 'undefined' ? false @@ -294,6 +296,19 @@ export class Gaxios { } else { opts.body = opts.data; } + } else if (opts.multipart && opts.multipart.length > 0) { + // note: once the minimum version reaches Node 16, + // this can be replaced with randomUUID() function from crypto + // and the dependency on UUID removed + const boundary = v4(); + opts.headers['Content-Type'] = `multipart/related; boundary=${boundary}`; + const bodyStream = new PassThrough(); + opts.body = bodyStream; + pipeline( + this.getMultipartRequest(opts.multipart, boundary), + bodyStream, + () => {} + ); } opts.validateStatus = opts.validateStatus || this.validateStatus; @@ -416,4 +431,32 @@ export class Gaxios { return response.blob(); } } + + /** + * Creates an async generator that yields the pieces of a multipart/related request body. + * This implementation follows the spec: https://www.ietf.org/rfc/rfc2387.txt. However, recursive + * multipart/related requests are not currently supported. + * + * @param {GaxioMultipartOptions[]} multipartOptions the pieces to turn into a multipart/related body. + * @param {string} boundary the boundary string to be placed between each part. + */ + private async *getMultipartRequest( + multipartOptions: GaxiosMultipartOptions[], + boundary: string + ) { + const finale = `--${boundary}--`; + for (const currentPart of multipartOptions) { + const partContentType = + currentPart.headers['Content-Type'] || 'application/octet-stream'; + const preamble = `--${boundary}\r\nContent-Type: ${partContentType}\r\n\r\n`; + yield preamble; + if (typeof currentPart.content === 'string') { + yield currentPart.content; + } else { + yield* currentPart.content; + } + yield '\r\n'; + } + yield finale; + } } diff --git a/test/test.getch.ts b/test/test.getch.ts index 6f26f2b8..67e4df52 100644 --- a/test/test.getch.ts +++ b/test/test.getch.ts @@ -14,7 +14,7 @@ import assert from 'assert'; import nock from 'nock'; import sinon from 'sinon'; -import stream from 'stream'; +import stream, {Readable} from 'stream'; import {describe, it, afterEach} from 'mocha'; import fetch from 'node-fetch'; import {HttpsProxyAgent} from 'https-proxy-agent'; @@ -698,6 +698,69 @@ describe('🎏 data handling', () => { assert.notEqual(res.data, body); }); + it('should handle multipart/related when options.multipart is set and a single part', async () => { + const bodyContent = {hello: '🌎'}; + const body = new Readable(); + body.push(JSON.stringify(bodyContent)); + body.push(null); + const scope = nock(url) + .matchHeader( + 'Content-Type', + /multipart\/related; boundary=[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/ + ) + .post( + '/', + /^(--[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}[\r\n]+Content-Type: application\/json[\r\n\r\n]+{"hello":"🌎"}[\r\n]+--[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}--)$/ + ) + .reply(200, {}); + const res = await request({ + url, + method: 'POST', + multipart: [ + { + headers: {'Content-Type': 'application/json'}, + content: body, + }, + ], + }); + scope.done(); + assert.ok(res.data); + }); + + it('should handle multipart/related when options.multipart is set and a multiple parts', async () => { + const jsonContent = {hello: '🌎'}; + const textContent = 'hello world'; + const body = new Readable(); + body.push(JSON.stringify(jsonContent)); + body.push(null); + const scope = nock(url) + .matchHeader( + 'Content-Type', + /multipart\/related; boundary=[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/ + ) + .post( + '/', + /^(--[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}[\r\n]+Content-Type: application\/json[\r\n\r\n]+{"hello":"🌎"}[\r\n]+--[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}[\r\n]+Content-Type: text\/plain[\r\n\r\n]+hello world[\r\n]+--[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}--)$/ + ) + .reply(200, {}); + const res = await request({ + url, + method: 'POST', + multipart: [ + { + headers: {'Content-Type': 'application/json'}, + content: body, + }, + { + headers: {'Content-Type': 'text/plain'}, + content: textContent, + }, + ], + }); + scope.done(); + assert.ok(res.data); + }); + it('should redact sensitive props via the `errorRedactor` by default', async () => { const REDACT = '< - See `errorRedactor` option in `gaxios` for configuration>.';