@superhero/http-request 4.1.0 → 4.1.2

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.
Files changed (3) hide show
  1. package/index.js +110 -82
  2. package/index.test.js +45 -1
  3. package/package.json +5 -2
package/index.js CHANGED
@@ -18,8 +18,9 @@ import { setTimeout as wait } from 'node:timers/promises'
18
18
  /**
19
19
  * @typedef {Object} RequestOptions
20
20
  *
21
- * @property {String} url - The URL to make the request to.
22
- * @property {String} base - The base path to resolve the URL against.
21
+ * @property {String} [method] - The HTTP method to use.
22
+ * @property {String} url - The URL to make the request to.
23
+ * @property {String} base - The base path to resolve the URL against.
23
24
  * @property {Object.<String, String>} [headers] - The request headers.
24
25
  * @property {Object|String} [body] - The request body.
25
26
  * @property {Object|String} [data] - Alias for body.
@@ -35,7 +36,6 @@ import { setTimeout as wait } from 'node:timers/promises'
35
36
  * @property {Boolean} [doNotThrowOnRedirectStatus] - Set to true to avoid throwing on redirect status.
36
37
  * @property {Stream.Readable>} [upstream] - An optional upstream stream to make it possible to pipe body to the upstream directly.
37
38
  * @property {Stream.Writable} [downstream] - An optional downstream stream to make it possible to pipe body from the downstream to.
38
- * @property {String} [method] - The HTTP method to use.
39
39
  *
40
40
  * @see {@link https://nodejs.org/api/http.html#httprequestoptions-callback}
41
41
  * @see {@link https://nodejs.org/api/https.html#httpsrequestoptions-callback}
@@ -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
 
@@ -162,7 +159,9 @@ export default class Request
162
159
  */
163
160
  get(options)
164
161
  {
165
- return this.#fetch('GET', options)
162
+ options = this.#normalizeOptions(options)
163
+ options.method = 'GET'
164
+ return this.fetch(options)
166
165
  }
167
166
 
168
167
  /**
@@ -175,7 +174,9 @@ export default class Request
175
174
  */
176
175
  post(options)
177
176
  {
178
- return this.#fetch('POST', options)
177
+ options = this.#normalizeOptions(options)
178
+ options.method = 'POST'
179
+ return this.fetch(options)
179
180
  }
180
181
 
181
182
  /**
@@ -188,7 +189,9 @@ export default class Request
188
189
  */
189
190
  put(options)
190
191
  {
191
- return this.#fetch('PUT', options)
192
+ options = this.#normalizeOptions(options)
193
+ options.method = 'PUT'
194
+ return this.fetch(options)
192
195
  }
193
196
 
194
197
  /**
@@ -201,7 +204,9 @@ export default class Request
201
204
  */
202
205
  patch(options)
203
206
  {
204
- return this.#fetch('PATCH', options)
207
+ options = this.#normalizeOptions(options)
208
+ options.method = 'PATCH'
209
+ return this.fetch(options)
205
210
  }
206
211
 
207
212
  /**
@@ -213,7 +218,9 @@ export default class Request
213
218
  */
214
219
  delete(options)
215
220
  {
216
- return this.#fetch('DELETE', options)
221
+ options = this.#normalizeOptions(options)
222
+ options.method = 'DELETE'
223
+ return this.fetch(options)
217
224
  }
218
225
 
219
226
  /**
@@ -226,7 +233,9 @@ export default class Request
226
233
  */
227
234
  head(options)
228
235
  {
229
- return this.#fetch('HEAD', options)
236
+ options = this.#normalizeOptions(options)
237
+ options.method = 'HEAD'
238
+ return this.fetch(options)
230
239
  }
231
240
 
232
241
  /**
@@ -239,7 +248,9 @@ export default class Request
239
248
  */
240
249
  options(options)
