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