feat: Implement comprehensive web request handling with caching, retry, and interceptors
- Added cache strategies: NetworkFirst, CacheFirst, StaleWhileRevalidate, NetworkOnly, and CacheOnly. - Introduced InterceptorManager for managing request, response, and error interceptors. - Developed RetryManager for handling request retries with customizable backoff strategies. - Implemented RequestDeduplicator to prevent simultaneous identical requests. - Created timeout utilities for handling request timeouts. - Enhanced WebrequestClient to support global interceptors, caching, and retry logic. - Added convenience methods for common HTTP methods (GET, POST, PUT, DELETE) with JSON handling. - Established a fetch-compatible webrequest function for seamless integration. - Defined core type structures for caching, retry options, interceptors, and web request configurations.
This commit is contained in:
		
							
								
								
									
										60
									
								
								test/test.all.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								test/test.all.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,60 @@ | ||||
| import { expect, tap } from '@git.zone/tstest/tapbundle'; | ||||
| import { webrequest } from '../ts/index.js'; | ||||
|  | ||||
| // Simple smoke tests for v4 API | ||||
|  | ||||
| // Test 1: Basic fetch-compatible API | ||||
| tap.test('should work as fetch replacement', async () => { | ||||
|   const response = await webrequest('https://httpbin.org/get', { | ||||
|     method: 'GET', | ||||
|   }); | ||||
|   expect(response).toBeInstanceOf(Response); | ||||
|   expect(response.ok).toEqual(true); | ||||
|   console.log('API response status:', response.status); | ||||
| }); | ||||
|  | ||||
| // Test 2: JSON convenience method | ||||
| tap.test('should support getJson convenience method', async () => { | ||||
|   interface HttpBinResponse { | ||||
|     url: string; | ||||
|     headers: Record<string, string>; | ||||
|   } | ||||
|  | ||||
|   const data = await webrequest.getJson<HttpBinResponse>('https://httpbin.org/get'); | ||||
|   console.log('getJson url:', data.url); | ||||
|   expect(data).toHaveProperty('url'); | ||||
|   expect(data).toHaveProperty('headers'); | ||||
| }); | ||||
|  | ||||
| // Test 3: POST with JSON | ||||
| tap.test('should support postJson', async () => { | ||||
|   interface PostResponse { | ||||
|     json: any; | ||||
|     url: string; | ||||
|   } | ||||
|  | ||||
|   const data = await webrequest.postJson<PostResponse>( | ||||
|     'https://httpbin.org/post', | ||||
|     { test: 'data' } | ||||
|   ); | ||||
|   expect(data).toHaveProperty('url'); | ||||
|   expect(data).toHaveProperty('json'); | ||||
|   console.log('postJson works'); | ||||
| }); | ||||
|  | ||||
| // Test 4: Caching | ||||
| tap.test('should support caching', async () => { | ||||
|   const data1 = await webrequest.getJson('https://httpbin.org/get', { | ||||
|     cacheStrategy: 'cache-first' | ||||
|   }); | ||||
|  | ||||
|   const data2 = await webrequest.getJson('https://httpbin.org/get', { | ||||
|     cacheStrategy: 'cache-first' | ||||
|   }); | ||||
|  | ||||
|   expect(data1).toHaveProperty('url'); | ||||
|   expect(data2).toHaveProperty('url'); | ||||
|   console.log('Caching works'); | ||||
| }); | ||||
|  | ||||
| export default tap.start(); | ||||
| @@ -1,11 +0,0 @@ | ||||
| import { expect, tap } from '@push.rocks/tapbundle'; | ||||
| import * as webrequest from '../ts/index.js'; | ||||
|  | ||||
| tap.test('should run multiendpoint request', async (tools) => { | ||||
|   const response = await new webrequest.WebRequest().request('https://api.signup.software', { | ||||
|     method: 'GET', | ||||
|   }); | ||||
|   console.log(JSON.stringify(await response.text())); | ||||
| }); | ||||
|  | ||||
| tap.start(); | ||||
							
								
								
									
										71
									
								
								test/test.ts
									
									
									
									
									
								
							
							
						
						
									
										71
									
								
								test/test.ts
									
									
									
									
									
								
							| @@ -1,5 +1,5 @@ | ||||