241
250
  {
242
- return this.#fetch('OPTIONS', options)
251
+ options = this.#normalizeOptions(options)
252
+ options.method = 'OPTIONS'
253
+ return this.fetch(options)
243
254
  }
244
255
 
245
256
  /**
@@ -254,17 +265,19 @@ export default class Request
254
265
  */
255
266
  trace(options)
256
267
  {
257
- return this.#fetch('TRACE', options)
268
+ options = this.#normalizeOptions(options)
269
+ options.method = 'TRACE'
270
+ return this.fetch(options)
258
271
  }
259
272
 
260
273
  /**
261
274
  * Generic fetch method.
262
275
  *
263
- * @param {string} method
264
276
  * @param {RequestOptions} options
265
277
  *
266
278
  * @returns {RequestResponse}
267
279
  *
280
+ * @throws {TypeError} E_HTTP_REQUEST_INVALID_METHOD
268
281
  * @throws {Error} E_HTTP_REQUEST_CLIENT_ERROR
269
282
  * @throws {Error} E_HTTP_REQUEST_CLIENT_TIMEOUT
270
283
  * @throws {Error} E_HTTP_REQUEST_DOWNSTREAM_ERROR
@@ -275,23 +288,11 @@ export default class Request
275
288
  * @throws {Error} E_HTTP_REQUEST_RETRY_HTTP2_RECONNECT
276
289
  * @throws {Error} E_HTTP_REQUEST_RETRY_ERROR
277
290
  */
278
- async #fetch(method, options)
291
+ async fetch(options)
279
292
  {
280
- if(typeof options === 'string')
281
- {
282
- options = { url:options }
283
- }
284
-
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
-
293
+ options = this.#normalizeOptions(options)
292
294
  options = Object.assign(
293
295
  {
294
- method,
295
296
  headers : {},
296
297
  retry : 3,
297
298
  retryDelay : 200,
@@ -300,6 +301,15 @@ export default class Request
300
301
  retryOnStatus : []
301
302
  }, this.config, options)
302
303
 
304
+ if('string' !== typeof options.method)
305
+ {
306
+ const error = new TypeError(`Method must be a string, got ${typeof options.method}`)
307
+ error.code = 'E_HTTP_REQUEST_INVALID_METHOD'
308
+ throw error
309
+ }
310
+
311
+ options.method = options.method.toUpperCase()
312
+
303
313
  try
304
314
  {
305
315
  return options.retry
@@ -308,13 +318,23 @@ export default class Request
308
318
  }
309
319
  catch(reason)
310
320
  {
311
- const error = new Error(`Failed request ${method} ${options.url}`)
321
+ const error = new Error(`Failed request ${options.method} ${options.url}`)
312
322
  error.code = 'E_HTTP_REQUEST_FAILED'
313
323
  error.cause = reason
314
324
  throw error
315
325
  }
316
326
  }
317
327
 
328
+ #normalizeOptions(options)
329
+ {
330
+ if(typeof options === 'string')
331
+ {
332
+ options = { url:options }
333
+ }
334
+
335
+ return options
336
+ }
337
+
318
338
  /**
319
339
  * Resolves the request.
320
340
  *
@@ -325,20 +345,20 @@ export default class Request
325
345
  */
326
346
  #resolve(options)
