mirror of
https://github.com/arthurfiorette/axios-cache-interceptor.git
synced 2025-12-08 17:36:16 +00:00
feat: more headers supports, tests and must-revalidate fix (#51)
* feat: must-revalidate and headers enum * test: added more tests * test: test and code fixes * test: more tests
This commit is contained in:
parent
89c9ba442d
commit
92b9ed7abf
4
src/cache/cache.ts
vendored
4
src/cache/cache.ts
vendored
@ -1,6 +1,6 @@
|
||||
import type { Method } from 'axios';
|
||||
import type { Deferred } from 'typed-core/dist/promises/deferred';
|
||||
import type { HeaderInterpreter } from '../header/types';
|
||||
import type { HeadersInterpreter } from '../header/types';
|
||||
import type { AxiosInterceptor } from '../interceptors/types';
|
||||
import type { CachedResponse, CacheStorage } from '../storage/types';
|
||||
import type { CachePredicate, KeyGenerator } from '../util/types';
|
||||
@ -87,7 +87,7 @@ export interface CacheInstance {
|
||||
* The function to parse and interpret response headers. Only used
|
||||
* if cache.interpretHeader is true.
|
||||
*/
|
||||
headerInterpreter: HeaderInterpreter;
|
||||
headerInterpreter: HeadersInterpreter;
|
||||
|
||||
/**
|
||||
* The request interceptor that will be used to handle the cache.
|
||||
|
||||
@ -1,37 +1,48 @@
|
||||
import { parse } from '@tusbar/cache-control';
|
||||
import type { HeaderInterpreter } from './types';
|
||||
import { Header } from '../util/headers';
|
||||
import type { HeaderInterpreter, HeadersInterpreter } from './types';
|
||||
|
||||
export const defaultHeaderInterpreter: HeaderInterpreter = (headers) => {
|
||||
const cacheControl = headers?.['cache-control'];
|
||||
|
||||
if (!cacheControl) {
|
||||
// Checks if Expires header is present
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Expires
|
||||
const expires = headers?.['expires'];
|
||||
|
||||
if (expires) {
|
||||
const milliseconds = Date.parse(expires) - Date.now();
|
||||
|
||||
if (milliseconds > 0) {
|
||||
return milliseconds;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
export const defaultHeaderInterpreter: HeadersInterpreter = (headers = {}) => {
|
||||
if (Header.CacheControl in headers) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return interpretCacheControl(headers[Header.CacheControl]!, headers);
|
||||
}
|
||||
|
||||
if (Header.Expires in headers) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return interpretExpires(headers[Header.Expires]!, headers);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const interpretExpires: HeaderInterpreter = (expires) => {
|
||||
const milliseconds = Date.parse(expires) - Date.now();
|
||||
return milliseconds >= 0 ? milliseconds : false;
|
||||
};
|
||||
|
||||
const interpretCacheControl: HeaderInterpreter = (cacheControl, headers) => {
|
||||
const { noCache, noStore, mustRevalidate, maxAge } = parse(cacheControl);
|
||||
|
||||
// Header told that this response should not be cached.
|
||||
if (noCache || noStore || mustRevalidate) {
|
||||
if (noCache || noStore) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!maxAge) {
|
||||
return undefined;
|
||||
// Already out of date, for cache can be saved, but must be requested again
|
||||
if (mustRevalidate) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return maxAge * 1000;
|
||||
if (maxAge) {
|
||||
const age = headers[Header.Age];
|
||||
|
||||
if (!age) {
|
||||
return maxAge * 1000;
|
||||
}
|
||||
|
||||
return maxAge * 1000 - Number(age) * 1000;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@ -1,11 +1,31 @@
|
||||
/**
|
||||
* Interpret the cache control header, if present.
|
||||
* `false` if cache should not be used.
|
||||
*
|
||||
* `undefined` when provided headers was not enough to determine a valid value.
|
||||
*
|
||||
* `number` containing the number of **milliseconds** to cache the response.
|
||||
*/
|
||||
type MaybeTtl = false | undefined | number;
|
||||
|
||||
/**
|
||||
* Interpret all http headers to determina a time to live.
|
||||
*
|
||||
* @param header The header object to interpret.
|
||||
* @returns `false` if cache should not be used. `undefined` when
|
||||
* provided headers was not enough to determine a valid value. Or a
|
||||
* `number` containing the number of **milliseconds** to cache the response.
|
||||
*/
|
||||
export type HeadersInterpreter = (headers?: Record<string, string>) => MaybeTtl;
|
||||
|
||||
/**
|
||||
* Interpret a single string header
|
||||
*
|
||||
* @param header The header string to interpret.
|
||||
* @returns `false` if cache should not be used. `undefined` when
|
||||
* provided headers was not enough to determine a valid value. Or a
|
||||
* `number` containing the number of **milliseconds** to cache the response.
|
||||
*/
|
||||
export type HeaderInterpreter = (
|
||||
headers?: Record<Lowercase<string>, string>
|
||||
) => false | undefined | number;
|
||||
header: string,
|
||||
headers: Record<string, string>
|
||||
) => MaybeTtl;
|
||||
|
||||
@ -76,10 +76,7 @@ export class CacheRequestInterceptor<D>
|
||||
if (cache.state === 'loading') {
|
||||
const deferred = this.axios.waiting[key];
|
||||
|
||||
/**
|
||||
* If the deferred is undefined, means that the outside has
|
||||
* removed that key from the waiting list
|
||||
*/
|
||||
// Just in case, the deferred doesn't exists.
|
||||
if (!deferred) {
|
||||
await this.axios.storage.remove(key);
|
||||
return config;
|
||||
|
||||
@ -87,7 +87,10 @@ export class CacheResponseInterceptor<R, D>
|
||||
|
||||
let ttl = response.config.cache?.ttl || this.axios.defaults.cache.ttl;
|
||||
|
||||
if (response.config.cache?.interpretHeader) {
|
||||
if (
|
||||
response.config.cache?.interpretHeader ||
|
||||
this.axios.defaults.cache.interpretHeader
|
||||
) {
|
||||
const expirationTime = this.axios.headerInterpreter(response.headers);
|
||||
|
||||
// Cache should not be used
|
||||
|
||||
64
src/util/headers.ts
Normal file
64
src/util/headers.ts
Normal file
@ -0,0 +1,64 @@
|
||||
export enum Header {
|
||||
/**
|
||||
* ```txt
|
||||
* If-Modified-Since: <day-name>, <day> <month> <year> <hour>:<minute>:<second> GMT
|
||||
* ```
|
||||
*
|
||||
* @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since
|
||||
*/
|
||||
IfModifiedSince = 'if-modified-since',
|
||||
|
||||
/**
|
||||
* ```txt
|
||||
* If-None-Match: "<etag_value>"
|
||||
* If-None-Match: "<etag_value>", "<etag_value>", …
|
||||
* If-None-Match: *
|
||||
* ```
|
||||
*
|
||||
* @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match
|
||||
*/
|
||||
IfNoneMatch = 'if-none-match',
|
||||
|
||||
/**
|
||||
* @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
|
||||
*/
|
||||
CacheControl = 'cache-control',
|
||||
|
||||
/**
|
||||
* ```txt
|
||||
* ETag: W / '<etag_value>';
|
||||
* ETag: '<etag_value>';
|
||||
* ```
|
||||
*
|
||||
* @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag
|
||||
*/
|
||||
ETag = 'etag',
|
||||
|
||||
/**
|
||||
* ```txt
|
||||
* Expires: <http-date>
|
||||
* ```
|
||||
*
|
||||
* @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Expires
|
||||
*/
|
||||
Expires = 'expires',
|
||||
|
||||
/**
|
||||
* ```txt
|
||||
* Age: <delta-seconds>
|
||||
* ```
|
||||
*
|
||||
* @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Age
|
||||
*/
|
||||
Age = 'age',
|
||||
|
||||
/**
|
||||
* ```txt
|
||||
* Content-Type: text/html; charset=UTF-8
|
||||
* Content-Type: multipart/form-data; boundary=something
|
||||
* ```
|
||||
*
|
||||
* @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type
|
||||
*/
|
||||
ContentType = 'content-type'
|
||||
}
|
||||
21
test/cache/create.test.ts
vendored
Normal file
21
test/cache/create.test.ts
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
import Axios from 'axios';
|
||||
import { createCache, useCache } from '../../src/cache/create';
|
||||
|
||||
describe('tests header interpreter', () => {
|
||||
it('tests argument composition', () => {
|
||||
const empty = createCache();
|
||||
expect(empty).not.toBeUndefined();
|
||||
|
||||
const axios = Axios.create();
|
||||
const withAxios = useCache(axios);
|
||||
expect(withAxios).not.toBeUndefined();
|
||||
|
||||
const withDefaults = createCache({
|
||||
axios: { baseURL: 'base-url' },
|
||||
cache: { ttl: 1234 }
|
||||
});
|
||||
expect(withDefaults).not.toBeUndefined();
|
||||
expect(withDefaults.defaults.cache.ttl).toBe(1234);
|
||||
expect(withDefaults.defaults.baseURL).toBe('base-url');
|
||||
});
|
||||
});
|
||||
@ -1,47 +1,57 @@
|
||||
import { defaultHeaderInterpreter } from '../../src/header/interpreter';
|
||||
import { Header } from '../../src/util/headers';
|
||||
|
||||
describe('tests header interpreter', () => {
|
||||
it('tests without cache-control header', () => {
|
||||
const noHeader = defaultHeaderInterpreter({});
|
||||
const noHeader = defaultHeaderInterpreter();
|
||||
expect(noHeader).toBeUndefined();
|
||||
|
||||
const emptyHeader = defaultHeaderInterpreter({ 'cache-control': 'public' });
|
||||
const emptyHeader = defaultHeaderInterpreter({ [Header.CacheControl]: '' });
|
||||
expect(emptyHeader).toBeUndefined();
|
||||
});
|
||||
|
||||
it('tests with cache preventing headers', () => {
|
||||
const noStore = defaultHeaderInterpreter({
|
||||
'cache-control': 'no-store'
|
||||
[Header.CacheControl]: 'no-store'
|
||||
});
|
||||
|
||||
expect(noStore).toBe(false);
|
||||
|
||||
const noCache = defaultHeaderInterpreter({
|
||||
'cache-control': 'no-cache'
|
||||
[Header.CacheControl]: 'no-cache'
|
||||
});
|
||||
|
||||
expect(noCache).toBe(false);
|
||||
|
||||
const mustRevalidate = defaultHeaderInterpreter({
|
||||
'cache-control': 'must-revalidate'
|
||||
[Header.CacheControl]: 'must-revalidate'
|
||||
});
|
||||
|
||||
expect(mustRevalidate).toBe(false);
|
||||
expect(mustRevalidate).toBe(0);
|
||||
});
|
||||
|
||||
it('tests with maxAge header for 10 seconds', () => {
|
||||
const result = defaultHeaderInterpreter({
|
||||
'cache-control': 'max-age=10'
|
||||
[Header.CacheControl]: 'max-age=10'
|
||||
});
|
||||
|
||||
// 10 Seconds in milliseconds
|
||||
expect(result).toBe(10 * 1000);
|
||||
});
|
||||
|
||||
it('tests with maxAge=10 and age=7 headers', () => {
|
||||
const result = defaultHeaderInterpreter({
|
||||
[Header.CacheControl]: 'max-age=10',
|
||||
[Header.Age]: '3'
|
||||
});
|
||||
|
||||
expect(result).toBe(7 * 1000);
|
||||
});
|
||||
|
||||
it('tests with expires and cache-control present', () => {
|
||||
const result = defaultHeaderInterpreter({
|
||||
'cache-control': 'max-age=10',
|
||||
expires: new Date(new Date().getFullYear() + 1, 1, 1).toISOString()
|
||||
[Header.CacheControl]: 'max-age=10',
|
||||
[Header.Expires]: new Date(new Date().getFullYear() + 1, 1, 1).toUTCString()
|
||||
});
|
||||
|
||||
// expires should be ignored
|
||||
@ -51,7 +61,7 @@ describe('tests header interpreter', () => {
|
||||
|
||||
it('tests with past expires', () => {
|
||||
const result = defaultHeaderInterpreter({
|
||||
expires: new Date(new Date().getFullYear() - 1, 1, 1).toISOString()
|
||||
[Header.Expires]: new Date(new Date().getFullYear() - 1, 1, 1).toUTCString()
|
||||
});
|
||||
|
||||
// Past means cache invalid
|
||||
@ -62,7 +72,7 @@ describe('tests header interpreter', () => {
|
||||
const date = new Date(new Date().getFullYear() + 1, 1, 1);
|
||||
|
||||
const result = defaultHeaderInterpreter({
|
||||
expires: date.toISOString()
|
||||
[Header.Expires]: date.toUTCString()
|
||||
});
|
||||
|
||||
const approx = date.getTime() - Date.now();
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { Header } from '../../src/util/headers';
|
||||
import { mockAxios } from '../mocks/axios';
|
||||
|
||||
describe('test request interceptor', () => {
|
||||
@ -21,7 +22,7 @@ describe('test request interceptor', () => {
|
||||
});
|
||||
|
||||
it('tests header interpreter integration', async () => {
|
||||
const axiosNoCache = mockAxios({}, { 'cache-control': 'no-cache' });
|
||||
const axiosNoCache = mockAxios({}, { [Header.CacheControl]: 'no-cache' });
|
||||
|
||||
// Make first request to cache it
|
||||
await axiosNoCache.get('', { cache: { interpretHeader: true } });
|
||||
@ -29,7 +30,10 @@ describe('test request interceptor', () => {
|
||||
|
||||
expect(resultNoCache.cached).toBe(false);
|
||||
|
||||
const axiosCache = mockAxios({}, { 'cache-control': `maxAge=${60 * 60 * 24 * 365}` });
|
||||
const axiosCache = mockAxios(
|
||||
{},
|
||||
{ [Header.CacheControl]: `max-age=${60 * 60 * 24 * 365}` }
|
||||
);
|
||||
|
||||
// Make first request to cache it
|
||||
await axiosCache.get('', { cache: { interpretHeader: true } });
|
||||
@ -55,4 +59,24 @@ describe('test request interceptor', () => {
|
||||
|
||||
expect(cache.state).toBe('empty');
|
||||
});
|
||||
|
||||
it('tests with blank cache-control header', async () => {
|
||||
const defaultTtl = 60;
|
||||
|
||||
const axios = mockAxios(
|
||||
{ ttl: defaultTtl, interpretHeader: true },
|
||||
{ [Header.CacheControl]: '' }
|
||||
);
|
||||
|
||||
const { id } = await axios.get('key01', {
|
||||
cache: {
|
||||
interpretHeader: true
|
||||
}
|
||||
});
|
||||
|
||||
const cache = await axios.storage.get(id);
|
||||
|
||||
expect(cache.state).toBe('cached');
|
||||
expect(cache.ttl).toBe(defaultTtl);
|
||||
});
|
||||
});
|
||||
|
||||
@ -11,13 +11,16 @@ export function mockAxios(
|
||||
|
||||
// Axios interceptors are a stack, so apply this after the cache interceptor
|
||||
axios.interceptors.request.use((config) => {
|
||||
config.adapter = async (config) => ({
|
||||
data: true,
|
||||
status: 200,
|
||||
statusText: '200 OK',
|
||||
headers,
|
||||
config
|
||||
});
|
||||
config.adapter = async (config) => {
|
||||
await 0; // Jumps to next tick of nodejs event loop
|
||||
return {
|
||||
data: true,
|
||||
status: 200,
|
||||
statusText: '200 OK',
|
||||
headers,
|
||||
config
|
||||
};
|
||||
};
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user