| import { expect, tap } from '@push.rocks/tapbundle'; | ||||
| import * as webrequest from '../ts/index.js'; | ||||
| import { expect, tap } from '@git.zone/tstest/tapbundle'; | ||||
| import { webrequest } from '../ts/index.js'; | ||||
|  | ||||
| // test dependencies | ||||
| import * as typedserver from '@api.global/typedserver'; | ||||
| @@ -18,7 +18,7 @@ tap.test('setup test server', async () => { | ||||
|     new typedserver.servertools.Handler('GET', (req, res) => { | ||||
|       res.status(429); | ||||
|       res.end(); | ||||
|     }) | ||||
|     }), | ||||
|   ); | ||||
|  | ||||
|   testServer.addRoute( | ||||
| @@ -26,7 +26,7 @@ tap.test('setup test server', async () => { | ||||
|     new typedserver.servertools.Handler('GET', (req, res) => { | ||||
|       res.status(500); | ||||
|       res.end(); | ||||
|     }) | ||||
|     }), | ||||
|   ); | ||||
|  | ||||
|   testServer.addRoute( | ||||
| @@ -36,43 +36,62 @@ tap.test('setup test server', async () => { | ||||
|       res.send({ | ||||
|         hithere: 'hi', | ||||
|       }); | ||||
|     }) | ||||
|     }), | ||||
|   ); | ||||
|  | ||||
|   await testServer.start(); | ||||
| }); | ||||
|  | ||||
| tap.test('first test', async (tools) => { | ||||
|   const response = await ( | ||||
|     await new webrequest.WebRequest().requestMultiEndpoint( | ||||
|       [ | ||||
|         'http://localhost:2345/apiroute1', | ||||
| tap.test('should handle fallback URLs', async () => { | ||||
|   const response = await webrequest( | ||||
|     'http://localhost:2345/apiroute1', | ||||
|     { | ||||
|       fallbackUrls: [ | ||||
|         'http://localhost:2345/apiroute2', | ||||
|         'http://localhost:2345/apiroute4', | ||||
|         'http://localhost:2345/apiroute3', | ||||
|       ], | ||||
|       { | ||||
|         method: 'GET', | ||||
|       } | ||||
|     ) | ||||
|   ).json(); | ||||
|       retry: { | ||||
|         maxAttempts: 3, | ||||
|         backoff: 'constant', | ||||
|         initialDelay: 100, | ||||
|       }, | ||||
|     } | ||||
|   ); | ||||
|  | ||||
|   const response2 = await new webrequest.WebRequest().getJson('http://localhost:2345/apiroute3'); | ||||
|   const data = await response.json(); | ||||
|   console.log('response with fallbacks: ' + JSON.stringify(data)); | ||||
|   expect(data).toHaveProperty('hithere'); | ||||
| }); | ||||
|  | ||||
|   console.log('response 1: ' + JSON.stringify(response)); | ||||
|   console.log('response 2: ' + JSON.stringify(response2)); | ||||
|  | ||||
|   expect(response).toHaveProperty('hithere'); //.to.equal('hi'); | ||||
|   expect(response2).toHaveProperty('hithere'); //.to.equal('hi'); | ||||
| tap.test('should use getJson convenience method', async () => { | ||||
|   const data = await webrequest.getJson('http://localhost:2345/apiroute3'); | ||||
|   console.log('getJson response: ' + JSON.stringify(data)); | ||||
|   expect(data).toHaveProperty('hithere'); | ||||
| }); | ||||
|  | ||||
| tap.test('should cache response', async () => { | ||||
|   const webrequestInstance = new webrequest.WebRequest(); | ||||
|   const response = await webrequestInstance.getJson('http://localhost:2345/apiroute3', true); | ||||
|   expect(response).toHaveProperty('hithere'); | ||||
|   // First request - goes to network | ||||
|   const response1 = await webrequest.getJson( | ||||
|     'http://localhost:2345/apiroute3', | ||||
|     { | ||||
|       cacheStrategy: 'cache-first', | ||||
|     } | ||||
|   ); | ||||
|   expect(response1).toHaveProperty('hithere'); | ||||
|  | ||||
|   // Stop server | ||||
|   await testServer.stop(); | ||||
|   const response2 = await webrequestInstance.getJson('http://localhost:2345/apiroute3', true); | ||||
|  | ||||
|   // Second request - should use cache since server is down | ||||
|   const response2 = await webrequest.getJson( | ||||
|     'http://localhost:2345/apiroute3', | ||||
|     { | ||||
|       cacheStrategy: 'network-first', // Will fallback to cache on network error | ||||
|     } | ||||
|   ); | ||||
|   expect(response2).toHaveProperty('hithere'); | ||||
|   console.log('Cache fallback worked'); | ||||
| }); | ||||
|  | ||||
| tap.start(); | ||||
| export default tap.start(); | ||||
|   | ||||
							
								
								
									
										312
									
								
								test/test.v4.node.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										312
									
								
								test/test.v4.node.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,312 @@ | ||||
| import { expect, tap } from '@git.zone/tstest/tapbundle'; | ||||
| import { webrequest, WebrequestClient } from '../ts/index.js'; | ||||
| import * as typedserver from '@api.global/typedserver'; | ||||
|  | ||||
| let testServer: typedserver.servertools.Server; | ||||
|  | ||||
| // Setup test server | ||||
| tap.test('setup test server for v4 tests', async () => { | ||||
|   testServer = new typedserver.servertools.Server({ | ||||
|     cors: false, | ||||
|     forceSsl: false, | ||||
|     port: 2346, | ||||
|   }); | ||||
|  | ||||
|   // Route that returns JSON with cache headers | ||||
|   testServer.addRoute( | ||||
|     '/cached', | ||||
|     new typedserver.servertools.Handler('GET', (req, res) => { | ||||
|       res.setHeader('Cache-Control', 'max-age=60'); | ||||
|       res.setHeader('ETag', '"12345"'); | ||||
|       res.status(200); | ||||
|       res.send({ data: 'cached response', timestamp: Date.now() }); | ||||
|     }), | ||||
|   ); | ||||
|  | ||||
|   // Route that returns different data each time | ||||
|   testServer.addRoute( | ||||
|     '/dynamic', | ||||
|     new typedserver.servertools.Handler('GET', (req, res) => { | ||||
|       res.status(200); | ||||
|       res.send({ data: 'dynamic response', timestamp: Date.now() }); | ||||
|     }), | ||||
|   ); | ||||
|  | ||||
|   // Route that sometimes fails (for retry testing) | ||||
|   let requestCount = 0; | ||||
|   testServer.addRoute( | ||||
|     '/flaky', | ||||
|     new typedserver.servertools.Handler('GET', (req, res) => { | ||||
|       requestCount++; | ||||
|       if (requestCount < 3) { | ||||
|         res.status(500); | ||||
|         res.end(); | ||||
|       } else { | ||||
|         res.status(200); | ||||
|         res.send({ success: true, attempts: requestCount }); | ||||
|       } | ||||
|     }), | ||||
|   ); | ||||
|  | ||||
|   // Route that returns 304 when ETag matches | ||||
|   testServer.addRoute( | ||||
|     '/conditional', | ||||
|     new typedserver.servertools.Handler('GET', (req, res) => { | ||||
|       const ifNoneMatch = req.headers['if-none-match']; | ||||
|       if (ifNoneMatch === '"67890"') { | ||||
|         res.status(304); | ||||
|         res.end(); | ||||
|       } else { | ||||
|         res.setHeader('ETag', '"67890"'); | ||||
|         res.status(200); | ||||
|         res.send({ data: 'conditional response' }); | ||||
|       } | ||||
|     }), | ||||
|   ); | ||||
|  | ||||
|   // POST route for testing | ||||
|   testServer.addRoute( | ||||
|     '/post', | ||||
|     new typedserver.servertools.Handler('POST', (req, res) => { | ||||
|       res.status(200); | ||||
|       res.send({ received: true }); | ||||
|     }), | ||||
|   ); | ||||
|  | ||||
|   await testServer.start(); | ||||
| }); | ||||
|  | ||||
| // Test 1: Basic fetch-compatible API | ||||
| tap.test('should work as fetch replacement', async () => { | ||||
|   const response = await webrequest('http://localhost:2346/dynamic'); | ||||
|   expect(response).toBeInstanceOf(Response); | ||||
|   expect(response.ok).toEqual(true); | ||||
|   expect(response.status).toEqual(200); | ||||
|  | ||||
|   const data = await response.json(); | ||||
|   expect(data).toHaveProperty('data'); | ||||
|   expect(data.data).toEqual('dynamic response'); | ||||
| }); | ||||
|  | ||||
| // Test 2: getJson convenience method with generics | ||||
| tap.test('should support getJson with type safety', async () => { | ||||
|   interface TestData { | ||||
|     data: string; | ||||
|     timestamp: number; | ||||
|   } | ||||
|  | ||||
|   const data = await webrequest.getJson<TestData>('http://localhost:2346/dynamic'); | ||||
|   expect(data).toHaveProperty('data'); | ||||
|   expect(data).toHaveProperty('timestamp'); | ||||
|   expect(typeof data.timestamp).toEqual('number'); | ||||
| }); | ||||
|  | ||||
| // Test 3: POST with JSON | ||||
| tap.test('should support postJson', async () => { | ||||
|   const data = await webrequest.postJson('http://localhost:2346/post', { | ||||
|     test: 'data' | ||||
|   }); | ||||
|   expect(data).toHaveProperty('received'); | ||||
|   expect(data.received).toEqual(true); | ||||
| }); | ||||
|  | ||||
| // Test 4: Cache strategy - network-first | ||||
| tap.test('should support network-first cache strategy', async () => { | ||||
|   const response1 = await webrequest('http://localhost:2346/cached', { | ||||
|     cacheStrategy: 'network-first' | ||||
|   }); | ||||
|   const data1 = await response1.json(); | ||||
|   expect(data1).toHaveProperty('timestamp'); | ||||
|  | ||||
|   // Second request should hit network but may use cache on error | ||||
|   const response2 = await webrequest('http://localhost:2346/cached', { | ||||
|     cacheStrategy: 'network-first' | ||||
|   }); | ||||
|   const data2 = await response2.json(); | ||||
|   expect(data2).toHaveProperty('timestamp'); | ||||
| }); | ||||
|  | ||||
| // Test 5: Cache strategy - cache-first | ||||
| tap.test('should support cache-first strategy', async () => { | ||||
|   // First request goes to network | ||||
|   const response1 = await webrequest('http://localhost:2346/cached', { | ||||
|     cacheStrategy: 'cache-first' | ||||
|   }); | ||||
|   const data1 = await response1.json(); | ||||
|   const timestamp1 = data1.timestamp; | ||||
|  | ||||
|   // Second request should use cache | ||||
|   const response2 = await webrequest('http://localhost:2346/cached', { | ||||
|     cacheStrategy: 'cache-first' | ||||
|   }); | ||||
|   const data2 = await response2.json(); | ||||
|  | ||||
|   // Timestamps should be identical if cached | ||||
|   expect(data2.timestamp).toEqual(timestamp1); | ||||
| }); | ||||
|  | ||||
| // Test 6: Retry system | ||||
| tap.test('should retry failed requests', async () => { | ||||
|   const response = await webrequest('http://localhost:2346/flaky', { | ||||
|     retry: { | ||||
|       maxAttempts: 3, | ||||
|       backoff: 'constant', | ||||
|       initialDelay: 100 | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   const data = await response.json(); | ||||
|   expect(data.success).toEqual(true); | ||||
|   expect(data.attempts).toBeGreaterThanOrEqual(3); | ||||
| }); | ||||
|  | ||||
| // Test 7: Fallback URLs | ||||
| tap.test('should support fallback URLs', async () => { | ||||
|   const response = await webrequest('http://localhost:2346/nonexistent', { | ||||
|     fallbackUrls: ['http://localhost:2346/dynamic'], | ||||
|     retry: { | ||||
|       maxAttempts: 2 | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   expect(response.ok).toEqual(true); | ||||
|   const data = await response.json(); | ||||
|   expect(data).toHaveProperty('data'); | ||||
| }); | ||||
|  | ||||
| // Test 8: Request interceptors | ||||
| tap.test('should support request interceptors', async () => { | ||||
|   let interceptorCalled = false; | ||||
|  | ||||
|   const response = await webrequest('http://localhost:2346/dynamic', { | ||||
|     interceptors: { | ||||
|       request: [(req) => { | ||||
|         interceptorCalled = true; | ||||
|         const headers = new Headers(req.headers); | ||||
|         headers.set('X-Custom-Header', 'test'); | ||||
|         return new Request(req, { headers }); | ||||
|       }] | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   expect(interceptorCalled).toEqual(true); | ||||
|   expect(response.ok).toEqual(true); | ||||
| }); | ||||
|  | ||||
| // Test 9: Response interceptors | ||||
| tap.test('should support response interceptors', async () => { | ||||
|   let interceptorCalled = false; | ||||
|   let capturedStatus: number; | ||||
|  | ||||
|   const response = await webrequest('http://localhost:2346/dynamic', { | ||||
|     interceptors: { | ||||
|       response: [(res) => { | ||||
|         interceptorCalled = true; | ||||
|         capturedStatus = res.status; | ||||
|         return res; | ||||
|       }] | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   expect(interceptorCalled).toEqual(true); | ||||
|   expect(capturedStatus!).toEqual(200); | ||||
| }); | ||||
|  | ||||
| // Test 10: Global interceptors with WebrequestClient | ||||
| tap.test('should support global interceptors', async () => { | ||||
|   const client = new WebrequestClient(); | ||||
|  | ||||
|   let globalInterceptorCalled = false; | ||||
|   client.addRequestInterceptor((req) => { | ||||
|     globalInterceptorCalled = true; | ||||
|     return req; | ||||
|   }); | ||||
|  | ||||
|   await client.request('http://localhost:2346/dynamic'); | ||||
|   expect(globalInterceptorCalled).toEqual(true); | ||||
| }); | ||||
|  | ||||
| // Test 11: Request deduplication | ||||
| tap.test('should deduplicate simultaneous requests', async () => { | ||||
|   const start = Date.now(); | ||||
|  | ||||
|   // Make 3 identical requests simultaneously | ||||
|   const [res1, res2, res3] = await Promise.all([ | ||||
|     webrequest('http://localhost:2346/dynamic', { deduplicate: true }), | ||||
|     webrequest('http://localhost:2346/dynamic', { deduplicate: true }), | ||||
|     webrequest('http://localhost:2346/dynamic', { deduplicate: true }), | ||||
|   ]); | ||||
|  | ||||
|   const [data1, data2, data3] = await Promise.all([ | ||||
|     res1.json(), | ||||
|     res2.json(), | ||||
|     res3.json(), | ||||
|   ]); | ||||
|  | ||||
|   // All should have the same timestamp (same response) | ||||
|   expect(data1.timestamp).toEqual(data2.timestamp); | ||||
|   expect(data2.timestamp).toEqual(data3.timestamp); | ||||
| }); | ||||
|  | ||||
| // Test 12: Timeout | ||||
| tap.test('should support timeout', async () => { | ||||
|   try { | ||||
|     await webrequest('http://localhost:2346/dynamic', { | ||||
|       timeout: 1 // 1ms timeout should fail | ||||
|     }); | ||||
|     throw new Error('Should have timed out'); | ||||
|   } catch (error) { | ||||
|     expect(error.message).toContain('timeout'); | ||||
|   } | ||||
| }); | ||||
|  | ||||
| // Test 13: WebrequestClient with default options | ||||
| tap.test('should support WebrequestClient with defaults', async () => { | ||||
|   const client = new WebrequestClient({ | ||||
|     logging: false, | ||||
|     cacheStrategy: 'network-first', | ||||
|     timeout: 30000 | ||||
|   }); | ||||
|  | ||||
|   const data = await client.getJson('http://localhost:2346/dynamic'); | ||||
|   expect(data).toHaveProperty('data'); | ||||
| }); | ||||
|  | ||||
| // Test 14: Clear cache | ||||
| tap.test('should clear cache', async () => { | ||||
|   // Cache a request | ||||
|   await webrequest('http://localhost:2346/cached', { | ||||
|     cacheStrategy: 'cache-first' | ||||
|   }); | ||||
|  | ||||
|   // Clear cache | ||||
|   await webrequest.clearCache(); | ||||
|  | ||||
|   // This should work even though cache is cleared | ||||
|   const response = await webrequest('http://localhost:2346/cached', { | ||||
|     cacheStrategy: 'cache-first' | ||||
|   }); | ||||
|   expect(response.ok).toEqual(true); | ||||
| }); | ||||
|  | ||||
| // Test 15: Backward compatibility with WebRequest class | ||||
| tap.test('should maintain backward compatibility with v3 API', async () => { | ||||
|   const { WebRequest } = await import('../ts/index.js'); | ||||
|   const client = new WebRequest({ logging: false }); | ||||
|  | ||||
|   const data = await client.getJson('http://localhost:2346/dynamic'); | ||||
|   expect(data).toHaveProperty('data'); | ||||
|  | ||||
|   // Test POST | ||||
|   const postData = await client.postJson('http://localhost:2346/post', { | ||||
|     test: 'data' | ||||
|   }); | ||||
|   expect(postData).toHaveProperty('received'); | ||||
| }); | ||||
|  | ||||
| // Cleanup | ||||
| tap.test('stop test server', async () => { | ||||
|   await testServer.stop(); | ||||
| }); | ||||
|  | ||||
| export default tap.start(); | ||||
		Reference in New Issue
	
	Block a user