gaxios
Version:
A simple common HTTP client specifically for Google APIs and services.
1,169 lines • 57.3 kB
JavaScript
// Copyright 2018 Google LLC
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import assert from 'assert';
import nock from 'nock';
import sinon from 'sinon';
import stream, { Readable } from 'stream';
import { describe, it, afterEach } from 'mocha';
import { HttpsProxyAgent } from 'https-proxy-agent';
import { Gaxios, GaxiosError, request, } from '../src/index.js';
import { GAXIOS_ERROR_SYMBOL, } from '../src/common.js';
import util from '../src/util.cjs';
import fs from 'fs';
const pkg = util.pkg;
nock.disableNetConnect();
const sandbox = sinon.createSandbox();
afterEach(() => {
sandbox.restore();
nock.cleanAll();
});
const url = 'https://example.com';
function setEnv(obj) {
return sandbox.stub(process, 'env').value(obj);
}
describe('🦖 option validation', () => {
it('should throw an error if a url is not provided', async () => {
await assert.rejects(request({}), /URL is required/);
});
});
describe('🚙 error handling', () => {
it('should throw on non-2xx responses by default', async () => {
const scope = nock(url).get('/').reply(500);
await assert.rejects(request({ url }), (err) => {
scope.done();
return err.status === 500;
});
});
it('should throw the error as a GaxiosError object, regardless of Content-Type header', async () => {
const body = {
error: {
message: 'File not found',
code: 404,
status: 'NOT FOUND',
details: [
{
some: 'details',
},
],
},
};
const scope = nock(url).get('/').reply(404, body);
await assert.rejects(request({ url, responseType: 'json' }), (err) => {
scope.done();
assert.deepStrictEqual(err.cause, body.error);
return err.status === 404 && err.message === 'File not found';
});
});
it('should throw the error as a `GaxiosError` object (with the message as a string), even if the request type is requested as an arraybuffer', async () => {
const body = {
error: {
status: 404,
message: 'File not found',
},
};
const scope = nock(url).get('/').reply(404, body);
await assert.rejects(request({ url, responseType: 'arraybuffer' }), (err) => {
scope.done();
return (err.status === 404 &&
err.message === 'Request failed with status code 404' &&
err.response?.data.error.message === 'File not found');
});
});
it('should not throw an error during a translation error', () => {
const notJSON = '.';
const response = {
config: {
responseType: 'json',
},
data: notJSON,
status: 500,
statusText: '',
headers: new Headers(),
// workaround for `node-fetch`'s `.data` deprecation...
bodyUsed: true,
};
const error = new GaxiosError('translation test', {}, response);
assert(error.response);
assert.equal(error.response.data, notJSON);
});
it('should support `instanceof` for GaxiosErrors of the same version', () => {
class A extends GaxiosError {
}
const wrongVersion = { [GAXIOS_ERROR_SYMBOL]: '0.0.0' };
const correctVersion = { [GAXIOS_ERROR_SYMBOL]: pkg.version };
const child = new A('', {});
assert.equal(wrongVersion instanceof GaxiosError, false);
assert.equal(correctVersion instanceof GaxiosError, true);
assert.equal(child instanceof GaxiosError, true);
});
});
describe('🥁 configuration options', () => {
it('should accept `URL` objects', async () => {
const scope = nock(url).get('/').reply(204);
const res = await request({ url: new URL(url) });
scope.done();
assert.strictEqual(res.status, 204);
});
it('should accept `Request` objects', async () => {
const scope = nock(url).get('/').reply(204);
const res = await request(new Request(url));
scope.done();
assert.strictEqual(res.status, 204);
});
it('should use options passed into the constructor', async () => {
const scope = nock(url).head('/').reply(200);
const inst = new Gaxios({ method: 'HEAD' });
const res = await inst.request({ url });
scope.done();
assert.strictEqual(res.config.method, 'HEAD');
});
it('should handle nested options passed into the constructor', async () => {
const scope = nock(url).get('/').reply(200);
const inst = new Gaxios({ headers: new Headers({ apple: 'juice' }) });
const res = await inst.request({
url,
headers: { figgy: 'pudding' },
});
scope.done();
assert.strictEqual(res.config.headers.get('apple'), 'juice');
assert.strictEqual(res.config.headers.get('figgy'), 'pudding');
});
it('should allow setting a base url in the options', async () => {
const scope = nock(url).get('/v1/mango').reply(200, {});
const inst = new Gaxios({ baseURL: `${url}/v1/` });
const res = await inst.request({ url: 'mango' });
scope.done();
assert.deepStrictEqual(res.data, {});
});
it('should allow overriding valid status', async () => {
const scope = nock(url).get('/').reply(304);
const res = await request({ url, validateStatus: () => true });
scope.done();
assert.strictEqual(res.status, 304);
});
it('should allow setting maxContentLength', async () => {
const body = { hello: '🌎' };
const scope = nock(url)
.get('/')
.reply(200, body, { 'content-length': body.toString().length.toString() });
const maxContentLength = 1;
await assert.rejects(request({ url, maxContentLength }), (err) => {
return err instanceof GaxiosError && /limit/.test(err.message);
});
scope.done();
});
it('should support redirects by default', async () => {
const body = { hello: '🌎' };
const scopes = [
nock(url).get('/foo').reply(200, body),
nock(url).get('/').reply(302, undefined, { location: '/foo' }),
];
const res = await request({ url });
scopes.forEach(x => x.done());
assert.deepStrictEqual(res.data, body);
assert.strictEqual(res.url, `${url}/foo`);
});
it('should allow overriding the adapter', async () => {
const response = {
data: { hello: '🌎' },
config: {},
status: 200,
statusText: 'OK',
headers: new Headers(),
};
const adapter = () => Promise.resolve(response);
const res = await request({ url, adapter });
assert.strictEqual(response, res);
});
it('should allow overriding the adapter with default adapter wrapper', async () => {
const body = { hello: '🌎' };
const extraProperty = '🦦';
const scope = nock(url).get('/').reply(200, body);
const timings = [];
const res = await request({
url,
adapter: async (opts, defaultAdapter) => {
const begin = Date.now();
const res = await defaultAdapter(opts);
const end = Date.now();
res.data = {
...res.data,
extraProperty,
};
timings.push({ duration: end - begin });
return res;
},
});
scope.done();
assert.deepStrictEqual(res.data, {
...body,
extraProperty,
});
assert(timings.length === 1);
assert(typeof timings[0].duration === 'number');
});
it('should encode URL parameters', async () => {
const path = '/?james=kirk&montgomery=scott';
const opts = { url: `${url}${path}` };
const scope = nock(url).get(path).reply(200, {});
const res = await request(opts);
assert.strictEqual(res.status, 200);
assert.strictEqual(res.config.url?.toString(), url + path);
scope.done();
});
it('should preserve the original querystring', async () => {
const path = '/?robot';
const opts = { url: `${url}${path}` };
const scope = nock(url).get(path).reply(200, {});
const res = await request(opts);
assert.strictEqual(res.status, 200);
assert.strictEqual(res.config.url?.toString(), url + path);
scope.done();
});
it('should handle empty querystring params', async () => {
const scope = nock(url).get('/').reply(200, {});
const res = await request({
url,
params: {},
});
assert.strictEqual(res.status, 200);
scope.done();
});
it('should encode parameters from the params option', async () => {
const opts = { url, params: { james: 'kirk', montgomery: 'scott' } };
const qs = '?james=kirk&montgomery=scott';
const path = `/${qs}`;
const scope = nock(url).get(path).reply(200, {});
const res = await request(opts);
assert.strictEqual(res.status, 200);
assert.strictEqual(res.config.url?.toString(), new URL(url + qs).toString());
scope.done();
});
it('should merge URL parameters with the params option', async () => {
const opts = {
url: `${url}/?james=beckwith&montgomery=scott`,
params: { james: 'kirk' },
};
const path = '/?james=beckwith&montgomery=scott&james=kirk';
const scope = nock(url).get(path).reply(200, {});
const res = await request(opts);
assert.strictEqual(res.status, 200);
assert.strictEqual(res.config.url?.toString(), url + path);
scope.done();
});
it('should allow overriding the param serializer', async () => {
const qs = '?oh=HAI';
const params = { james: 'kirk' };
const opts = {
url,
params,
paramsSerializer: ps => {
assert.strictEqual(JSON.stringify(params), JSON.stringify(ps));
return '?oh=HAI';
},
};
const scope = nock(url).get(`/${qs}`).reply(200, {});
const res = await request(opts);
assert.strictEqual(res.status, 200);
assert.strictEqual(res.config.url.toString(), new URL(url + qs).toString());
scope.done();
});
it('should return json by default', async () => {
const body = { hello: '🌎' };
const scope = nock(url).get('/').reply(200, body);
const res = await request({ url });
scope.done();
assert.deepStrictEqual(body, res.data);
});
it('should send an application/json header by default', async () => {
const scope = nock(url)
.matchHeader('accept', 'application/json')
.get('/')
.reply(200, {});
const res = await request({ url, responseType: 'json' });
scope.done();
assert.deepStrictEqual(res.data, {});
});
describe('proxying', () => {
const url = 'https://domain.example.com/with-path';
const proxy = 'https://fake.proxy/';
let gaxios;
let request;
let responseBody;
let scope;
beforeEach(() => {
gaxios = new Gaxios();
request = gaxios.request.bind(gaxios);
responseBody = { hello: '🌎' };
const direct = new URL(url);
scope = nock(direct.origin).get(direct.pathname).reply(200, responseBody);
});
function expectDirect(res) {
scope.done();
assert.deepStrictEqual(res.data, responseBody);
assert.strictEqual(res.config.agent, undefined);
}
function expectProxy(res) {
scope.done();
assert.deepStrictEqual(res.data, responseBody);
assert.ok(res.config.agent instanceof HttpsProxyAgent);
assert.equal(res.config.agent.proxy.toString(), proxy);
}
it('should use an https proxy if asked nicely (config)', async () => {
const res = await request({ url, proxy });
expectProxy(res);
});
it('should use an https proxy if asked nicely (env)', async () => {
setEnv({ https_proxy: proxy });
const res = await request({ url });
expectProxy(res);
});
it('should use mTLS with proxy', async () => {
const cert = 'cert';
const key = 'key';
const res = await request({ url, proxy, cert, key });
expectProxy(res);
assert(res.config.agent instanceof HttpsProxyAgent);
assert.equal(res.config.agent.connectOpts.cert, cert);
assert.equal(res.config.agent.connectOpts.key, key);
});
it('should load the proxy from the cache', async () => {
const res1 = await request({ url, proxy });
const agent = res1.config.agent;
expectProxy(res1);
const direct = new URL(url);
scope = nock(direct.origin).get(direct.pathname).reply(200, responseBody);
const res2 = await request({ url, proxy });
assert.strictEqual(agent, res2.config.agent);
expectProxy(res2);
});
it('should load the proxy from the cache with mTLS', async () => {
const cert = 'cert';
const key = 'key';
const res1 = await request({ url, proxy, cert, key });
const agent = res1.config.agent;
expectProxy(res1);
const direct = new URL(url);
scope = nock(direct.origin).get(direct.pathname).reply(200, responseBody);
const res2 = await request({ url, proxy });
assert.strictEqual(agent, res2.config.agent);
expectProxy(res2);
assert(res2.config.agent instanceof HttpsProxyAgent);
assert.equal(res2.config.agent.connectOpts.cert, cert);
assert.equal(res2.config.agent.connectOpts.key, key);
});
describe('noProxy', () => {
it('should not proxy when url matches `noProxy` (config > string)', async () => {
const noProxy = [new URL(url).host];
const res = await request({ url, proxy, noProxy });
expectDirect(res);
});
it('should not proxy when url matches `noProxy` (config > URL)', async () => {
// should match by `URL#origin`
const noProxyURL = new URL(url);
noProxyURL.pathname = '/some-other-path';
const noProxy = [noProxyURL];
const res = await request({ url, proxy, noProxy });
expectDirect(res);
});
it('should not proxy when url matches `noProxy` (config > RegExp)', async () => {
const noProxy = [/example.com/];
const res = await request({ url, proxy, noProxy });
expectDirect(res);
});
it('should not proxy when url matches `noProxy` (config + env > match config)', async () => {
const noProxy = [url];
setEnv({ no_proxy: 'https://foo.bar' });
const res = await request({ url, proxy, noProxy });
expectDirect(res);
});
it('should not proxy when url matches `noProxy` (config + env > match env)', async () => {
const noProxy = ['https://foo.bar'];
setEnv({ no_proxy: url });
const res = await request({ url, proxy, noProxy });
expectDirect(res);
});
it('should proxy when url does not match `noProxy` (config > string)', async () => {
const noProxy = [url];
const res = await request({ url, proxy, noProxy });
expectDirect(res);
});
it('should proxy if url does not match `noProxy` (config > URL > diff origin > protocol)', async () => {
const noProxyURL = new URL(url);
noProxyURL.protocol = 'http:';
const noProxy = [noProxyURL];
const res = await request({ url, proxy, noProxy });
expectProxy(res);
});
it('should proxy if url does not match `noProxy` (config > URL > diff origin > port)', async () => {
const noProxyURL = new URL(url);
noProxyURL.port = '8443';
const noProxy = [noProxyURL];
const res = await request({ url, proxy, noProxy });
expectProxy(res);
});
it('should proxy if url does not match `noProxy` (env)', async () => {
setEnv({ https_proxy: proxy, no_proxy: 'https://blah' });
const res = await request({ url });
expectProxy(res);
});
it('should not proxy if `noProxy` env var matches the origin or hostname of the URL (config > string)', async () => {
const noProxy = [new URL(url).hostname];
const res = await request({ url, proxy, noProxy });
expectDirect(res);
});
it('should not proxy if `noProxy` env var matches the origin or hostname of the URL (env)', async () => {
setEnv({ https_proxy: proxy, no_proxy: new URL(url).hostname });
const res = await request({ url });
expectDirect(res);
});
it('should not proxy if `noProxy` env variable has asterisk, and URL partially matches (config)', async () => {
const parentHost = new URL(url).hostname.split('.').slice(1).join('.');
// ensure we have a host for a valid test
assert(parentHost);
const noProxy = [`*.${parentHost}`];
const res = await request({ url, proxy, noProxy });
expectDirect(res);
});
it('should not proxy if `noProxy` env variable has asterisk, and URL partially matches (env)', async () => {
const parentHost = new URL(url).hostname.split('.').slice(1).join('.');
// ensure we have a host for a valid test
assert(parentHost);
setEnv({ https_proxy: proxy, no_proxy: `*.${parentHost}` });
const res = await request({ url });
expectDirect(res);
});
it('should not proxy if `noProxy` env variable starts with a dot, and URL partially matches (config)', async () => {
const parentHost = new URL(url).hostname.split('.').slice(1).join('.');
// ensure we have a host for a valid test
assert(parentHost);
const noProxy = [`.${parentHost}`];
const res = await request({ url, proxy, noProxy });
expectDirect(res);
});
it('should not proxy if `noProxy` env variable starts with a dot, and URL partially matches (env)', async () => {
const parentHost = new URL(url).hostname.split('.').slice(1).join('.');
// ensure we have a host for a valid test
assert(parentHost);
setEnv({ https_proxy: proxy, no_proxy: '.example.com' });
const res = await request({ url });
expectDirect(res);
});
it('should proxy if `noProxy` env variable has asterisk, but URL is not matching (config)', async () => {
const noProxy = ['*.no.match'];
const res = await request({ url, proxy, noProxy });
expectProxy(res);
});
it('should proxy if `noProxy` env variable has asterisk, but URL is not matching (env)', async () => {
setEnv({ https_proxy: proxy, no_proxy: '*.no.match' });
const res = await request({ url });
expectProxy(res);
});
it('should allow comma-separated lists for `noProxy` env variables (config)', async () => {
const parentHost = new URL(url).hostname.split('.').slice(1).join('.');
// ensure we have a host for a valid test
assert(parentHost);
const noProxy = ['google.com', `*.${parentHost}`, 'hello.com'];
const res = await request({ url, proxy, noProxy });
expectDirect(res);
});
it('should allow comma-separated lists for `noProxy` env variables (env)', async () => {
const parentHost = new URL(url).hostname.split('.').slice(1).join('.');
// ensure we have a host for a valid test
assert(parentHost);
// added spaces to ensure trimming works as expected
const noProxy = [' google.com ', ` *.${parentHost} `, ' hello.com '];
setEnv({ https_proxy: proxy, no_proxy: noProxy.join(',') });
const res = await request({ url });
expectDirect(res);
});
});
});
it('should include the request data in the response config', async () => {
const body = { hello: '🌎' };
const scope = nock(url).post('/', body).reply(200);
const res = await request({ url, method: 'POST', data: body });
scope.done();
assert.deepStrictEqual(res.config.data, body);
});
it('should not stringify the data if it is appended by a form', async () => {
const formData = new FormData();
formData.append('test', '123');
const scope = nock(url)
.post('/', body => {
/**
* Sample from native `node-fetch`
* body: '------3785545705014550845559551617\r\n' +
* 'Content-Disposition: form-data; name="test"\r\n' +
* '\r\n' +
* '123\r\n' +
* '------3785545705014550845559551617--',
*/
/**
* Sample from native `fetch`
* body: '------formdata-undici-0.39470493152687736\r\n' +
* 'Content-Disposition: form-data; name="test"\r\n' +
* '\r\n' +
* '123\r\n' +
* '------formdata-undici-0.39470493152687736--',
*/
return body.match('Content-Disposition: form-data;');
})
.reply(200);
const res = await request({
url,
method: 'POST',
data: formData,
});
scope.done();
assert.deepStrictEqual(res.config.data, formData);
assert.ok(res.config.body instanceof FormData);
assert.ok(res.config.data instanceof FormData);
});
it('should allow explicitly setting the fetch implementation', async () => {
let customFetchCalled = false;
const myFetch = (...args) => {
customFetchCalled = true;
return fetch(...args);
};
const scope = nock(url).post('/').reply(204);
const res = await request({
url,
method: 'POST',
fetchImplementation: myFetch,
// This `data` ensures the 'duplex' option has been set
data: { sample: 'data' },
});
assert(customFetchCalled);
assert.equal(res.status, 204);
scope.done();
});
it('should be able to disable the `errorRedactor`', async () => {
const scope = nock(url).get('/').reply(200);
const instance = new Gaxios({ url, errorRedactor: false });
assert.equal(instance.defaults.errorRedactor, false);
await instance.request({ url });
scope.done();
assert.equal(instance.defaults.errorRedactor, false);
});
it('should be able to set a custom `errorRedactor`', async () => {
const scope = nock(url).get('/').reply(200);
const errorRedactor = (t) => t;
const instance = new Gaxios({ url, errorRedactor });
assert.equal(instance.defaults.errorRedactor, errorRedactor);
await instance.request({ url });
scope.done();
assert.equal(instance.defaults.errorRedactor, errorRedactor);
});
describe('timeout', () => {
it('should accept and use a `timeout`', async () => {
nock(url).get('/').delay(2000).reply(204);
const gaxios = new Gaxios();
const timeout = 10;
await assert.rejects(() => gaxios.request({ url, timeout }), /abort/);
});
it('should a `timeout`, an existing `signal`, and be triggered by timeout', async () => {
nock(url).get('/').delay(2000).reply(204);
const gaxios = new Gaxios();
const signal = new AbortController().signal;
const timeout = 10;
await assert.rejects(() => gaxios.request({ url, timeout, signal }), /abort/);
});
it('should use a `timeout`, a `signal`, and be triggered by signal', async () => {
nock(url).get('/').delay(2000).reply(204);
const gaxios = new Gaxios();
const ac = new AbortController();
const signal = ac.signal;
const timeout = 4000; // after network delay, so this shouldn't trigger
const message = 'Changed my mind - no request please';
setTimeout(() => ac.abort(message), 10);
await assert.rejects(() => gaxios.request({ url, timeout, signal }),
// `node-fetch` always rejects with the generic 'abort' error:
/abort/);
});
});
});
describe('🎏 data handling', () => {
it('should accept a ReadableStream as request data', async () => {
const scope = nock(url).post('/', 'test').reply(200, {});
const res = await request({
url,
method: 'POST',
data: Readable.from('test'),
});
scope.done();
assert.deepStrictEqual(res.data, {});
});
it('should accept a string in the request data', async () => {
const body = { hello: '🌎' };
const encoded = new URLSearchParams(body);
const scope = nock(url)
.matchHeader('content-type', 'application/x-www-form-urlencoded')
.post('/', encoded.toString())
.reply(200, {});
const res = await request({
url,
method: 'POST',
data: encoded,
headers: new Headers({
'content-type': 'application/x-www-form-urlencoded',
}),
});
scope.done();
assert.deepStrictEqual(res.data, {});
});
it('should set application/json content-type for object request by default', async () => {
const body = { hello: '🌎' };
const scope = nock(url)
.matchHeader('Content-Type', 'application/json')
.post('/', JSON.stringify(body))
.reply(200, {});
const res = await request({
url,
method: 'POST',
data: body,
});
scope.done();
assert.deepStrictEqual(res.data, {});
});
it('should allow other JSON content-types to be specified', async () => {
const body = { hello: '🌎' };
const scope = nock(url)
.matchHeader('Content-Type', 'application/json-patch+json')
.post('/', JSON.stringify(body))
.reply(200, {});
const res = await request({
url,
method: 'POST',
data: body,
headers: new Headers({
'Content-Type': 'application/json-patch+json',
}),
});
scope.done();
assert.deepStrictEqual(res.data, {});
});
it('should stringify with qs when content-type is set to application/x-www-form-urlencoded', async () => {
const body = { hello: '🌎' };
const scope = nock(url)
.matchHeader('Content-Type', 'application/x-www-form-urlencoded')
.post('/', new URLSearchParams(body).toString())
.reply(200, {});
const res = await request({
url,
method: 'POST',
data: body,
headers: new Headers({
'Content-Type': 'application/x-www-form-urlencoded',
}),
});
scope.done();
assert.deepStrictEqual(res.data, {});
});
it('should return stream if asked nicely', async () => {
const body = { hello: '🌎' };
const scope = nock(url).get('/').reply(200, body);
const res = await request({ url, responseType: 'stream' });
scope.done();
assert(res.data instanceof stream.Readable);
});
it('should return a `ReadableStream` when `fetch` has been provided ', async () => {
const body = { hello: '🌎' };
const scope = nock(url).get('/').reply(200, body);
const res = await request({
url,
responseType: 'stream',
fetchImplementation: fetch,
});
scope.done();
assert(res.data instanceof ReadableStream);
});
it('should return an ArrayBuffer if asked nicely', async () => {
const body = { hello: '🌎' };
const scope = nock(url).get('/').reply(200, body);
const res = await request({
url,
responseType: 'arraybuffer',
});
scope.done();
assert(res.data instanceof ArrayBuffer);
assert.deepStrictEqual(Buffer.from(JSON.stringify(body)), Buffer.from(res.data));
});
it('should return a blob if asked nicely', async () => {
const body = { hello: '🌎' };
const scope = nock(url).get('/').reply(200, body);
const res = await request({ url, responseType: 'blob' });
scope.done();
assert.ok(res.data);
});
it('should return text if asked nicely', async () => {
const body = 'hello 🌎';
const scope = nock(url).get('/').reply(200, body);
const res = await request({ url, responseType: 'text' });
scope.done();
assert.strictEqual(res.data, body);
});
it('should return status text', async () => {
const body = { hello: '🌎' };
const scope = nock(url).get('/').reply(200, body);
const res = await request({ url });
scope.done();
assert.ok(res.data);
// node-fetch and native fetch specs differ...
// https://github.com/node-fetch/node-fetch/issues/1066
assert.strictEqual(typeof res.statusText, 'string');
// assert.strictEqual(res.statusText, 'OK');
});
it('should return JSON when response Content-Type=application/json', async () => {
const body = { hello: 'world' };
const scope = nock(url)
.get('/')
.reply(200, body, { 'Content-Type': 'application/json' });
const res = await request({ url });
scope.done();
assert.ok(res.data);
assert.deepStrictEqual(res.data, body);
});
it('should return invalid JSON as text when response Content-Type=application/json', async () => {
const body = 'hello world';
const scope = nock(url)
.get('/')
.reply(200, body, { 'Content-Type': 'application/json' });
const res = await request({ url });
scope.done();
assert.ok(res.data);
assert.deepStrictEqual(res.data, body);
});
it('should return text when response Content-Type=text/plain', async () => {
const body = 'hello world';
const scope = nock(url)
.get('/')
.reply(200, body, { 'Content-Type': 'text/plain' });
const res = await request({ url });
scope.done();
assert.ok(res.data);
assert.deepStrictEqual(res.data, body);
});
it('should return text when response Content-Type=text/csv', async () => {
const body = '"col1","col2"\n"hello","world"';
const scope = nock(url)
.get('/')
.reply(200, body, { 'Content-Type': 'text/csv' });
const res = await request({ url });
scope.done();
assert.ok(res.data);
assert.deepStrictEqual(res.data, body);
});
it('should return raw data when Content-Type is unable to be parsed', async () => {
const body = Buffer.from('hello world', 'utf-8');
const scope = nock(url)
.get('/')
.reply(200, body, { 'Content-Type': 'image/gif' });
const res = await request({ url });
scope.done();
assert.ok(res.data);
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: new 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: new Headers({ 'Content-Type': 'application/json' }),
content: body,
},
{
headers: new 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>.';
const customURL = new URL(url);
customURL.searchParams.append('token', 'sensitive');
customURL.searchParams.append('client_secret', 'data');
customURL.searchParams.append('random', 'non-sensitive');
const config = {
headers: {
Authentication: 'My Auth',
/**
* Ensure casing is properly handled
*/
AUTHORIZATION: 'My Auth',
'content-type': 'application/x-www-form-urlencoded',
random: 'data',
},
data: {
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: 'somesensitivedata',
unrelated: 'data',
client_secret: 'data',
},
body: 'grant_type=somesensitivedata&assertion=somesensitivedata&client_secret=data',
};
// simulate JSON response
const responseHeaders = {
...config.headers,
'content-type': 'application/json',
};
const response = { ...config.data };
const scope = nock(url)
.post('/')
.query(() => true)
.reply(404, response, responseHeaders);
const instance = new Gaxios(JSON.parse(JSON.stringify(config)));
const requestConfig = {
url: customURL.toString(),
method: 'POST',
};
const requestConfigCopy = JSON.parse(JSON.stringify({ ...requestConfig }));
try {
await instance.request(requestConfig);
throw new Error('Expected a GaxiosError');
}
catch (e) {
assert(e instanceof GaxiosError);
// config should not be mutated
assert.deepStrictEqual(instance.defaults, config);
assert.deepStrictEqual(requestConfig, requestConfigCopy);
assert.notStrictEqual(e.config, config);
// config redactions - headers
const expectedRequestHeaders = new Headers({
...config.headers, // non-redactables should be present
Authentication: REDACT,
AUTHORIZATION: REDACT,
});
const actualHeaders = e.config.headers;
expectedRequestHeaders.forEach((value, key) => {
assert.equal(actualHeaders.get(key), value);
});
// config redactions - data
assert.deepStrictEqual(e.config.data, {
...config.data, // non-redactables should be present
grant_type: REDACT,
assertion: REDACT,
client_secret: REDACT,
});
assert.deepStrictEqual(Object.fromEntries(e.config.body), {
...config.data, // non-redactables should be present
grant_type: REDACT,
assertion: REDACT,
client_secret: REDACT,
});
expectedRequestHeaders.forEach((value, key) => {
assert.equal(actualHeaders.get(key), value);
});
// config redactions - url
assert(e.config.url);
const resultURL = new URL(e.config.url);
assert.notDeepStrictEqual(resultURL.toString(), customURL.toString());
customURL.searchParams.set('token', REDACT);
customURL.searchParams.set('client_secret', REDACT);
assert.deepStrictEqual(resultURL.toString(), customURL.toString());
// response redactions
assert(e.response);
assert.deepStrictEqual(e.response.config, e.config);
const expectedResponseHeaders = new Headers({
...responseHeaders, // non-redactables should be present
});
expectedResponseHeaders.set('authentication', REDACT);
expectedResponseHeaders.set('authorization', REDACT);
expectedResponseHeaders.forEach((value, key) => {
assert.equal(e.response?.headers.get(key), value);
});
assert.deepStrictEqual(e.response.data, {
...response, // non-redactables should be present
assertion: REDACT,
client_secret: REDACT,
grant_type: REDACT,
});
}
finally {
scope.done();
}
});
it('should redact after final retry', async () => {
const customURL = new URL(url);
customURL.searchParams.append('token', 'sensitive');
customURL.searchParams.append('client_secret', 'data');
customURL.searchParams.append('random', 'non-sensitive');
const data = {
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: 'somesensitivedata',
unrelated: 'data',
client_secret: 'data',
};
let retryAttempted = false;
const config = {
url: customURL,
method: 'POST',
data: new URLSearchParams(data),
retry: true,
retryConfig: {
httpMethodsToRetry: ['POST'],
onRetryAttempt: err => {
assert.deepStrictEqual(err.config.data, new URLSearchParams(data));
retryAttempted = true;
},
},
};
const scope = nock(url)
.post('/', data)
.query(() => true)
.reply(500)
.post('/', data)
.query(() => true)
.reply(204);
const gaxios = new Gaxios();
try {
await gaxios.request(config);
assert(retryAttempted);
}
finally {
scope.done();
}
});
});
describe('🍂 defaults & instances', () => {
it('should allow creating a new instance', () => {
const requestInstance = new Gaxios();
assert.strictEqual(typeof requestInstance.request, 'function');
});
it('should allow passing empty options', async () => {
const body = { hello: '🌎' };
const scope = nock(url).get('/').reply(200, body);
const gax = new Gaxios({ url });
const res = await gax.request();
scope.done();
assert.deepStrictEqual(res.data, body);
});
it('should allow buffer to be posted', async () => {
const pkg = fs.readFileSync('./package.json');
const pkgJson = JSON.parse(pkg.toString('utf8'));
const scope = nock(url)
.matchHeader('content-type', 'application/dicom')
.post('/', pkgJson)
.reply(200, {});
const res = await request({
url,
method: 'POST',
data: pkg,
headers: new Headers({ 'content-type': 'application/dicom' }),
});
scope.done();
assert.deepStrictEqual(res.data, {});
});
it('should not set a default content-type for buffers', async () => {
const jsonLike = '{}';
const data = Buffer.from(jsonLike);
const scope = nock(url)
// no content type should be present
.matchHeader('content-type', v => v === undefined)
.post('/', jsonLike)
.reply(204);
const res = await request({ url, method: 'POST', data });
scope.done();
assert.equal(res.status, 204);
});
describe('mtls', () => {
class GaxiosAssertAgentCache extends Gaxios {
getAgentCache() {
return this.agentCache;
}
async _request(opts) {
assert(opts.agent);
return super._request(opts);
}
}
it('uses HTTPS agent if cert and key provided, on first request', async () => {
const key = fs.readFileSync('./test/fixtures/fake.key', 'utf8');
const scope = nock(url).get('/').reply(200);
const inst = new GaxiosAssertAgentCache({
headers: new Headers({ apple: 'juice' }),
cert: fs.readFileSync('./test/fixtures/fake.cert', 'utf8'),
key,
});
const res = await inst.request({
url,
headers: new Headers({ figgy: 'pudding' }),
});
scope.done();
assert.strictEqual(res.config.headers.get('apple'), 'juice');
assert.strictEqual(res.config.headers.get('figgy'), 'pudding');
const agentCache = inst.getAgentCache();
assert(agentCache.get(key));
});
it('uses HTTPS agent if cert and key provided, on subsequent requests', async () => {
const key = fs.readFileSync('./test/fixtures/fake.key', 'utf8');
const scope = nock(url).get('/').reply(200).get('/').reply(200);
const inst = new GaxiosAssertAgentCache({
headers: new Headers({ apple: 'juice' }),
cert: fs.readFileSync('./test/fixtures/fake.cert', 'utf8'),
key,
});
await inst.request({ url, headers: new Headers({ figgy: 'pudding' }) });
await inst.request({ url, headers: new Headers({ figgy: 'pudding' }) });
scope.done();
const agentCache = inst.getAgentCache();
assert(agentCache.get(key));
});
});
});
describe('interceptors', () => {
describe('request', () => {
it('should invoke a request interceptor when one is provided', async () => {
const scope = nock(url)
.matchHeader('hello', 'world')
.get('/')
.reply(200, {});
const instance = new Gaxios();
instance.interceptors.request.add({
resolved: config => {
config.headers.set('hello', 'world');
return Promise.resolve(config);
},
});
await instance.request({ url });
scope.done();
});
it('should not invoke a request interceptor after it is removed', async () => {
const scope = nock(url).persist().get('/').reply(200, {});
const spyFunc = sinon.fake(() => Promise.resolve({
url,
validateStatus: () => {
return true;
},
}));
const instance = new Gaxios();
const interceptor = { resolved: spyFunc };
instance.interceptors.request.add(interceptor);
await instance.request({ url });
instance.interceptors.request.delete(interceptor);
await instance.request({ url });
scope.done();
assert.strictEqual(spyFunc.callCount, 1);
});
it('should invoke multiple request interceptors in the order they were added', async () => {
const scope = nock(url)
.matchHeader('foo', 'bar')
.matchHeader('bar', 'baz')
.matchHeader('baz', 'buzz')
.get('/')
.reply(200, {});
const instance = new Gaxios();
instance.interceptors.request.add({
resolved: config => {
config.headers.set('foo', 'bar');
return Promise.resolve(config);
},
});
instance.interceptors.request.add({
resolved: config => {
assert.strictEqual(config.headers.get('foo'), 'bar');
config.headers.set('bar', 'baz');
return Promise.resolve(config);
},
});
instance.interceptors.request.add({
resolved: config => {
assert.strictEqual(config.headers.get('foo'), 'bar');
assert.strictEqual(config.headers.get('bar'), 'baz');
config.headers.set('baz', 'buzz');
return Promise.resolve(config);
},
});
await instance.request({ url });
scope.done();
});
it('should not invoke a any request interceptors after they are removed', async () => {
const scope = nock(url).persist().get('/').reply(200, {});
const spyFunc = sinon.fake(() => Promise.resolve({
url,
validateStatus: () => {
return true;
},
}));
const instance = new Gaxios();
instance.interceptors.request.add({
resolved: spyFunc,
});
instance.interceptors.request.add({
resolved: spyFunc,
});
instance.interceptors.request.add({
resolved: spyFunc,
});
await instance.request({ url });
instance.interceptors.request.clear();
await instance.request({ url });
scope.done();
assert.strictEqual(spyFunc.callCount, 3);
});
it('should invoke the rejected function when a previous request interceptor rejects', async () => {
const instance = new Gaxios();
instance.interceptors.request.add({
resolved: () => {
throw new Error('Something went wrong');
},
});
instance.interceptors.request.add({
resolved: config => {
config.headers.set('hello', 'world');
return Promise.resolve(config);
},
rejected: err => {
assert.strictEqual(err.message, 'Something went wrong');
},
});
// Because the options wind up being invalid the call will reject with a URL problem.
await assert.rejects(instance.request({ url }));
});
});
describe('response', () => {
it('should invoke a respons