UNPKG

@bbc/http-transport

Version:

A flexible, modular REST client built for ease-of-use and resilience.

749 lines (607 loc) 19.7 kB
'use strict'; const assert = require('chai').assert; const expect = require('chai').expect; const nock = require('nock'); const sinon = require('sinon'); const HttpTransport = require('..'); const Transport = require('../lib/transport/transport'); const toJson = require('../lib/middleware/asJson'); const setContextProperty = require('../lib/middleware/setContextProperty'); const log = require('../lib/middleware/logger'); const packageInfo = require('../package'); const toError = require('./toError'); const sandbox = sinon.sandbox.create(); const url = 'http://www.example.com/'; const host = 'http://www.example.com'; const api = nock(host); const path = '/'; const simpleResponseBody = { blobbus: 'Illegitimi non carborundum' }; const requestBody = { foo: 'bar' }; const responseBody = requestBody; const defaultHeaders = { 'Content-Type': 'application/json' }; function toUpperCase() { return async (ctx, next) => { await next(); ctx.res.body.blobbus = ctx.res.body.blobbus.toUpperCase(); }; } function nockRetries(retry, opts) { const httpMethod = opts?.httpMethod || 'get'; const successCode = opts?.successCode || 200; nock.cleanAll(); api[httpMethod](path) .times(retry) .reply(500); api[httpMethod](path).reply(successCode, simpleResponseBody, defaultHeaders); } function nockTimeouts(number, opts) { const httpMethod = opts?.httpMethod || 'get'; const successCode = opts?.successCode || 200; nock.cleanAll(); api[httpMethod](path) .times(number) .delay(10000) .reply(200); api[httpMethod](path).reply(successCode, simpleResponseBody, defaultHeaders); } describe('HttpTransportClient', () => { beforeEach(() => { nock.disableNetConnect(); nock.cleanAll(); api.get(path).reply(200, simpleResponseBody, defaultHeaders); }); afterEach(() => { sandbox.restore(); }); describe('.get', () => { it('returns a response', async () => { const res = await HttpTransport.createClient() .get(url) .asResponse(); assert.deepEqual(res.body, simpleResponseBody); }); it('handles an empty response body when parsing as JSON', async () => { nock.cleanAll(); api.get(path).reply(200, undefined, defaultHeaders); const res = await HttpTransport.createClient() .get(url) .asResponse(); assert.deepEqual(res.body, undefined); }); it('sets a default User-agent for every request', async () => { nock.cleanAll(); nock(host, { reqheaders: { 'User-Agent': `${packageInfo.name}/${packageInfo.version}` } }) .get(path) .times(2) .reply(200, responseBody); const client = HttpTransport.createClient(); await client.get(url).asResponse(); }); it('overrides the default User-agent for every request', async () => { nock.cleanAll(); nock(host, { reqheaders: { 'User-Agent': 'some-new-user-agent' } }) .get(path) .times(2) .reply(200, responseBody); const client = HttpTransport.createBuilder() .userAgent('some-new-user-agent') .createClient(); await client.get(url).asResponse(); }); }); describe('default', () => { it('sets default retry values in the context', async () => { const transport = new Transport(); sandbox.stub(transport, 'execute').returns(Promise.resolve()); const client = HttpTransport.createBuilder(transport) .retries(50) .retryDelay(2000) .createClient(); await client .get(url) .asResponse(); const ctx = transport.execute.getCall(0).args[0]; assert.equal(ctx.retries, 50); assert.equal(ctx.retryDelay, 2000); }); it('sets default criticalErrorDetector in the context', async () => { const transport = new Transport(); sandbox.stub(transport, 'execute').returns(Promise.resolve()); const client = HttpTransport.createBuilder(transport) .criticalErrorDetector(() => false) .createClient(); await client .get(url) .asResponse(); const ctx = transport.execute.getCall(0).args[0]; assert.equal(ctx.criticalErrorDetector.toString(), (() => false).toString()); }); }); describe('.retries', () => { it('retries a given number of times for failed requests', async () => { nockRetries(2); const client = HttpTransport.createBuilder() .use(toError()) .createClient(); const res = await client .get(url) .retry(2) .asResponse(); assert.equal(res.statusCode, 200); }); it('retries a given number of times for requests that timed out', async () => { nockTimeouts(2); const client = HttpTransport.createBuilder() .use(toError()) .createClient(); const res = await client .get(url) .timeout(500) .retry(2) .asResponse(); assert.equal(res.statusCode, 200); }); it('waits a minimum of 100ms between retries by default', async () => { nockRetries(1); const startTime = Date.now(); const client = HttpTransport.createBuilder() .use(toError()) .createClient(); const res = await client .get(url) .retry(2) .asResponse(); const timeTaken = Date.now() - startTime; assert(timeTaken > 100); assert.equal(res.statusCode, 200); }); it('disables retryDelay if retries if set to zero', async () => { nock.cleanAll(); api.get(path).reply(500); const client = HttpTransport.createBuilder() .use(toError()) .createClient(); try { await client .get(url) .retry(0) .retryDelay(10000) .asResponse(); } catch (e) { return assert.equal(e.message, 'something bad happened.'); } assert.fail('Should have thrown'); }); it('overrides the minimum wait time between retries', async () => { nockRetries(1); const retryDelay = 200; const startTime = Date.now(); const client = HttpTransport.createBuilder() .use(toError()) .createClient(); const res = await client .get(url) .retry(1) .retryDelay(retryDelay) .asResponse(); const timeTaken = Date.now() - startTime; assert(timeTaken > retryDelay); assert.equal(res.statusCode, 200); }); it('does not retry 4XX errors', async () => { nock.cleanAll(); api .get(path) .once() .reply(400); const client = HttpTransport.createBuilder() .use(toError()) .createClient(); try { await client .get(url) .retry(1) .asResponse(); } catch (err) { return assert.equal(err.statusCode, 400); } assert.fail('Should have thrown'); }); }); describe('.post', () => { it('makes a POST request', async () => { api.post(path, requestBody).reply(201, responseBody, { 'content-type': 'application/json' }); const body = await HttpTransport.createClient() .post(url, requestBody) .asBody(); assert.deepEqual(body, responseBody); }); it('returns an error when the API returns a 5XX statusCode code', async () => { api.post(path, requestBody).reply(500); try { await HttpTransport.createClient() .use(toError()) .post(url, requestBody) .asResponse(); } catch (err) { return assert.equal(err.statusCode, 500); } assert.fail('Should have thrown'); }); }); describe('.put', () => { it('makes a PUT request with a JSON body', async () => { api.put(path, requestBody).reply(201, responseBody, { 'content-type': 'application/json' }); const body = await HttpTransport.createClient() .put(url, requestBody) .asBody(); assert.deepEqual(body, responseBody); }); it('returns an error when the API returns a 5XX statusCode code', async () => { api.put(path, requestBody).reply(500); try { await HttpTransport.createClient() .use(toError()) .put(url, requestBody) .asResponse(); } catch (err) { return assert.equal(err.statusCode, 500); } assert.fail('Should have thrown'); }); }); describe('.delete', () => { it('makes a DELETE request', () => { api.delete(path).reply(204); return HttpTransport.createClient().delete(url); }); it('returns an error when the API returns a 5XX statusCode code', async () => { api.delete(path).reply(500); try { await HttpTransport.createClient() .use(toError()) .delete(url) .asResponse(); } catch (err) { return assert.equal(err.statusCode, 500); } assert.fail('Should have thrown'); }); }); describe('.patch', () => { it('makes a PATCH request', async () => { api.patch(path).reply(204, simpleResponseBody); await HttpTransport.createClient() .patch(url) .asResponse(); }); it('returns an error when the API returns a 5XX statusCode code', async () => { api.patch(path, requestBody).reply(500); try { await HttpTransport.createClient() .use(toError()) .patch(url, requestBody) .asResponse(); } catch (err) { return assert.equal(err.statusCode, 500); } assert.fail('Should have thrown'); }); }); describe('.head', () => { it('makes a HEAD request', async () => { api.head(path).reply(200, simpleResponseBody); const res = await HttpTransport.createClient() .head(url) .asResponse(); assert.strictEqual(res.statusCode, 200); }); it('returns an error when the API returns a 5XX statusCode code', async () => { api.head(path).reply(500); try { await HttpTransport.createClient() .use(toError()) .head(url) .asResponse(); } catch (err) { return assert.strictEqual(err.statusCode, 500); } assert.fail('Should have thrown'); }); }); describe('.headers', () => { it('sends a custom headers', async () => { nock.cleanAll(); const HeaderValue = `${packageInfo.name}/${packageInfo.version}`; nock(host, { reqheaders: { 'User-Agent': HeaderValue, foo: 'bar' } }) .get(path) .reply(200, responseBody); const res = await HttpTransport.createClient() .get(url) .headers({ 'User-Agent': HeaderValue, foo: 'bar' }) .asResponse(); assert.equal(res.statusCode, 200); }); it('ignores an empty header object', async () => { nock.cleanAll(); api.get(path).reply(200, simpleResponseBody, { 'content-type': 'application/json' }); const res = await HttpTransport.createClient() .headers({}) .get(url) .asResponse(); assert.deepEqual(res.body, simpleResponseBody); }); }); describe('query strings', () => { it('supports adding a query string', async () => { api.get('/?a=1').reply(200, simpleResponseBody, { 'content-type': 'application/json' }); const body = await HttpTransport.createClient() .get(url) .query('a', 1) .asBody(); assert.deepEqual(body, simpleResponseBody); }); it('supports multiple query strings', async () => { nock.cleanAll(); api.get('/?a=1&b=2&c=3').reply(200, simpleResponseBody, { 'content-type': 'application/json' }); const body = await HttpTransport.createClient() .get(url) .query({ a: 1, b: 2, c: 3 }) .asBody(); assert.deepEqual(body, simpleResponseBody); }); it('ignores empty query objects', async () => { const res = await HttpTransport.createClient() .query({}) .get(url) .asResponse(); assert.deepEqual(res.body, simpleResponseBody); }); }); describe('.timeout', () => { it('sets the a timeout', async () => { nock.cleanAll(); api .get('/') .delay(1000) .reply(200, simpleResponseBody); try { await HttpTransport.createClient() .get(url) .timeout(20) .asBody(); } catch (err) { return assert.equal(err.message, 'Request failed for GET http://www.example.com/: ESOCKETTIMEDOUT'); } assert.fail('Should have thrown'); }); }); describe('.redirect', () => { describe('sets the type of redirect handling', async () => { it('redirects automatically if value is not set', async () => { nock.cleanAll(); api .get('/') .reply(303, '', { Location: `${url}new-path` }); api .get('/new-path') .reply(200, 'It works'); const client = HttpTransport.createClient() .get(url); const response = await client.asResponse(); assert(response.statusCode, 200); assert(response.body, 'It works'); }); it('returns 303 and the location if value is `manual`', async () => { nock.cleanAll(); api .get('/') .reply(303, '', { Location: `${url}new-path` }); const client = HttpTransport.createClient() .redirect('manual') .get(url); const response = await client.asResponse(); assert(response.statusCode, 303); assert(response.headers.location, `${url}new-path`); }); it('throws error if value is `error`', async () => { nock.cleanAll(); api .get('/') .reply(303, '', { Location: `${url}new-path` }); try { const client = HttpTransport.createClient() .redirect('error') .get(url); await client.asResponse(); } catch (err) { expect(err.message).to.include('Request failed for GET http://www.example.com/'); return; } assert.fail('Should have thrown'); }); }); }); describe('plugins', () => { it('supports a per request plugin', async () => { nock.cleanAll(); api .get(path) .times(2) .reply(200, simpleResponseBody); const client = HttpTransport.createClient(); const upperCaseResponse = await client .use(toUpperCase()) .get(url) .asBody(); const lowerCaseResponse = await client .get(url) .asBody(); assert.equal(upperCaseResponse.blobbus, 'ILLEGITIMI NON CARBORUNDUM'); assert.equal(lowerCaseResponse.blobbus, 'Illegitimi non carborundum'); }); it('executes global and per request plugins', async () => { nock.cleanAll(); api.get(path).reply(200, simpleResponseBody); function appendTagGlobally() { return async (ctx, next) => { await next(); ctx.res.body = 'global ' + ctx.res.body; }; } function appendTagPerRequestTag() { return async (ctx, next) => { await next(); ctx.res.body = 'request'; }; } const client = HttpTransport.createBuilder() .use(appendTagGlobally()) .createClient(); const body = await client .use(appendTagPerRequestTag()) .get(url) .asBody(); assert.equal(body, 'global request'); }); it('throws if a global plugin is not a function', () => { assert.throws( () => { HttpTransport.createBuilder().use('bad plugin'); }, TypeError, 'Plugin is not a function' ); }); it('throws if a per request plugin is not a function', () => { assert.throws( () => { const client = HttpTransport.createClient(); client.use('bad plugin').get(url); }, TypeError, 'Plugin is not a function' ); }); describe('setContextProperty', () => { it('sets an option in the context', async () => { nock.cleanAll(); api.get(path).reply(200, '1234'); const client = HttpTransport.createBuilder() .createClient(); const res = await client .use(setContextProperty({ json: true }, 'opts' )) .get(url) .asResponse(); assert.strictEqual(res.body, 1234); }); it('sets an explict key on the context', async () => { nock.cleanAll(); api .get(path) .delay(1000) .reply(200, responseBody); const client = HttpTransport.createBuilder() .use(toJson()) .createClient(); try { await client .use(setContextProperty(20, 'req._timeout')) .get(url) .asResponse(); } catch (err) { return assert.equal(err.message, 'Request failed for GET http://www.example.com/: ESOCKETTIMEDOUT'); } assert.fail('Should have thrown'); }); }); describe('toJson', () => { it('returns body of a JSON response', async () => { nock.cleanAll(); api.get(path).reply(200, responseBody, defaultHeaders); const client = HttpTransport.createBuilder() .use(toJson()) .createClient(); const body = await client .get(url) .asBody(); assert.equal(body.foo, 'bar'); }); }); describe('logging', () => { it('logs each request at info level when a logger is passed in', async () => { api.get(path).reply(200); const stubbedLogger = { info: sandbox.stub(), warn: sandbox.stub() }; const client = HttpTransport.createBuilder() .use(log(stubbedLogger)) .createClient(); await client .get(url) .asBody(); const message = stubbedLogger.info.getCall(0).args[0]; assert.match(message, /GET http:\/\/www.example.com\/ 200 \d+ ms/); }); it('uses default logger', async () => { sandbox.stub(console, 'info'); const client = HttpTransport.createBuilder() .use(log()) .createClient(); await client .get(url) .asBody(); /*eslint no-console: ["error", { allow: ["info"] }] */ const message = console.info.getCall(0).args[0]; assert.match(message, /GET http:\/\/www.example.com\/ 200 \d+ ms/); }); it('logs retry attempts as warnings when they return a critical error', async () => { nock.cleanAll(); sandbox.stub(console, 'info'); sandbox.stub(console, 'warn'); nockRetries(2); const client = HttpTransport.createBuilder() .use(toError()) .use(log()) .createClient(); await client .retry(2) .get(url) .asBody(); /*eslint no-console: ["error", { allow: ["info", "warn"] }] */ sinon.assert.calledOnce(console.warn); const intial = console.info.getCall(0).args[0]; const attempt1 = console.warn.getCall(0).args[0]; assert.match(intial, /GET http:\/\/www.example.com\/ 500 \d+ ms/); assert.match(attempt1, /Attempt 1 GET http:\/\/www.example.com\/ 500 \d+ ms/); }); }); }); });