327
347
  {
328
- return new Promise((accept, reject) =>
348
+ return new Promise((resolve, reject) =>
329
349
  {
330
350
  const
331
351
  method = options.method,
332
352
  headers = this.#normalizeHeaders(options.headers),
333
353
  body = this.#normalizeBody(options.body ?? options.data, headers['content-type']),
334
354
  delimiter = this.#createBodyHeaderDelimiter(body, !!options.upstream || !!this.http2Session),
335
- url = this.#normalizeUrl(options.url, options.base)
355
+ url = this.#normalizeUrl(options.authority, options.base, options.url)
336
356
 
337
357
  Object.assign(headers, delimiter)
338
358
 
339
359
  const upstream = this.http2Session
340
- ? this.#resolveHttp2Client(options, method, headers, url, accept, reject)
341
- : this.#resolveHttp1Client(options, method, headers, url, accept, reject)
360
+ ? this.#resolveHttp2Client(options, method, headers, url, resolve, reject)
361
+ : this.#resolveHttp1Client(options, method, headers, url, resolve, reject)
342
362
 
343
363
  options.upstream
344
364
  ? options.upstream.pipe(upstream)
@@ -346,7 +366,7 @@ export default class Request
346
366
  })
347
367
  }
348
368
 
349
- #resolveHttp2Client(options, method, headers, url, accept, reject)
369
+ #resolveHttp2Client(options, method, headers, url, resolve, reject)
350
370
  {
351
371
  if(true === this.http2Session.destroyed)
352
372
  {
@@ -394,13 +414,13 @@ export default class Request
394
414
  delete upstream.headers[HEADER_STATUS]
395
415
  Object.defineProperty(upstream.headers, SENSITIVE_HEADERS, { enumerable:false, value:headers[SENSITIVE_HEADERS] })
396
416
 
397
- this.#resolveOnResponse(options, method, url, accept, reject, upstream)
417
+ this.#resolveOnResponse(options, method, url, resolve, reject, upstream)
398
418
  })
399
419
 
400
420
  return upstream
401
421
  }
402
422
 
