@superhero/http-request 4.0.10 → 4.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -95,11 +95,12 @@ console.log(response.body)
95
95
 
96
96
  Errors are thrown with specific error codes for easier handling:
97
97
 
98
- - `E_HTTP_REQUEST_CLIENT_ERROR`
99
- - `E_HTTP_REQUEST_CLIENT_TIMEOUT`
100
- - `E_HTTP_REQUEST_DOWNSTREAM_ERROR`
101
- - `E_HTTP_REQUEST_INVALID_RESPONSE_BODY_FORMAT`
102
- - `E_HTTP_REQUEST_INVALID_RESPONSE_STATUS`
98
+ - `E_HTTP_REQUEST_FAILED`
99
+ - `E_HTTP_REQUEST_CLIENT_ERROR`
100
+ - `E_HTTP_REQUEST_CLIENT_TIMEOUT`
101
+ - `E_HTTP_REQUEST_DOWNSTREAM_ERROR`
102
+ - `E_HTTP_REQUEST_INVALID_RESPONSE_BODY_FORMAT`
103
+ - `E_HTTP_REQUEST_INVALID_RESPONSE_STATUS`
103
104
 
104
105
  Example:
105
106
 
@@ -334,10 +335,10 @@ pass 42
334
335
  ----------------------------------------------------------------------------------------
335
336
  file | line % | branch % | funcs % | uncovered lines
336
337
  ----------------------------------------------------------------------------------------
337
- index.js | 94.05 | 91.74 | 92.86 | 496-505 533-535 671-677 702-705 735-755
338
+ index.js | 89.56 | 87.39 | 86.67 | 100-104 137-139 150-153 353-357 361-365
338
339
  index.test.js | 100.00 | 97.67 | 100.00 |
339
340
  ----------------------------------------------------------------------------------------
340
- all files | 96.57 | 94.36 | 97.17 |
341
+ all files | 93.73 | 91.71 | 94.50 |
341
342
  ----------------------------------------------------------------------------------------
