Feature: whitelist paths (#1008)

Co-authored-by: thomassth <8331853+thomassth@users.noreply.github.com>
This commit is contained in:
Thomas Kam 2025-04-27 12:58:35 -04:00 committed by GitHub
parent 6d578087c6
commit 6ca387efd9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 236 additions and 8 deletions

View File

@ -182,9 +182,11 @@ exceptions to the method rule.
_(These default status codes follows RFC 7231)_
An object or function that will be tested against the response to indicate if it can be
cached. You can use `statusCheck`, `containsHeader`, `ignoreUrls` and `responseMatch` to test against
cached. You can use `statusCheck`, `containsHeader`, `ignoreUrls`, `allowUrls` and `responseMatch` to test against
the response.
If both `ignoreUrls` & `allowUrls` are matched, `ignoreUrls` take precedence.
```ts{5,8,13}
axios.get<{ auth: { status: string } }>('url', {
cache: {
@ -205,6 +207,9 @@ axios.get<{ auth: { status: string } }>('url', {
// Ensures no request is cached if its url starts with "/api"
ignoreUrls: [/^\/api/]
// only cache request urls that includes "weekly"
allowUrls: ['weekly']
}
}
});

View File

@ -2,6 +2,7 @@ import { deferred } from 'fast-defer';
import type { AxiosCacheInstance, CacheAxiosResponse } from '../cache/axios.js';
import { Header } from '../header/headers.js';
import type { CachedResponse, CachedStorageValue, LoadingStorageValue } from '../storage/types.js';
import { regexOrStringMatch } from '../util/cache-predicate.js';
import type { RequestInterceptor } from './build.js';
import {
type ConfigWithCache,
@ -29,19 +30,14 @@ export function defaultRequestInterceptor(axios: AxiosCacheInstance): RequestInt
// merge defaults with per request configuration
config.cache = { ...axios.defaults.cache, ...config.cache };
// ignoreUrls (blacklist)
if (
typeof config.cache.cachePredicate === 'object' &&
config.cache.cachePredicate.ignoreUrls &&
config.url
) {
for (const url of config.cache.cachePredicate.ignoreUrls) {
if (
url instanceof RegExp
? // Handles stateful regexes
// biome-ignore lint: reduces the number of checks
((url.lastIndex = 0), url.test(config.url))
: config.url.includes(url)
) {
if (regexOrStringMatch(url, config.url)) {
if (__ACI_DEV__) {
axios.debug({
id: config.id,
@ -58,6 +54,47 @@ export function defaultRequestInterceptor(axios: AxiosCacheInstance): RequestInt
}
}
// allowUrls
if (
typeof config.cache.cachePredicate === 'object' &&
config.cache.cachePredicate.allowUrls &&
config.url
) {
let matched = false;
for (const url of config.cache.cachePredicate.allowUrls) {
if (regexOrStringMatch(url, config.url)) {
matched = true;
if (__ACI_DEV__) {
axios.debug({
id: config.id,
msg: `Cached because url (${config.url}) matches allowUrls (${config.cache.cachePredicate.allowUrls})`,
data: {
url: config.url,
cachePredicate: config.cache.cachePredicate
}
});
}
break;
}
}
if (!matched) {
if (__ACI_DEV__) {
axios.debug({
id: config.id,
msg: `Ignored because url (${config.url}) does not match any allowUrls (${config.cache.cachePredicate.allowUrls})`,
data: {
url: config.url,
cachePredicate: config.cache.cachePredicate
}
});
}
return config;
}
}
// Applies sufficient headers to prevent other cache systems to work along with this one
//
// Its currently used before isMethodIn because if the isMethodIn returns false, the request

View File

@ -35,3 +35,21 @@ export async function testCachePredicate<R = unknown, D = unknown>(
return true;
}
/**
* Determines whether a given URL matches a specified pattern, which can be either a string or a regular expression.
*
* @param matchPattern - The pattern to match against
* - If it's a regular expression, it will be reset to ensure consistent behavior for stateful regular expressions.
* - If it's a string, the function checks if the URL contains the string.
* @param configUrl - The URL to test against the provided pattern; normally `config.url`.
* @returns `true` if the `configUrl` matches the `matchPattern`
*/
export function regexOrStringMatch(matchPattern: string | RegExp, configUrl: string) {
if (matchPattern instanceof RegExp) {
matchPattern.lastIndex = 0; // Reset the regex to ensure consistent matching
return matchPattern.test(configUrl);
}
return configUrl.includes(matchPattern);
}

View File

@ -42,6 +42,15 @@ export interface CachePredicateObject<R = unknown, D = unknown> {
* - When only `baseURL` is specified, this property is ignored.
*/
ignoreUrls?: (RegExp | string)[];
/**
* Ignores the request if their url does not match any provided urls and/or regexes.
*
* - It checks against the `request.url` property, `baseURL` is not considered.
* - When only `baseURL` is specified, this property is ignored.
* - If both `ignoreUrls` & `allowUrls` are matched, `ignoreUrls` take precedence.
*/
allowUrls?: (RegExp | string)[];
}
/**

View File

@ -453,6 +453,165 @@ describe('Request Interceptor', () => {
assert.equal(req5.stale, undefined);
});
it('ensures request with urls in allowUrls are cached', async () => {
const axios = mockAxios({
cachePredicate: {
allowUrls: ['keep']
}
});
const [req0, req1] = await Promise.all([axios.get('url'), axios.get('url')]);
assert.equal(req0.cached, false);
assert.equal(req0.stale, undefined);
assert.equal(req1.cached, false);
assert.equal(req1.stale, undefined);
const [req2, req3] = await Promise.all([axios.get('keep-url'), axios.get('keep-url')]);
assert.equal(req2.cached, false);
assert.equal(req2.stale, undefined);
assert.ok(req3.cached);
assert.equal(req3.stale, false);
});
it('ensures request with urls in allowUrls are cached (regex)', async () => {
const axios = mockAxios({
cachePredicate: {
allowUrls: [/keep/]
}
});
const [req0, req1] = await Promise.all([axios.get('my/url'), axios.get('my/url')]);
assert.equal(req0.cached, false);
assert.equal(req0.stale, undefined);
assert.equal(req1.cached, false);
assert.equal(req1.stale, undefined);
const [req2, req3] = await Promise.all([axios.get('keep-url'), axios.get('keep-url')]);
assert.equal(req2.cached, false);
assert.equal(req2.stale, undefined);
assert.ok(req3.cached);
assert.equal(req3.stale, false);
const [req4, req5] = await Promise.all([axios.get('keep/url'), axios.get('keep/url')]);
assert.equal(req4.cached, false);
assert.equal(req4.stale, undefined);
assert.ok(req5.cached);
assert.equal(req5.stale, false);
});
it('ensures request with urls matching ignoreUrls and allowUrls are not cached', async () => {
const axios = mockAxios({
cachePredicate: {
ignoreUrls: ['ignore'],
allowUrls: ['keep']
}
});
const [req0, req1] = await Promise.all([axios.get('ignore/link'), axios.get('ignore/link')]);
assert.equal(req0.cached, false);
assert.equal(req0.stale, undefined);
assert.equal(req1.cached, false);
assert.equal(req1.stale, undefined);
const [req2, req3] = await Promise.all([axios.get('keep/link'), axios.get('keep/link')]);
assert.equal(req2.cached, false);
assert.equal(req2.stale, undefined);
assert.ok(req3.cached);
assert.equal(req3.stale, false);
const [req4, req5] = await Promise.all([axios.get('keep/ignore'), axios.get('keep/ignore')]);
assert.equal(req4.cached, false);
assert.equal(req4.stale, undefined);
assert.equal(req5.cached, false);
assert.equal(req5.stale, undefined);
const [req6, req7] = await Promise.all([
axios.get('ignore/ignore'),
axios.get('ignore/ignore')
]);
assert.equal(req6.cached, false);
assert.equal(req6.stale, undefined);
assert.equal(req7.cached, false);
assert.equal(req7.stale, undefined);
const [req8, req9] = await Promise.all([axios.get('ignore/keep'), axios.get('ignore/keep')]);
assert.equal(req8.cached, false);
assert.equal(req8.stale, undefined);
assert.equal(req9.cached, false);
assert.equal(req9.stale, undefined);
const [req10, req11] = await Promise.all([axios.get('keep/keep'), axios.get('keep/keep')]);
assert.equal(req10.cached, false);
assert.equal(req10.stale, undefined);
assert.ok(req11.cached);
assert.equal(req11.stale, false);
});
it('ensures request with urls matching ignoreUrls and allowUrls are not cached (regex)', async () => {
const axios = mockAxios({
cachePredicate: {
ignoreUrls: [/ignore/],
allowUrls: [/keep/]
}
});
const [req0, req1] = await Promise.all([axios.get('ignore/link'), axios.get('ignore/link')]);
assert.equal(req0.cached, false);
assert.equal(req0.stale, undefined);
assert.equal(req1.cached, false);
assert.equal(req1.stale, undefined);
const [req2, req3] = await Promise.all([axios.get('keep/link'), axios.get('keep/link')]);
assert.equal(req2.cached, false);
assert.equal(req2.stale, undefined);
assert.ok(req3.cached);
assert.equal(req3.stale, false);
const [req4, req5] = await Promise.all([axios.get('keep/ignore'), axios.get('keep/ignore')]);
assert.equal(req4.cached, false);
assert.equal(req4.stale, undefined);
assert.equal(req5.cached, false);
assert.equal(req5.stale, undefined);
const [req6, req7] = await Promise.all([
axios.get('ignore/ignore'),
axios.get('ignore/ignore')
]);
assert.equal(req6.cached, false);
assert.equal(req6.stale, undefined);
assert.equal(req7.cached, false);
assert.equal(req7.stale, undefined);
const [req8, req9] = await Promise.all([axios.get('ignore/keep'), axios.get('ignore/keep')]);
assert.equal(req8.cached, false);
assert.equal(req8.stale, undefined);
assert.equal(req9.cached, false);
assert.equal(req9.stale, undefined);
const [req10, req11] = await Promise.all([axios.get('keep/keep'), axios.get('keep/keep')]);
assert.equal(req10.cached, false);
assert.equal(req10.stale, undefined);
assert.ok(req11.cached);
assert.equal(req11.stale, false);
});
it('clone works with concurrent requests', async () => {
const axios = mockAxios(
{