Skip to content

Commit

Permalink
feat: support multipart/related requests (#610)
Browse files Browse the repository at this point in the history
* feat: support multipart/related requests

* move uuid to deps

* fix typo

* add spec note
  • Loading branch information
ddelgrosso1 committed Apr 3, 2024
1 parent 696246c commit 086c824
Show file tree
Hide file tree
Showing 4 changed files with 118 additions and 5 deletions.
4 changes: 2 additions & 2 deletions package.json
Expand Up @@ -82,14 +82,14 @@
"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"
},
"dependencies": {
"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"
}
}
7 changes: 7 additions & 0 deletions src/common.ts
Expand Up @@ -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.
Expand Down Expand Up @@ -135,6 +136,11 @@ export interface GaxiosResponse<T = any> {
request: GaxiosXMLHttpRequest;
}

export interface GaxiosMultipartOptions {
headers: Headers;
content: string | Readable;
}

/**
* Request options that are used to form the request.
*/
Expand Down Expand Up @@ -175,6 +181,7 @@ export interface GaxiosOptions {
*/
maxRedirects?: number;
follow?: number;
multipart?: GaxiosMultipartOptions[];
params?: any;

Check warning on line 185 in src/common.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
paramsSerializer?: (params: {[index: string]: string | number}) => string;
timeout?: number;
Expand Down
47 changes: 45 additions & 2 deletions src/gaxios.ts
Expand Up @@ -21,6 +21,7 @@ import {URL} from 'url';

import {
FetchResponse,
GaxiosMultipartOptions,
GaxiosError,
GaxiosOptions,
GaxiosPromise,
Expand All @@ -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 */

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
65 changes: 64 additions & 1 deletion test/test.getch.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -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 =
'<<REDACTED> - See `errorRedactor` option in `gaxios` for configuration>.';
Expand Down

0 comments on commit 086c824

Please sign in to comment.