342
343
  ```
343
344
 
package/index.js CHANGED
@@ -68,8 +68,8 @@ export default class Request
68
68
  /**
69
69
  * Connects to a HTTP/2 server.
70
70
  *
71
- * @param {String} authority the URL
72
- * @param {Object} [options]
71
+ * @param {String|Object} [authority] the URL to connect to the server, or the options object
72
+ * @param {Object} [options] @see node:http2.connect options
73
73
  *
74
74
  * @throws {Error} E_HTTP_REQUEST_CONNECT_INVALID_ARGUMENT
75
75
  *
@@ -77,31 +77,28 @@ export default class Request
77
77
  */
78
78
  connect(authority, options)
79
79
  {
80
- return new Promise(async (accept, reject) =>
80
+ return new Promise(async resolve =>
81
81
  {
82
82
  await this.close()
83
83
 
84
- const url = new URL(authority || this.config.base)
85
- authority = url.protocol + '//' + url.host
86
- options = Object.assign(this.config, options)
84
+ if('object' === typeof authority
85
+ && null !== authority
86
+ && false === !!options)
87
+ {
88
+ options = authority
89
+ authority = null
90
+ }
91
+
92
+ options = Object.assign(this.config, options)
93
+ const url = new URL(authority || options.authority || options.base || options.url)
87
94
 
88
- this.config.base = authority
89
- this.config.url = url.pathname + url.search
90
- this.http2Session = http2.connect(authority, options, () =>
95
+ this.config.authority = url.origin
96
+ this.http2Session = http2.connect(url.origin, options, () =>
91
97
  {
92
98
  this.http2Session.removeAllListeners('error')
93
99
  this.http2Session.on('error', console.error)
94
100
 
95
- accept()
96
- })
97
-
98
- // If there is a error on connection, reject the promise.
99
- this.http2Session.once('error', (reason) =>
100
- {
101
- const error = new Error(`Failed to connect to server over HTTP2 using authority: ${authority}`)
102
- error.code = 'E_HTTP_REQUEST_CONNECT_ERROR'
103
- error.cause = reason
104
- reject(error)
101
+ resolve()
105
102
  })
106
103
  })
107
104
  }
@@ -113,7 +110,7 @@ export default class Request
113
110
  */
114
111
  close()
115
112
  {
116
- return new Promise((accept, reject) =>
113
+ return new Promise((resolve, reject) =>
117
114
  {
118
115
  const http2Session = this.http2Session
119
116
 
@@ -129,7 +126,7 @@ export default class Request
129
126
 
130
127
  error
131
128
  ? reject(error)
132
- : accept()
129
+ : resolve()
133
130
  })
134
131
 
135
132
  return // await the close event
@@ -138,8 +135,8 @@ export default class Request
138
135
  delete this.http2Session
139
136
  }
140
137
 
141
- // fallback to accept if nothing to close
142
- accept()
138
+ // fallback to resolve if nothing to close
139
+ resolve()
143
140
  })
144
141
  }
145
142
 
@@ -275,20 +272,13 @@ export default class Request
275
272
  * @throws {Error} E_HTTP_REQUEST_RETRY_HTTP2_RECONNECT
276
273
  * @throws {Error} E_HTTP_REQUEST_RETRY_ERROR
277
274
  */
278
- #fetch(method, options)
275
+ async #fetch(method, options)
279
276
  {
280
277
  if(typeof options === 'string')
281
278
  {
282
279
  options = { url:options }
283
280
  }
284
281
 
285
- if(this.config.url && options.url)
286
- {
287
- options.url = options.url[0] === '/'
288
- ? options.url
289
- : this.config.url + options.url
290
- }
291
-
292
282
  options = Object.assign(
293
283
  {
294
284
  method,
@@ -300,9 +290,19 @@ export default class Request
300
290
  retryOnStatus : []
301
291
  }, this.config, options)
302
292
 
303
- return options.retry
304
- ? this.#resolveRetryLoop(options)
305
- : this.#resolve(options)
293
+ try
294
+ {
295
+ return options.retry
296
+ ? await this.#resolveRetryLoop(options)
297
+ : await this.#resolve(options)
298
+ }
299
+ catch(reason)
300
+ {
301
+ const error = new Error(`Failed request ${method} ${options.url}`)
302
+ error.code = 'E_HTTP_REQUEST_FAILED'
303
+ error.cause = reason
304
+ throw error
305
+ }
306
306
  }
307
307
 
308
308
  /**
@@ -315,20 +315,20 @@ export default class Request
315
315
  */
316
316
  #resolve(options)
317
317
  {
318
- return new Promise((accept, reject) =>
318
+ return new Promise((resolve, reject) =>
319
319
  {
320
320
  const
321
321
  method = options.method,
322
322
  headers = this.#normalizeHeaders(options.headers),
323
323
  body = this.#normalizeBody(options.body ?? options.data, headers['content-type']),
324
324
  delimiter = this.#createBodyHeaderDelimiter(body, !!options.upstream || !!this.http2Session),
325
- url = this.#normalizeUrl(options.url, options.base)
325
+ url = this.#normalizeUrl(options.authority, options.base, options.url)
326
326
 
327
327
  Object.assign(headers, delimiter)
328
328
 
329
329
  const upstream = this.http2Session
330
- ? this.#resolveHttp2Client(options, method, headers, url, accept, reject)
331
- : this.#resolveHttp1Client(options, method, headers, url, accept, reject)
330
+ ? this.#resolveHttp2Client(options, method, headers, url, resolve, reject)
331
+ : this.#resolveHttp1Client(options, method, headers, url, resolve, reject)
332
332
 
333
333
  options.upstream
334
334
  ? options.upstream.pipe(upstream)
@@ -336,7 +336,7 @@ export default class Request
336
336
  })
337
337
  }
338
338
 
339
- #resolveHttp2Client(options, method, headers, url, accept, reject)
339
+ #resolveHttp2Client(options, method, headers, url, resolve, reject)
340
340
  {
341
341
  if(true === this.http2Session.destroyed)
342
342
  {
@@ -384,13 +384,13 @@ export default class Request
384
384
  delete upstream.headers[HEADER_STATUS]
385
385
  Object.defineProperty(upstream.headers, SENSITIVE_HEADERS, { enumerable:false, value:headers[SENSITIVE_HEADERS] })
386
386
 
387
- this.#resolveOnResponse(options, method, url, accept, reject, upstream)
387
+ this.#resolveOnResponse(options, method, url, resolve, reject, upstream)
388
388
  })
389
389
 
390
390
  return upstream
391
391
  }
392
392
 
393
- #resolveHttp1Client(options, method, headers, url, accept, reject)
393
+ #resolveHttp1Client(options, method, headers, url, resolve, reject)
394
394
  {
395
395
  const
396
396
  request = url.startsWith('https:') ? https.request : http.request,
@@ -400,7 +400,7 @@ export default class Request
400
400
  upstream.on('close', this.#connectionClosed .bind(this, upstream, reject))
401
401
  upstream.on('error', this.#resolveOnClientError .bind(this, method, url, reject))
402
402
  upstream.on('timeout', this.#resolveOnClientTimeout.bind(this, upstream, options.timeout, method, url, reject))
403
- upstream.on('response', this.#resolveOnResponse .bind(this, options, method, url, accept, reject))
403
+ upstream.on('response', this.#resolveOnResponse .bind(this, options, method, url, resolve, reject))
404
404
 
405
405
  return upstream
406
406
  }
@@ -419,13 +419,13 @@ export default class Request
419
419
  * @param {RequestOptions} options
420
420
  * @param {String} method
421
421
  * @param {String} url
422
- * @param {Function} accept Promise accept
422
+ * @param {Function} resolve Promise resolve
423
423
  * @param {Function} reject Promise reject
424
424
  * @param {http.IncomingMessage} readable
425
425
  *
426
426
  * @returns {Void}
427
427
  */
428
- #resolveOnResponse(options, method, url, accept, reject, readable)
428
+ #resolveOnResponse(options, method, url, resolve, reject, readable)
429
429
  {
430
430
  const response =
431
431
  {
@@ -448,11 +448,11 @@ export default class Request
448
448
  {
449
449
  readable.pipe(options.downstream)
450
450
  readable.resume()
451
- accept(response)
451
+ resolve(response)
452
452
  }
453
453
  else
454
454
  {
455
- this.#bufferResponseBody(readable, response, method, url, accept, reject)
455
+ this.#bufferResponseBody(readable, response, method, url, resolve, reject)
456
456
  }
457
457
  }
458
458
 
@@ -463,7 +463,7 @@ export default class Request
463
463
  */
464
464
  async #resolveRetryLoop(options)
465
465
  {
466
- const errorTrace = []
466
+ const reasons = []
467
467
 
468
468
  let retry = Math.abs(Math.floor(options.retry))
469
469
 
@@ -485,49 +485,48 @@ export default class Request
485
485
  return response
486
486
  }
487
487
  }
488
- catch(error)
488
+ catch(reason)
489
489
  {
490
- if(retry === 0)
490
+ if(retry <= 0)
491
491
  {
492
- throw error
492
+ reasons.push(reason)
493
493
  }
494
494
  else
495
495
  {
496
- await this.#resolveRetryLoopError(options, errorTrace, error)
496
+ await this.#resolveRetryLoopError(options, reasons, reason)
497
497
  await wait(options.retryDelay)
498
498
  }
499
499
  }
500
500
  }
501
501
 
502
- if(errorTrace.every((error) => error.code === errorTrace[0].code))
503
- {
504
- throw errorTrace.pop()
505
- }
506
- else
502
+ const
503
+ unique = reasons.filter((reason, i) => [i, -1].includes(reasons.map(e => e.code).lastIndexOf(code => code === reason.code))),
504
+ reason = unique.pop()
505
+
506
+ if(unique.length)
507
507
  {
508
- const error = new Error('Multiple types of errors occurred during the retry loop')
509
- error.code = 'E_HTTP_REQUEST_RETRY_ERROR'
510
- error.cause = errorTrace
511
- throw error
508
+ reason.retried = unique
512
509
  }
510
+
511
+ throw reason
513
512
  }
514
513
 
515
514
  /**
516
515
  * Resolves the error in the retry loop.
517
516
  *
518
517
  * @param {RequestOptions} options
519
- * @param {Error[]} errorTrace
520
- * @param {Error} error
518
+ * @param {Error[]} reasons
519
+ * @param {Error} reason
521
520
  *
522
521
  * @returns {Void}
523
522
  */
524
- async #resolveRetryLoopError(options, errorTrace, error)
523
+ async #resolveRetryLoopError(options, reasons, reason)
525
524
  {
526
- switch(error.code)
525
+ switch(reason.code)
527
526
  {
528
527
  case 'E_HTTP_REQUEST_CLIENT_ERROR':
529
528
  {
530
- return errorTrace.push(error)
529
+ return reasons.push(reason)
531
530
  }
532
531
  case 'E_HTTP_REQUEST_HTTP2_SESSION_DESTROYED':
533
532
  case 'E_HTTP_REQUEST_HTTP2_SESSION_CLOSED':
@@ -535,67 +534,68 @@ export default class Request
535
534
  try
536
535
  {
537
536
  await this.reconnect()
537
+ return reasons.push(reason)
538
538
  }
539
539
  catch(reason)
540
540
  {
541
- const reconnectError = new Error(`${reason.message}, retry to reconnect to the server failed`)
542
- reconnectError.code = 'E_HTTP_REQUEST_RETRY_HTTP2_RECONNECT'
543
- reconnectError.cause = reason
541
+ const error = new Error(`${reason.message}, retry to reconnect to the server failed`)
542
+ error.code = 'E_HTTP_REQUEST_RETRY_HTTP2_RECONNECT'
543
+ error.cause = reason
544
+ error.reasons = reasons
544
545
  throw reason
545
546
  }
546
- return errorTrace.push(error)
547
547
  }
548
548
  case 'E_HTTP_REQUEST_CLIENT_TIMEOUT':
549
549
  {
550
550
  if(options.retryOnClientTimeout)
551
551
  {
552
- return errorTrace.push(error)
552
+ return reasons.push(reason)
553
553
  }
554
554
  else
555
555
  {
556
- throw error
556
+ throw reason
557
557
  }
558
558
  }
559
559
  case 'E_HTTP_REQUEST_DOWNSTREAM_ERROR':
560
560
  {
561
561
  if(options.retryOnDownstreamError)
562
562
  {
563
- return errorTrace.push(error)
563
+ return reasons.push(reason)
564
564
  }
565
565
  else
566
566
  {
567
- throw error
567
+ throw reason
568
568
  }
569
569
  }
570
570
  case 'E_HTTP_REQUEST_INVALID_RESPONSE_BODY_FORMAT':
571
571
  {
572
572
  if(options.retryOnInvalidResponseBodyFormat)
573
573
  {
574
- return errorTrace.push(error)
574
+ return reasons.push(reason)
575
575
  }
576
576
  else
577
577
  {
578
- throw error
578
+ throw reason
579
579
  }
580
580
  }
581
581
  case 'E_HTTP_REQUEST_INVALID_RESPONSE_STATUS':
582
582
  {
583
583
  if(options.retryOnErrorResponseStatus)
584
584
  {
585
- return errorTrace.push(error)
585
+ return reasons.push(reason)
586
586
  }
587
- else if(options.retryOnStatus.includes(error.response.status))
587
+ else if(options.retryOnStatus.includes(reason.response.status))
588
588
  {
589
- return errorTrace.push(error)
589
+ return reasons.push(reason)
590
590
  }
591
591
  else
592
592
  {
593
- throw error
593
+ throw reason
594
594
  }
595
595
  }
596
596
  default:
597
597
  {
598
- throw error
598
+ throw reason
599
599
  }
600
600
  }
601
601
  }
@@ -649,17 +649,28 @@ export default class Request
649
649
 
650
650
  /**
651
651
  * Normalizes the URL.
652
+ * One of authority or base must be provided,
652
653
  *
653
- * @param {String} url
654
- * @param {String} base
654
+ * @param {String} [base]
655
+ * @param {String} [authority] the URL origin (e.g. https://example.com)
656
+ * @param {String} [url]
655
657
  *
656
658
  * @returns {String} Normalized URL
657
659
  */
658
- #normalizeUrl(url, base)
660
+ #normalizeUrl(authority, base, url)
659
661
  {
662
+ let baseURL = authority && base
663
+ ? new URL(base, authority)
664
+ : new URL(base || authority)
665
+
666
+ if(url && false === baseURL.pathname.endsWith('/'))
667
+ {
668
+ baseURL = new URL(baseURL.pathname + '/', baseURL.href)
669
+ }
670
+
660
671
  return url
661
- ? new URL(url, base).toString()
662
- : new URL(base).toString()
672
+ ? new URL(url, baseURL.href).href
673
+ : baseURL.href
663
674
  }
664
675
 
665
676
  /**
@@ -710,12 +721,12 @@ export default class Request
710
721
  upstream.destroy(error)
711
722
  }
712
723
 
713
- #bufferResponseBody(readable, response, method, url, accept, reject)
724
+ #bufferResponseBody(readable, response, method, url, resolve, reject)
714
725
  {
715
726
  response.body = ''
716
727
  readable.on('data', (chunk) => response.body += chunk)
717
728
  readable.on('error', this.#onStreamError.bind(this, method, url, response))
718
- readable.on('end', this.#onStreamEnd .bind(this, method, url, response, accept, reject))
729
+ readable.on('end', this.#onStreamEnd .bind(this, method, url, response, resolve, reject))
719
730
  readable.resume()
720
731
  }
721
732
 
@@ -746,14 +757,14 @@ export default class Request
746
757
  * @param {String} method
747
758
  * @param {String} url
748
759
  * @param {http.IncomingMessage} response
749
- * @param {Function} accept promise accept
760
+ * @param {Function} resolve promise resolve
750
761
  * @param {Function} reject promise reject
751
762
  *
752
763
  * @returns {void}
753
764
  *
754
765
  * @throws {Error} E_HTTP_REQUEST_INVALID_RESPONSE_BODY_FORMAT
755
766
  */
756
- #onStreamEnd(method, url, response, accept, reject)
767
+ #onStreamEnd(method, url, response, resolve, reject)
757
768
  {
758
769
  try
759
770
  {
@@ -778,7 +789,7 @@ export default class Request
778
789
  return
779
790
  }
780
791
 
781
- accept(response)
792
+ resolve(response)
782
793
  }
783
794
 
784
795
  #contentTypeApplicationJson(body)
package/index.test.js CHANGED
@@ -53,6 +53,50 @@ suite('@superhero/http-request', () =>
53
53
  assert.equal(response.body.url, '/', 'Should result to /')
54
54
  })
55
55
 
56
+ test('Request using a relative path', async sub =>
57
+ {
58
+ // ...close the server that was started before the
59
+ // sub tests starts another server for each test...
60
+ await request.close()
61
+ await new Promise(resolve => server.close(resolve))
62
+
63
+ await sub.test('Request using an absolute path with a trailing base path slash', async () =>
64
+ {
65
+ const base = request.config.base || ''
66
+ request.config.base = base + '/path/'
67
+ const response = await request.get('/foobar')
68
+ assert.equal(response.status, 200, 'Should return a 200 status code')
69
+ assert.equal(response.body.url, '/foobar', 'Should result to the absolute path')
70
+ })
71
+
72
+ await sub.test('Request using a absolute path with out a trailing base path slash', async () =>
73
+ {
74
+ const base = request.config.base || ''
75
+ request.config.base = base + '/path'
76
+ const response = await request.get('/foobar')
77
+ assert.equal(response.status, 200, 'Should return a 200 status code')
78
+ assert.equal(response.body.url, '/foobar', 'Should result to the absolute path')
79
+ })
80
+
81
+ await sub.test('Request using a relative path with a trailing base path slash', async () =>
82
+ {
83
+ const base = request.config.base || ''
84
+ request.config.base = base + '/path/'
85
+ const response = await request.get('foobar')
86
+ assert.equal(response.status, 200, 'Should return a 200 status code')
87
+ assert.equal(response.body.url, '/path/foobar', 'Should result to the absolute path')
88
+ })
89
+
90
+ await sub.test('Request using a relative path with out a trailing base path slash', async () =>
91
+ {
92
+ const base = request.config.base || ''
93
+ request.config.base = base + '/path'
94
+ const response = await request.get('foobar')
95
+ assert.equal(response.status, 200, 'Should return a 200 status code')
96
+ assert.equal(response.body.url, '/path/foobar', 'Should result to the absolute path')
97
+ })
98
+ })
99
+
56
100
  test('Request using a string body', async () =>
57
101
  {
58
102
  const response = await request.post({ body: 'test body' })
@@ -199,10 +243,10 @@ suite('@superhero/http-request', () =>
199
243
  }
200
244
  await assert.rejects(
201
245
  request.get(options),
202
- (error) => error.code === 'E_HTTP_REQUEST_CLIENT_TIMEOUT',
246
+ (error) => error.cause.code === 'E_HTTP_REQUEST_CLIENT_TIMEOUT',
203
247
  'Should throw a timeout error')
204
248
  })
205
-
249
+
206
250
  test('Rejects invalid JSON response accurately', async () =>
207
251
  {
208
252
  // Alter the server to respond with invalid JSON
@@ -216,7 +260,7 @@ suite('@superhero/http-request', () =>
216
260
  // Make the request
217
261
  await assert.rejects(
218
262
  request.get({ url: '/invalid-json' }),
219
- (error) => error.code === 'E_HTTP_REQUEST_INVALID_RESPONSE_BODY_FORMAT',
263
+ (error) => error.cause.code === 'E_HTTP_REQUEST_INVALID_RESPONSE_BODY_FORMAT',
220
264
  'Should throw a parse error')
221
265
  })
222
266
 
@@ -383,7 +427,7 @@ suite('@superhero/http-request', () =>
383
427
 
384
428
  await assert.rejects(
385
429
  request.get(options),
386
- { code:'E_HTTP_REQUEST_INVALID_RESPONSE_STATUS' },
430
+ (error) => error.cause.code === 'E_HTTP_REQUEST_INVALID_RESPONSE_STATUS',
387
431
  'Should throw an error after the second attempt')
388
432
 
389
433
  assert.equal(attempt, 2, 'Should make exactly 2 attempts')
@@ -401,7 +445,7 @@ suite('@superhero/http-request', () =>
401
445
 
402
446
  await assert.rejects(
403
447
  request.get(options),
404
- (error) => error.code === 'E_HTTP_REQUEST_INVALID_RESPONSE_STATUS',
448
+ (error) => error.cause.code === 'E_HTTP_REQUEST_INVALID_RESPONSE_STATUS',
405
449
  'Should throw an error right away')
406
450
 
407
451
  assert.equal(attempt, 1, 'Should make exactly 1 attempt')
@@ -503,7 +547,7 @@ suite('@superhero/http-request', () =>
503
547
  })
504
548
  })
505
549
 
506
- afterEach((done) => request.close().then(() => server.close(done)))
550
+ afterEach(done => request.close().then(() => server.close(done)))
507
551
 
508
552
  executeTheSameTestSuitFor_http1_http2()
509
553
 
@@ -547,7 +591,7 @@ suite('@superhero/http-request', () =>
547
591
 
548
592
  await assert.rejects(
549
593
  request.get({ url: '/timeout-test', timeout: 100 }),
550
- (error) => error.code === 'E_HTTP_REQUEST_CLIENT_TIMEOUT',
594
+ (error) => error.cause.code === 'E_HTTP_REQUEST_CLIENT_TIMEOUT',
551
595
  'Should throw a timeout error')
552
596
  })
553
597
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@superhero/http-request",
3
- "version": "4.0.10",
3
+ "version": "4.1.1",
4
4
  "description": "HTTP(S) request component supporting HTTP 1.1 and HTTP 2.0",
5
5
  "keywords": [
6
6
  "http request",