1 | 'use strict';
|
2 |
|
3 | const assert = require('chai').assert;
|
4 | const nock = require('nock');
|
5 | const sinon = require('sinon');
|
6 |
|
7 | const HttpTransport = require('..');
|
8 | const Transport = require('../lib/transport/transport');
|
9 | const toJson = require('../lib/middleware/asJson');
|
10 | const setContextProperty = require('../lib/middleware/setContextProperty');
|
11 | const log = require('../lib/middleware/logger');
|
12 | const packageInfo = require('../package');
|
13 | const toError = require('./toError');
|
14 | const sandbox = sinon.sandbox.create();
|
15 |
|
16 | const url = 'http://www.example.com/';
|
17 | const host = 'http://www.example.com';
|
18 | const api = nock(host);
|
19 | const path = '/';
|
20 |
|
21 | const simpleResponseBody = { blobbus: 'Illegitimi non carborundum' };
|
22 | const requestBody = {
|
23 | foo: 'bar'
|
24 | };
|
25 | const responseBody = requestBody;
|
26 | const defaultHeaders = { 'Content-Type': 'application/json' };
|
27 |
|
28 | function toUpperCase() {
|
29 | return async (ctx, next) => {
|
30 | await next();
|
31 | ctx.res.body.blobbus = ctx.res.body.blobbus.toUpperCase();
|
32 | };
|
33 | }
|
34 |
|
35 | function nockRetries(retry, opts) {
|
36 | const httpMethod = opts?.httpMethod || 'get';
|
37 | const successCode = opts?.successCode || 200;
|
38 |
|
39 | nock.cleanAll();
|
40 | api[httpMethod](path)
|
41 | .times(retry)
|
42 | .reply(500);
|
43 | api[httpMethod](path).reply(successCode, simpleResponseBody, defaultHeaders);
|
44 | }
|
45 |
|
46 | function nockTimeouts(number, opts) {
|
47 | const httpMethod = opts?.httpMethod || 'get';
|
48 | const successCode = opts?.successCode || 200;
|
49 |
|
50 | nock.cleanAll();
|
51 | api[httpMethod](path)
|
52 | .times(number)
|
53 | .delay(10000)
|
54 | .reply(200);
|
55 | api[httpMethod](path).reply(successCode, simpleResponseBody, defaultHeaders);
|
56 | }
|
57 |
|
58 | describe('HttpTransportClient', () => {
|
59 | beforeEach(() => {
|
60 | nock.disableNetConnect();
|
61 | nock.cleanAll();
|
62 | api.get(path).reply(200, simpleResponseBody, defaultHeaders);
|
63 | });
|
64 |
|
65 | afterEach(() => {
|
66 | sandbox.restore();
|
67 | });
|
68 |
|
69 | describe('.get', () => {
|
70 | it('returns a response', async () => {
|
71 | const res = await HttpTransport.createClient()
|
72 | .get(url)
|
73 | .asResponse();
|
74 |
|
75 | assert.deepEqual(res.body, simpleResponseBody);
|
76 | });
|
77 |
|
78 | it('handles an empty response body when parsing as JSON', async () => {
|
79 | nock.cleanAll();
|
80 | api.get(path).reply(200, undefined, defaultHeaders);
|
81 |
|
82 | const res = await HttpTransport.createClient()
|
83 | .get(url)
|
84 | .asResponse();
|
85 |
|
86 | assert.deepEqual(res.body, '');
|
87 | });
|
88 |
|
89 | it('sets a default User-agent for every request', async () => {
|
90 | nock.cleanAll();
|
91 |
|
92 | nock(host, {
|
93 | reqheaders: {
|
94 | 'User-Agent': `${packageInfo.name}/${packageInfo.version}`
|
95 | }
|
96 | })
|
97 | .get(path)
|
98 | .times(2)
|
99 | .reply(200, responseBody);
|
100 |
|
101 | const client = HttpTransport.createClient();
|
102 | await client.get(url).asResponse();
|
103 | });
|
104 |
|
105 | it('overrides the default User-agent for every request', async () => {
|
106 | nock.cleanAll();
|
107 |
|
108 | nock(host, {
|
109 | reqheaders: {
|
110 | 'User-Agent': 'some-new-user-agent'
|
111 | }
|
112 | })
|
113 | .get(path)
|
114 | .times(2)
|
115 | .reply(200, responseBody);
|
116 |
|
117 | const client = HttpTransport.createBuilder()
|
118 | .userAgent('some-new-user-agent')
|
119 | .createClient();
|
120 |
|
121 | await client.get(url).asResponse();
|
122 | });
|
123 | });
|
124 |
|
125 | describe('default', () => {
|
126 | it('sets default retry values in the context', async () => {
|
127 | const transport = new Transport();
|
128 | sandbox.stub(transport, 'execute').returns(Promise.resolve());
|
129 |
|
130 | const client = HttpTransport.createBuilder(transport)
|
131 | .retries(50)
|
132 | .retryDelay(2000)
|
133 | .createClient();
|
134 |
|
135 | await client
|
136 | .get(url)
|
137 | .asResponse();
|
138 |
|
139 | const ctx = transport.execute.getCall(0).args[0];
|
140 | assert.equal(ctx.retries, 50);
|
141 | assert.equal(ctx.retryDelay, 2000);
|
142 | });
|
143 | });
|
144 |
|
145 | describe('.retries', () => {
|
146 | it('retries a given number of times for failed requests', async () => {
|
147 | nockRetries(2);
|
148 |
|
149 | const client = HttpTransport.createBuilder()
|
150 | .use(toError())
|
151 | .createClient();
|
152 |
|
153 | const res = await client
|
154 | .get(url)
|
155 | .retry(2)
|
156 | .asResponse();
|
157 |
|
158 | assert.equal(res.statusCode, 200);
|
159 | });
|
160 |
|
161 | it('retries a given number of times for requests that timed out', async () => {
|
162 | nockTimeouts(2);
|
163 |
|
164 | const client = HttpTransport.createBuilder()
|
165 | .use(toError())
|
166 | .createClient();
|
167 |
|
168 | const res = await client
|
169 | .get(url)
|
170 | .timeout(500)
|
171 | .retry(2)
|
172 | .asResponse();
|
173 |
|
174 | assert.equal(res.statusCode, 200);
|
175 | });
|
176 |
|
177 | it('waits a minimum of 100ms between retries by default', async () => {
|
178 | nockRetries(1);
|
179 | const startTime = Date.now();
|
180 |
|
181 | const client = HttpTransport.createBuilder()
|
182 | .use(toError())
|
183 | .createClient();
|
184 |
|
185 | const res = await client
|
186 | .get(url)
|
187 | .retry(2)
|
188 | .asResponse();
|
189 |
|
190 | const timeTaken = Date.now() - startTime;
|
191 | assert(timeTaken > 100);
|
192 | assert.equal(res.statusCode, 200);
|
193 | });
|
194 |
|
195 | it('disables retryDelay if retries if set to zero', async () => {
|
196 | nock.cleanAll();
|
197 | api.get(path).reply(500);
|
198 |
|
199 | const client = HttpTransport.createBuilder()
|
200 | .use(toError())
|
201 | .createClient();
|
202 |
|
203 | try {
|
204 | await client
|
205 | .get(url)
|
206 | .retry(0)
|
207 | .retryDelay(10000)
|
208 | .asResponse();
|
209 | } catch (e) {
|
210 | return assert.equal(e.message, 'something bad happened.');
|
211 | }
|
212 |
|
213 | assert.fail('Should have thrown');
|
214 | });
|
215 |
|
216 | it('overrides the minimum wait time between retries', async () => {
|
217 | nockRetries(1);
|
218 | const retryDelay = 200;
|
219 | const startTime = Date.now();
|
220 |
|
221 | const client = HttpTransport.createBuilder()
|
222 | .use(toError())
|
223 | .createClient();
|
224 |
|
225 | const res = await client
|
226 | .get(url)
|
227 | .retry(1)
|
228 | .retryDelay(retryDelay)
|
229 | .asResponse();
|
230 |
|
231 | const timeTaken = Date.now() - startTime;
|
232 | assert(timeTaken > retryDelay);
|
233 | assert.equal(res.statusCode, 200);
|
234 | });
|
235 |
|
236 | it('does not retry 4XX errors', async () => {
|
237 | nock.cleanAll();
|
238 | api
|
239 | .get(path)
|
240 | .once()
|
241 | .reply(400);
|
242 |
|
243 | const client = HttpTransport.createBuilder()
|
244 | .use(toError())
|
245 | .createClient();
|
246 |
|
247 | try {
|
248 | await client
|
249 | .get(url)
|
250 | .retry(1)
|
251 | .asResponse();
|
252 | } catch (err) {
|
253 | return assert.equal(err.statusCode, 400);
|
254 | }
|
255 | assert.fail('Should have thrown');
|
256 | });
|
257 | });
|
258 |
|
259 | describe('.post', () => {
|
260 | it('makes a POST request', async () => {
|
261 | api.post(path, requestBody).reply(201, responseBody, { 'content-type': 'application/json' });
|
262 |
|
263 | const body = await HttpTransport.createClient()
|
264 | .post(url, requestBody)
|
265 | .asBody();
|
266 |
|
267 | assert.deepEqual(body, responseBody);
|
268 | });
|
269 |
|
270 | it('returns an error when the API returns a 5XX statusCode code', async () => {
|
271 | api.post(path, requestBody).reply(500);
|
272 |
|
273 | try {
|
274 | await HttpTransport.createClient()
|
275 | .use(toError())
|
276 | .post(url, requestBody)
|
277 | .asResponse();
|
278 | } catch (err) {
|
279 | return assert.equal(err.statusCode, 500);
|
280 | }
|
281 |
|
282 | assert.fail('Should have thrown');
|
283 | });
|
284 | });
|
285 |
|
286 | describe('.put', () => {
|
287 | it('makes a PUT request with a JSON body', async () => {
|
288 | api.put(path, requestBody).reply(201, responseBody, { 'content-type': 'application/json' });
|
289 |
|
290 | const body = await HttpTransport.createClient()
|
291 | .put(url, requestBody)
|
292 | .asBody();
|
293 |
|
294 | assert.deepEqual(body, responseBody);
|
295 | });
|
296 |
|
297 | it('returns an error when the API returns a 5XX statusCode code', async () => {
|
298 | api.put(path, requestBody).reply(500);
|
299 |
|
300 | try {
|
301 | await HttpTransport.createClient()
|
302 | .use(toError())
|
303 | .put(url, requestBody)
|
304 | .asResponse();
|
305 | } catch (err) {
|
306 | return assert.equal(err.statusCode, 500);
|
307 | }
|
308 |
|
309 | assert.fail('Should have thrown');
|
310 | });
|
311 | });
|
312 |
|
313 | describe('.delete', () => {
|
314 | it('makes a DELETE request', () => {
|
315 | api.delete(path).reply(204);
|
316 | return HttpTransport.createClient().delete(url);
|
317 | });
|
318 |
|
319 | it('returns an error when the API returns a 5XX statusCode code', async () => {
|
320 | api.delete(path).reply(500);
|
321 |
|
322 | try {
|
323 | await HttpTransport.createClient()
|
324 | .use(toError())
|
325 | .delete(url)
|
326 | .asResponse();
|
327 | } catch (err) {
|
328 | return assert.equal(err.statusCode, 500);
|
329 | }
|
330 |
|
331 | assert.fail('Should have thrown');
|
332 | });
|
333 | });
|
334 |
|
335 | describe('.patch', () => {
|
336 | it('makes a PATCH request', async () => {
|
337 | api.patch(path).reply(204, simpleResponseBody);
|
338 | await HttpTransport.createClient()
|
339 | .patch(url)
|
340 | .asResponse();
|
341 | });
|
342 |
|
343 | it('returns an error when the API returns a 5XX statusCode code', async () => {
|
344 | api.patch(path, requestBody).reply(500);
|
345 |
|
346 | try {
|
347 | await HttpTransport.createClient()
|
348 | .use(toError())
|
349 | .patch(url, requestBody)
|
350 | .asResponse();
|
351 | } catch (err) {
|
352 | return assert.equal(err.statusCode, 500);
|
353 | }
|
354 | assert.fail('Should have thrown');
|
355 | });
|
356 | });
|
357 |
|
358 | describe('.head', () => {
|
359 | it('makes a HEAD request', async () => {
|
360 | api.head(path).reply(200, simpleResponseBody);
|
361 |
|
362 | const res = await HttpTransport.createClient()
|
363 | .head(url)
|
364 | .asResponse();
|
365 |
|
366 | assert.strictEqual(res.statusCode, 200);
|
367 | });
|
368 |
|
369 | it('returns an error when the API returns a 5XX statusCode code', async () => {
|
370 | api.head(path).reply(500);
|
371 |
|
372 | try {
|
373 | await HttpTransport.createClient()
|
374 | .use(toError())
|
375 | .head(url)
|
376 | .asResponse();
|
377 | } catch (err) {
|
378 | return assert.strictEqual(err.statusCode, 500);
|
379 | }
|
380 | assert.fail('Should have thrown');
|
381 | });
|
382 | });
|
383 |
|
384 | describe('.headers', () => {
|
385 | it('sends a custom headers', async () => {
|
386 | nock.cleanAll();
|
387 |
|
388 | const HeaderValue = `${packageInfo.name}/${packageInfo.version}`;
|
389 | nock(host, {
|
390 | reqheaders: {
|
391 | 'User-Agent': HeaderValue,
|
392 | foo: 'bar'
|
393 | }
|
394 | })
|
395 | .get(path)
|
396 | .reply(200, responseBody);
|
397 |
|
398 | const res = await HttpTransport.createClient()
|
399 | .get(url)
|
400 | .headers({
|
401 | 'User-Agent': HeaderValue,
|
402 | foo: 'bar'
|
403 | })
|
404 | .asResponse();
|
405 |
|
406 | assert.equal(res.statusCode, 200);
|
407 | });
|
408 |
|
409 | it('ignores an empty header object', async () => {
|
410 | nock.cleanAll();
|
411 | api.get(path).reply(200, simpleResponseBody, { 'content-type': 'application/json' });
|
412 |
|
413 | const res = await HttpTransport.createClient()
|
414 | .headers({})
|
415 | .get(url)
|
416 | .asResponse();
|
417 |
|
418 | assert.deepEqual(res.body, simpleResponseBody);
|
419 | });
|
420 | });
|
421 |
|
422 | describe('query strings', () => {
|
423 | it('supports adding a query string', async () => {
|
424 | api.get('/?a=1').reply(200, simpleResponseBody, { 'content-type': 'application/json' });
|
425 |
|
426 | const body = await HttpTransport.createClient()
|
427 | .get(url)
|
428 | .query('a', 1)
|
429 | .asBody();
|
430 |
|
431 | assert.deepEqual(body, simpleResponseBody);
|
432 | });
|
433 |
|
434 | it('supports multiple query strings', async () => {
|
435 | nock.cleanAll();
|
436 | api.get('/?a=1&b=2&c=3').reply(200, simpleResponseBody, { 'content-type': 'application/json' });
|
437 |
|
438 | const body = await HttpTransport.createClient()
|
439 | .get(url)
|
440 | .query({
|
441 | a: 1,
|
442 | b: 2,
|
443 | c: 3
|
444 | })
|
445 | .asBody();
|
446 |
|
447 | assert.deepEqual(body, simpleResponseBody);
|
448 | });
|
449 |
|
450 | it('ignores empty query objects', async () => {
|
451 | const res = await HttpTransport.createClient()
|
452 | .query({})
|
453 | .get(url)
|
454 | .asResponse();
|
455 |
|
456 | assert.deepEqual(res.body, simpleResponseBody);
|
457 | });
|
458 | });
|
459 |
|
460 | describe('.timeout', () => {
|
461 | it('sets the a timeout', async () => {
|
462 | nock.cleanAll();
|
463 | api
|
464 | .get('/')
|
465 | .delay(1000)
|
466 | .reply(200, simpleResponseBody);
|
467 |
|
468 | try {
|
469 | await HttpTransport.createClient()
|
470 | .get(url)
|
471 | .timeout(20)
|
472 | .asBody();
|
473 | } catch (err) {
|
474 | return assert.equal(err.message, 'Request failed for GET http://www.example.com/: ESOCKETTIMEDOUT');
|
475 | }
|
476 | assert.fail('Should have thrown');
|
477 | });
|
478 | });
|
479 |
|
480 | describe('plugins', () => {
|
481 | it('supports a per request plugin', async () => {
|
482 | nock.cleanAll();
|
483 | api
|
484 | .get(path)
|
485 | .times(2)
|
486 | .reply(200, simpleResponseBody);
|
487 |
|
488 | const client = HttpTransport.createClient();
|
489 |
|
490 | const upperCaseResponse = await client
|
491 | .use(toUpperCase())
|
492 | .get(url)
|
493 | .asBody();
|
494 |
|
495 | const lowerCaseResponse = await client
|
496 | .get(url)
|
497 | .asBody();
|
498 |
|
499 | assert.equal(upperCaseResponse.blobbus, 'ILLEGITIMI NON CARBORUNDUM');
|
500 | assert.equal(lowerCaseResponse.blobbus, 'Illegitimi non carborundum');
|
501 | });
|
502 |
|
503 | it('executes global and per request plugins', async () => {
|
504 | nock.cleanAll();
|
505 | api.get(path).reply(200, simpleResponseBody);
|
506 |
|
507 | function appendTagGlobally() {
|
508 | return async (ctx, next) => {
|
509 | await next();
|
510 | ctx.res.body = 'global ' + ctx.res.body;
|
511 | };
|
512 | }
|
513 |
|
514 | function appendTagPerRequestTag() {
|
515 | return async (ctx, next) => {
|
516 | await next();
|
517 | ctx.res.body = 'request';
|
518 | };
|
519 | }
|
520 |
|
521 | const client = HttpTransport.createBuilder()
|
522 | .use(appendTagGlobally())
|
523 | .createClient();
|
524 |
|
525 | const body = await client
|
526 | .use(appendTagPerRequestTag())
|
527 | .get(url)
|
528 | .asBody();
|
529 |
|
530 | assert.equal(body, 'global request');
|
531 | });
|
532 |
|
533 | it('throws if a global plugin is not a function', () => {
|
534 | assert.throws(
|
535 | () => {
|
536 | HttpTransport.createBuilder().use('bad plugin');
|
537 | },
|
538 | TypeError,
|
539 | 'Plugin is not a function'
|
540 | );
|
541 | });
|
542 |
|
543 | it('throws if a per request plugin is not a function', () => {
|
544 | assert.throws(
|
545 | () => {
|
546 | const client = HttpTransport.createClient();
|
547 | client.use('bad plugin').get(url);
|
548 | },
|
549 | TypeError,
|
550 | 'Plugin is not a function'
|
551 | );
|
552 | });
|
553 |
|
554 | describe('setContextProperty', () => {
|
555 | it('sets an option in the context', async () => {
|
556 | nock.cleanAll();
|
557 | api.get(path).reply(200, responseBody);
|
558 |
|
559 | const client = HttpTransport.createBuilder()
|
560 | .use(toJson())
|
561 | .createClient();
|
562 |
|
563 | const res = await client
|
564 | .use(setContextProperty({
|
565 | time: false
|
566 | },
|
567 | 'opts'
|
568 | ))
|
569 | .get(url)
|
570 | .asResponse();
|
571 |
|
572 | assert.isUndefined(res.elapsedTime);
|
573 | });
|
574 |
|
575 | it('sets an explict key on the context', async () => {
|
576 | nock.cleanAll();
|
577 | api
|
578 | .get(path)
|
579 | .delay(1000)
|
580 | .reply(200, responseBody);
|
581 |
|
582 | const client = HttpTransport.createBuilder()
|
583 | .use(toJson())
|
584 | .createClient();
|
585 |
|
586 | try {
|
587 | await client
|
588 | .use(setContextProperty(20, 'req._timeout'))
|
589 | .get(url)
|
590 | .asResponse();
|
591 | } catch (err) {
|
592 | return assert.equal(err.message, 'Request failed for GET http://www.example.com/: ESOCKETTIMEDOUT');
|
593 | }
|
594 | assert.fail('Should have thrown');
|
595 | });
|
596 | });
|
597 |
|
598 | describe('toJson', () => {
|
599 | it('returns body of a JSON response', async () => {
|
600 | nock.cleanAll();
|
601 | api.get(path).reply(200, responseBody, defaultHeaders);
|
602 |
|
603 | const client = HttpTransport.createBuilder()
|
604 | .use(toJson())
|
605 | .createClient();
|
606 |
|
607 | const body = await client
|
608 | .get(url)
|
609 | .asBody();
|
610 |
|
611 | assert.equal(body.foo, 'bar');
|
612 | });
|
613 | });
|
614 |
|
615 | describe('logging', () => {
|
616 | it('logs each request at info level when a logger is passed in', async () => {
|
617 | api.get(path).reply(200);
|
618 |
|
619 | const stubbedLogger = {
|
620 | info: sandbox.stub(),
|
621 | warn: sandbox.stub()
|
622 | };
|
623 |
|
624 | const client = HttpTransport.createBuilder()
|
625 | .use(log(stubbedLogger))
|
626 | .createClient();
|
627 |
|
628 | await client
|
629 | .get(url)
|
630 | .asBody();
|
631 |
|
632 | const message = stubbedLogger.info.getCall(0).args[0];
|
633 | assert.match(message, /GET http:\/\/www.example.com\/ 200 \d+ ms/);
|
634 | });
|
635 |
|
636 | it('uses default logger', async () => {
|
637 | sandbox.stub(console, 'info');
|
638 |
|
639 | const client = HttpTransport.createBuilder()
|
640 | .use(log())
|
641 | .createClient();
|
642 |
|
643 | await client
|
644 | .get(url)
|
645 | .asBody();
|
646 |
|
647 |
|
648 | const message = console.info.getCall(0).args[0];
|
649 | assert.match(message, /GET http:\/\/www.example.com\/ 200 \d+ ms/);
|
650 | });
|
651 |
|
652 | it('logs retry attempts as warnings when they return a critical error', async () => {
|
653 | nock.cleanAll();
|
654 | sandbox.stub(console, 'info');
|
655 | sandbox.stub(console, 'warn');
|
656 | nockRetries(2);
|
657 |
|
658 | const client = HttpTransport.createBuilder()
|
659 | .use(toError())
|
660 | .use(log())
|
661 | .createClient();
|
662 |
|
663 | await client
|
664 | .retry(2)
|
665 | .get(url)
|
666 | .asBody();
|
667 |
|
668 |
|
669 | sinon.assert.calledOnce(console.warn);
|
670 | const intial = console.info.getCall(0).args[0];
|
671 | const attempt1 = console.warn.getCall(0).args[0];
|
672 | assert.match(intial, /GET http:\/\/www.example.com\/ 500 \d+ ms/);
|
673 | assert.match(attempt1, /Attempt 1 GET http:\/\/www.example.com\/ 500 \d+ ms/);
|
674 | });
|
675 | });
|
676 | });
|
677 | });
|