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