@superhero/http-request 4.0.0

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 (4) hide show
  1. package/README.md +351 -0
  2. package/index.js +704 -0
  3. package/index.test.js +555 -0
  4. package/package.json +27 -0
package/index.test.js ADDED
@@ -0,0 +1,555 @@
1
+ import assert from 'node:assert'
2
+ import http from 'node:http'
3
+ import http2 from 'node:http2'
4
+ import Request from '@superhero/http-request'
5
+ import { once } from 'events'
6
+ import { Readable, Writable } from 'node:stream'
7
+ import { suite, test, beforeEach, afterEach } from 'node:test'
8
+
9
+ suite('@superhero/http-request', () =>
10
+ {
11
+ let server, request, baseUrl
12
+
13
+ function executeTheSameTestSuitFor_http1_http2()
14
+ {
15
+ suite('Request using method', () =>
16
+ {
17
+ const methods =
18
+ [
19
+ { method: 'POST', body: { key: 'foo' } },
20
+ { method: 'PUT', body: { key: 'bar' } },
21
+ { method: 'PATCH', body: { key: 'baz' } },
22
+ { method: 'GET' },
23
+ { method: 'DELETE' },
24
+ { method: 'HEAD' },
25
+ { method: 'OPTIONS' },
26
+ { method: 'TRACE' }
27
+ ]
28
+
29
+ for (const { method, body } of methods)
30
+ {
31
+ test(method, async () =>
32
+ {
33
+ const
34
+ options = body ? { body, headers: { 'Content-Type': 'application/json' } } : {},
35
+ response = await request[method.toLowerCase()]({ url: `/test-${method}`, ...options })
36
+
37
+ assert.equal(response.status, 200, `Should return a 200 status code for ${method}`)
38
+
39
+ if(method === 'HEAD') return // HEAD requests does not have a body to validate
40
+
41
+ assert.equal(response.body.method, method, `Should report method as ${method}`)
42
+ assert.equal(response.body.url, `/test-${method}`, `Should request /test-${method}`)
43
+
44
+ body && assert.deepEqual(response.body.body, body, `Should send correct body for ${method}`)
45
+ })
46
+ }
47
+ })
48
+
49
+ test('Request using the URL as the option parameter', async () =>
50
+ {
51
+ const response = await request.get('/')
52
+ assert.equal(response.status, 200, 'Should return a 200 status code')
53
+ assert.equal(response.body.url, '/', 'Should result to /')
54
+ })
55
+
56
+ test('Request using a string body', async () =>
57
+ {
58
+ const response = await request.post({ body: 'test body' })
59
+ assert.equal(response.status, 200, 'Should return a 200 status code')
60
+ assert.equal(response.body.body, 'test body', 'Should result to the value of the request body')
61
+ })
62
+
63
+ test('Request using a custom header', async () =>
64
+ {
65
+ const response = await request.get(
66
+ {
67
+ url : '/header-test',
68
+ headers : { 'custom-header': 'test-value' },
69
+ })
70
+
71
+ assert.equal(response.status, 200, 'Should return a 200 status code')
72
+ assert.equal(response.body.headers['custom-header'], 'test-value', 'Should return the custom header')
73
+ })
74
+
75
+ test('Normalizes headers and body', async () =>
76
+ {
77
+ const response = await request.post(
78
+ {
79
+ url : '/normalize-test',
80
+ headers : { 'Custom-Header': 'foo' },
81
+ body : { bar: 'baz' }
82
+ })
83
+
84
+ assert.equal(response.status, 200, 'Should return a 200 status code')
85
+ assert.equal(response.body.headers['custom-header'], 'foo', 'Should normalize headers')
86
+ assert.deepEqual(response.body.body, 'bar=baz', 'Should normalize body')
87
+ })
88
+
89
+ test('Can pipe upstream body through the request from a readable stream', async () =>
90
+ {
91
+ const
92
+ upstream = Readable.from(['test upstream body']),
93
+ response = await request.post(
94
+ {
95
+ url: '/upstream-test',
96
+ upstream
97
+ })
98
+
99
+ assert.equal(response.status, 200)
100
+ assert.equal(response.body.body, 'test upstream body')
101
+ })
102
+
103
+ test('Can pipe downstream response from the request to a writable stream', async () =>
104
+ {
105
+ let body = ''
106
+
107
+ const
108
+ downstream = new Writable(
109
+ {
110
+ write(chunk, encoding, callback)
111
+ {
112
+ body += chunk.toString()
113
+ callback()
114
+ }
115
+ }),
116
+ finished = once(downstream, 'finish'),
117
+ response = await request.get(
118
+ {
119
+ url: '/downstream-test',
120
+ downstream
121
+ })
122
+
123
+ await finished
124
+
125
+ assert.equal(response.status, 200, 'Should return a 200 status code')
126
+ assert.doesNotThrow(() => body = JSON.parse(body), 'Should be able to parse body as JSON')
127
+ assert.equal(body.url, '/downstream-test', 'Should be able to pipe downstream body')
128
+ })
129
+ }
130
+
131
+ suite('HTTP 1.1', () =>
132
+ {
133
+ beforeEach(async () =>
134
+ {
135
+ server = http.createServer((req, res) =>
136
+ {
137
+ const dto =
138
+ {
139
+ method : req.method,
140
+ url : req.url,
141
+ headers : req.headers,
142
+ body : ''
143
+ }
144
+
145
+ req.on('error', console.error)
146
+ res.on('error', console.error)
147
+
148
+ req.on('data', (chunk) => dto.body += chunk)
149
+ req.on('end', () =>
150
+ {
151
+ if(req.headers['content-type'] === 'application/json')
152
+ {
153
+ dto.body = JSON.parse(dto.body ?? '{}')
154
+ }
155
+
156
+ res.writeHead(200, { 'Content-Type': 'application/json' })
157
+ res.end(JSON.stringify(dto))
158
+ })
159
+ })
160
+
161
+ await new Promise((accept, reject) =>
162
+ {
163
+ server.on('error', reject)
164
+ server.listen(() =>
165
+ {
166
+ const { port } = server.address()
167
+ baseUrl = `http://localhost:${port}`
168
+
169
+ server.off('error', reject)
170
+ request = new Request({ base:baseUrl })
171
+ accept()
172
+ })
173
+ })
174
+ })
175
+
176
+ afterEach((done) => server.close(done))
177
+
178
+ executeTheSameTestSuitFor_http1_http2()
179
+
180
+ suite('Tests that require an altered server response', () =>
181
+ {
182
+ test('Supports request timeout', async () =>
183
+ {
184
+ // Alter the server to delay the response by 0.5 seconds
185
+ server.removeAllListeners('request')
186
+ server.on('request', (req, res) =>
187
+ {
188
+ setTimeout(() =>
189
+ {
190
+ res.writeHead(200, { 'Content-Type': 'application/json' })
191
+ res.end(JSON.stringify({ success: true }))
192
+ }, 500)
193
+ })
194
+
195
+ const options =
196
+ {
197
+ url : '/timeout-test',
198
+ timeout : 100
199
+ }
200
+ await assert.rejects(
201
+ request.get(options),
202
+ (error) => error.code === 'E_HTTP_REQUEST_CLIENT_TIMEOUT',
203
+ 'Should throw a timeout error')
204
+ })
205
+
206
+ test('Rejects invalid JSON response accurately', async () =>
207
+ {
208
+ // Alter the server to respond with invalid JSON
209
+ server.removeAllListeners('request')
210
+ server.on('request', (req, res) =>
211
+ {
212
+ res.writeHead(200, { 'Content-Type': 'application/json' })
213
+ res.end('Invalid JSON')
214
+ })
215
+
216
+ // Make the request
217
+ await assert.rejects(
218
+ request.get({ url: '/invalid-json' }),
219
+ (error) => error.code === 'E_HTTP_REQUEST_INVALID_RESPONSE_BODY_FORMAT',
220
+ 'Should throw a parse error')
221
+ })
222
+
223
+ test('Retry on client error', async () =>
224
+ {
225
+ let attempt = 0
226
+
227
+ // Alter the server to not accept the first two requests
228
+ server.removeAllListeners('request')
229
+ server.on('request', (req, res) =>
230
+ {
231
+ if(++attempt < 3)
232
+ {
233
+ res.destroy()
234
+ }
235
+ else
236
+ {
237
+ res.writeHead(200, { 'Content-Type': 'application/json' })
238
+ res.end(JSON.stringify({ success: true }))
239
+ }
240
+ })
241
+
242
+ const response = await request.get(
243
+ {
244
+ url : `/retry-test`,
245
+ retry : 3,
246
+ retryDelay : 100
247
+ })
248
+ assert.equal(response.status, 200)
249
+ assert.deepEqual(response.body, { success: true })
250
+ assert.equal(attempt, 3)
251
+ })
252
+
253
+ test('Retry on client timeout', async () =>
254
+ {
255
+ let attempt = 0
256
+
257
+ // Alter the server to delay the response by 0.5 seconds the first attempt
258
+ server.removeAllListeners('request')
259
+ server.on('request', (req, res) =>
260
+ {
261
+ attempt++;
262
+
263
+ if(attempt < 2)
264
+ {
265
+ setTimeout(() =>
266
+ {
267
+ res.writeHead(200, { 'Content-Type': 'application/json' })
268
+ res.end(JSON.stringify({ success: true }))
269
+ }, 500)
270
+ }
271
+ else
272
+ {
273
+ res.writeHead(200, { 'Content-Type': 'application/json' })
274
+ res.end(JSON.stringify({ success: true }))
275
+ }
276
+ })
277
+
278
+ const response = await request.get(
279
+ {
280
+ url : '/timeout-test',
281
+ timeout : 100,
282
+ retry : 2,
283
+ retryOnClientTimeout: true
284
+ })
285
+ assert.equal(response.status, 200, 'Should eventually return 200 status')
286
+ assert.deepEqual(response.body, { success: true }, 'Should return the correct success body')
287
+ assert.equal(attempt, 2, 'Should make exactly 3 attempts')
288
+ })
289
+
290
+ test('Retry on invalid response body format', async () =>
291
+ {
292
+ let attempt = 0
293
+
294
+ // Alter the server to initially return invalid JSON body
295
+ server.removeAllListeners('request')
296
+ server.on('request', (req, res) =>
297
+ {
298
+ attempt++;
299
+
300
+ if (attempt < 3)
301
+ {
302
+ // Return invalid JSON
303
+ res.writeHead(200, { 'Content-Type': 'application/json' })
304
+ res.end('Invalid JSON')
305
+ }
306
+ else
307
+ {
308
+ // Return valid JSON after retries
309
+ res.writeHead(200, { 'Content-Type': 'application/json' })
310
+ res.end(JSON.stringify({ success: true }))
311
+ }
312
+ })
313
+
314
+ const response = await request.get(
315
+ {
316
+ url : '/retry-invalid-json',
317
+ retry : 3,
318
+ retryDelay : 100,
319
+ retryOnInvalidResponseBodyFormat: true
320
+ })
321
+ assert.equal(response.status, 200, 'Should eventually return 200 status')
322
+ assert.deepEqual(response.body, { success: true }, 'Should return the correct success body')
323
+ assert.equal(attempt, 3, 'Should make exactly 3 attempts')
324
+ })
325
+
326
+ test('Retry on response status', async (sub) =>
327
+ {
328
+ // Close the server that was started before the
329
+ // sub tests starts another server for each test.
330
+ await new Promise((accept) => server.close(accept))
331
+
332
+ let attempt
333
+
334
+ sub.beforeEach(() =>
335
+ {
336
+ // Reset the attempt counter
337
+ attempt = 0
338
+
339
+ // Alter the server to return a retryable statuses before success
340
+ server.removeAllListeners('request')
341
+ server.on('request', (req, res) =>
342
+ {
343
+ attempt++;
344
+
345
+ if(attempt < 3)
346
+ {
347
+ // Respond with the retryable status
348
+ res.writeHead(503, { 'Content-Type': 'application/json' })
349
+ res.end(JSON.stringify({ message: 'Service Unavailable' }))
350
+ }
351
+ else
352
+ {
353
+ // Respond with a success status after the third attempt
354
+ res.writeHead(200, { 'Content-Type': 'application/json' })
355
+ res.end(JSON.stringify({ success: true }))
356
+ }
357
+ })
358
+ })
359
+
360
+ await sub.test('Retry on status: 503 - should succeed after re-attempts', async () =>
361
+ {
362
+ const response = await request.get(
363
+ {
364
+ url : '/retry-on-status',
365
+ retry : 3,
366
+ retryDelay : 100,
367
+ retryOnStatus : [ 503 ]
368
+ })
369
+ assert.equal(response.status, 200, 'Should eventually return 200 status')
370
+ assert.deepEqual(response.body, { success: true }, 'Should return the correct success body')
371
+ assert.equal(attempt, 3, 'Should make exactly 3 attempts')
372
+ })
373
+
374
+ await sub.test('Retry on status: 503 - should reject after to few re-attempts', async () =>
375
+ {
376
+ const options =
377
+ {
378
+ url : '/retry-on-status',
379
+ retry : 2,
380
+ retryDelay : 100,
381
+ retryOnStatus : [ 503 ]
382
+ }
383
+
384
+ await assert.rejects(
385
+ request.get(options),
386
+ (error) => error.code === 'E_HTTP_REQUEST_INVALID_RESPONSE_STATUS',
387
+ 'Should throw an error after the second attempt')
388
+
389
+ assert.equal(attempt, 2, 'Should make exactly 2 attempts')
390
+ })
391
+
392
+ await sub.test('Retry on status: 500 - should reject on first attempt', async () =>
393
+ {
394
+ const options =
395
+ {
396
+ url : '/retry-on-status',
397
+ retry : 3,
398
+ retryDelay : 100,
399
+ retryOnStatus : [ 500 ]
400
+ }
401
+
402
+ await assert.rejects(
403
+ request.get(options),
404
+ (error) => error.code === 'E_HTTP_REQUEST_INVALID_RESPONSE_STATUS',
405
+ 'Should throw an error right away')
406
+
407
+ assert.equal(attempt, 1, 'Should make exactly 1 attempt')
408
+ })
409
+
410
+ await sub.test('Retry on error response status', async (sub) =>
411
+ {
412
+ // Close the server that was started before the
413
+ // sub tests starts another server for each test.
414
+ await new Promise((accept) => server.close(accept))
415
+
416
+ await sub.test('Should succeed after re-attempts', async () =>
417
+ {
418
+ const response = await request.get(
419
+ {
420
+ url : '/retry-on-status',
421
+ retry : 3,
422
+ retryDelay : 100,
423
+ retryOnErrorResponseStatus : true
424
+ })
425
+ assert.equal(response.status, 200, 'Should eventually return 200 status')
426
+ assert.deepEqual(response.body, { success: true }, 'Should return the correct success body')
427
+ assert.equal(attempt, 3, 'Should make exactly 3 attempts')
428
+ })
429
+
430
+ await sub.test('Should succeed after re-attempts when "doNotThrowOnErrorStatus"', async () =>
431
+ {
432
+ const response = await request.get(
433
+ {
434
+ url : '/retry-on-status',
435
+ retry : 3,
436
+ retryDelay : 100,
437
+ retryOnErrorResponseStatus : true,
438
+ doNotThrowOnErrorStatus : true
439
+ })
440
+ assert.equal(response.status, 200, 'Should eventually return 200 status')
441
+ assert.deepEqual(response.body, { success: true }, 'Should return the correct success body')
442
+ assert.equal(attempt, 3, 'Should make exactly 3 attempts')
443
+ })
444
+ })
445
+ })
446
+ })
447
+ })
448
+
449
+ // ---------
450
+ // HTTP2 2.0
451
+ // ---------
452
+
453
+ suite('HTTP 2.0', () =>
454
+ {
455
+ // let server, baseUrl, request
456
+
457
+ beforeEach(async () =>
458
+ {
459
+ server = http2.createServer()
460
+
461
+ server.on('stream', (stream, headers) =>
462
+ {
463
+ const
464
+ method = headers[':method'],
465
+ path = headers[':path']
466
+
467
+ let body = ''
468
+
469
+ stream.on('error', console.error)
470
+ stream.on('data', (chunk) => body += chunk)
471
+ stream.on('end', () =>
472
+ {
473
+ const response =
474
+ {
475
+ method,
476
+ headers,
477
+ url : path,
478
+ body : headers['content-type'] === 'application/json'
479
+ ? JSON.parse(body || '{}')
480
+ : body
481
+ }
482
+
483
+ stream.respond(
484
+ {
485
+ ':status' : 200,
486
+ 'content-type' : 'application/json',
487
+ })
488
+
489
+ stream.writable && stream.end(JSON.stringify(response))
490
+ })
491
+ })
492
+
493
+ await new Promise((accept) =>
494
+ {
495
+ server.listen(async () =>
496
+ {
497
+ const { port } = server.address()
498
+ baseUrl = `http://localhost:${port}`
499
+ request = new Request()
500
+ await request.connect(baseUrl)
501
+ accept()
502
+ })
503
+ })
504
+ })
505
+
506
+ afterEach((done) => request.close().then(() => server.close(done)))
507
+
508
+ executeTheSameTestSuitFor_http1_http2()
509
+
510
+ suite('Tests that require an altered server response', () =>
511
+ {
512
+ test('Retry on connection error', async () =>
513
+ {
514
+ let attempts = 0
515
+
516
+ // Alter the server to not accept the first two requests
517
+ server.removeAllListeners('stream')
518
+ server.on('stream', (stream, headers) =>
519
+ {
520
+ attempts++
521
+
522
+ if(attempts < 3)
523
+ {
524
+ stream.destroy()
525
+ }
526
+ else
527
+ {
528
+ stream.respond({ ':status': 200, 'content-type': 'application/json' })
529
+ stream.end(JSON.stringify({ success: true }))
530
+ }
531
+ })
532
+
533
+ const response = await request.get({ url:'/retry-test', retry: 3, retryDelay: 100 })
534
+ assert.equal(response.status, 200, 'Should return a 200 status code after retries')
535
+ assert.deepEqual(response.body, { success: true }, 'Should return the correct body')
536
+ assert.equal(attempts, 3, 'Should make exactly 3 attempts')
537
+ })
538
+
539
+ test('Supports request timeout', async () =>
540
+ {
541
+ // Alter the server to delay the response by 0.5 seconds
542
+ server.removeAllListeners('stream')
543
+ server.on('stream', (stream) =>
544
+ {
545
+ setTimeout(() => stream.destroy(), 500)
546
+ })
547
+
548
+ await assert.rejects(
549
+ request.get({ url: '/timeout-test', timeout: 100 }),
550
+ (error) => error.code === 'E_HTTP_REQUEST_CLIENT_TIMEOUT',
551
+ 'Should throw a timeout error')
552
+ })
553
+ })
554
+ })
555
+ })
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "@superhero/http-request",
3
+ "version": "4.0.0",
4
+ "description": "HTTP request supporting HTTP 1.1 and HTTP 2.0",
5
+ "keywords": [
6
+ "http request",
7
+ "http 1.1",
8
+ "http 2.0"
9
+ ],
10
+ "main": "index.js",
11
+ "license": "MIT",
12
+ "type": "module",
13
+ "exports": {
14
+ ".": "./index.js"
15
+ },
16
+ "scripts": {
17
+ "test": "node --trace-warnings --test --experimental-test-coverage"
18
+ },
19
+ "author": {
20
+ "name": "Erik Landvall",
21
+ "email": "erik@landvall.se"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/superhero/http-request"
26
+ }
27
+ }