403
- #resolveHttp1Client(options, method, headers, url, accept, reject)
423
+ #resolveHttp1Client(options, method, headers, url, resolve, reject)
404
424
  {
405
425
  const
406
426
  request = url.startsWith('https:') ? https.request : http.request,
@@ -410,7 +430,7 @@ export default class Request
410
430
  upstream.on('close', this.#connectionClosed .bind(this, upstream, reject))
411
431
  upstream.on('error', this.#resolveOnClientError .bind(this, method, url, reject))
412
432
  upstream.on('timeout', this.#resolveOnClientTimeout.bind(this, upstream, options.timeout, method, url, reject))
413
- upstream.on('response', this.#resolveOnResponse .bind(this, options, method, url, accept, reject))
433
+ upstream.on('response', this.#resolveOnResponse .bind(this, options, method, url, resolve, reject))
414
434
 
415
435
  return upstream
416
436
  }
@@ -429,13 +449,13 @@ export default class Request
429
449
  * @param {RequestOptions} options
430
450
  * @param {String} method
431
451
  * @param {String} url
432
- * @param {Function} accept Promise accept
452
+ * @param {Function} resolve Promise resolve
433
453
  * @param {Function} reject Promise reject
434
454
  * @param {http.IncomingMessage} readable
435
455
  *
436
456
  * @returns {Void}
437
457
  */
438
- #resolveOnResponse(options, method, url, accept, reject, readable)
458
+ #resolveOnResponse(options, method, url, resolve, reject, readable)
439
459
  {
440
460
  const response =
441
461
  {
@@ -458,11 +478,11 @@ export default class Request
458
478
  {
459
479
  readable.pipe(options.downstream)
460
480
  readable.resume()
461
- accept(response)
481
+ resolve(response)
462
482
  }
463
483
  else
464
484
  {
465
- this.#bufferResponseBody(readable, response, method, url, accept, reject)
485
+ this.#bufferResponseBody(readable, response, method, url, resolve, reject)
466
486
  }
467
487
  }
468
488
 
@@ -509,19 +529,16 @@ export default class Request
509
529
  }
510
530
  }
511
531
 
512
- const
513
- uniqueReasons = reasons.filter((reason, i) => [i, -1].includes(reasons.lastIndexOf(({ code }) => code === reason.code))),
514
- reason = uniqueReasons.pop()
532
+ const
533
+ unique = reasons.filter((reason, i) => [i, -1].includes(reasons.map(e => e.code).lastIndexOf(code => code === reason.code))),
534
+ reason = unique.pop()
515
535
 
516
- if(uniqueReasons.length === 1)
536
+ if(unique.length)
517
537
  {
518
- throw reason
519
- }
520
- else
521
- {
522
- reason.previous = uniqueReasons
523
- throw reason
538
+ reason.retried = unique
524
539
  }
540
+
541
+ throw reason
525
542
  }
526
543
 
527
544
  /**
@@ -662,17 +679,28 @@ export default class Request
662
679
 
663
680
  /**
664
681
  * Normalizes the URL.
682
+ * One of authority or base must be provided,
665
683
  *
666
- * @param {String} url
667
- * @param {String} base
684
+ * @param {String} [base]
685
+ * @param {String} [authority] the URL origin (e.g. https://example.com)
686
+ * @param {String} [url]
668
687
  *
669
688
  * @returns {String} Normalized URL
670
689
  */
671
- #normalizeUrl(url, base)
690
+ #normalizeUrl(authority, base, url)
672
691
  {
692
+ let baseURL = authority && base
693
+ ? new URL(base, authority)
694
+ : new URL(base || authority)
695
+
696
+ if(url && false === baseURL.pathname.endsWith('/'))
697
+ {
698
+ baseURL = new URL(baseURL.pathname + '/', baseURL.href)
699
+ }
700
+
673
701
  return url
674
- ? new URL(url, base).toString()
675
- : new URL(base).toString()
702
+ ? new URL(url, baseURL.href).href
703
+ : baseURL.href
676
704
  }
677
705
 
678
706
  /**
@@ -723,12 +751,12 @@ export default class Request
723
751
  upstream.destroy(error)
724
752
  }
725
753
 
726
- #bufferResponseBody(readable, response, method, url, accept, reject)
754
+ #bufferResponseBody(readable, response, method, url, resolve, reject)
727
755
  {
728
756
  response.body = ''
729
757
  readable.on('data', (chunk) => response.body += chunk)
730
758
  readable.on('error', this.#onStreamError.bind(this, method, url, response))
731
- readable.on('end', this.#onStreamEnd .bind(this, method, url, response, accept, reject))
759
+ readable.on('end', this.#onStreamEnd .bind(this, method, url, response, resolve, reject))
732
760
  readable.resume()
733
761
  }
734
762
 
@@ -759,14 +787,14 @@ export default class Request
759
787
  * @param {String} method
760
788
  * @param {String} url
761
789
  * @param {http.IncomingMessage} response
762
- * @param {Function} accept promise accept
790
+ * @param {Function} resolve promise resolve
763
791
  * @param {Function} reject promise reject
764
792
  *
765
793
  * @returns {void}
766
794
  *
767
795
  * @throws {Error} E_HTTP_REQUEST_INVALID_RESPONSE_BODY_FORMAT
768
796
  */
769
- #onStreamEnd(method, url, response, accept, reject)
797
+ #onStreamEnd(method, url, response, resolve, reject)
770
798
  {
771
799
  try
772
800
  {
@@ -791,7 +819,7 @@ export default class Request
791
819
  return
792
820
  }
793
821
 
794
- accept(response)
822
+ resolve(response)
795
823
  }
796
824
 
797
825
  #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' })
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@superhero/http-request",
3
- "version": "4.1.0",
3
+ "version": "4.1.2",
4
4
  "description": "HTTP(S) request component supporting HTTP 1.1 and HTTP 2.0",
5
5
  "keywords": [
6
6
  "http request",
@@ -18,7 +18,10 @@
18
18
  ".": "./index.js"
19
19
  },
20
20
  "scripts": {
21
- "test": "node --trace-warnings --test --experimental-test-coverage"
21
+ "test": "node --test --test-reporter=@superhero/audit/reporter --experimental-test-coverage"
22
+ },
23
+ "devDependencies": {
24
+ "@superhero/audit": "^4.0.3"
22
25
  },
23
26
  "author": {
24
27
  "name": "Erik Landvall",