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:
Arthur Fiorette 2021-11-11 14:16:37 -03:00 committed by GitHub
parent 89c9ba442d
commit 92b9ed7abf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 207 additions and 54 deletions

4
src/cache/cache.ts vendored
View File

@ -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.

View File

@ -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;
};

View File

@ -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;

View File

@ -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;

View File

@ -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
View 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
View 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');
});
});

View File

@ -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();

View File

@ -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);
});
});

View File

@ -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;
});