diff --git a/src/cache/cache.ts b/src/cache/cache.ts index afbb3b2..90d78f8 100644 --- a/src/cache/cache.ts +++ b/src/cache/cache.ts @@ -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. diff --git a/src/header/interpreter.ts b/src/header/interpreter.ts index 7747f02..ec1ec0c 100644 --- a/src/header/interpreter.ts +++ b/src/header/interpreter.ts @@ -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; }; diff --git a/src/header/types.ts b/src/header/types.ts index cc43718..f962ac2 100644 --- a/src/header/types.ts +++ b/src/header/types.ts @@ -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) => 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, string> -) => false | undefined | number; + header: string, + headers: Record +) => MaybeTtl; diff --git a/src/interceptors/request.ts b/src/interceptors/request.ts index 7abd9c7..c9cb9f6 100644 --- a/src/interceptors/request.ts +++ b/src/interceptors/request.ts @@ -76,10 +76,7 @@ export class CacheRequestInterceptor 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; diff --git a/src/interceptors/response.ts b/src/interceptors/response.ts index df06931..461d391 100644 --- a/src/interceptors/response.ts +++ b/src/interceptors/response.ts @@ -87,7 +87,10 @@ export class CacheResponseInterceptor 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 diff --git a/src/util/headers.ts b/src/util/headers.ts new file mode 100644 index 0000000..32d9687 --- /dev/null +++ b/src/util/headers.ts @@ -0,0 +1,64 @@ +export enum Header { + /** + * ```txt + * If-Modified-Since: , :: GMT + * ``` + * + * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since + */ + IfModifiedSince = 'if-modified-since', + + /** + * ```txt + * If-None-Match: "" + * If-None-Match: "", "", … + * 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: ''; + * ``` + * + * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag + */ + ETag = 'etag', + + /** + * ```txt + * Expires: + * ``` + * + * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Expires + */ + Expires = 'expires', + + /** + * ```txt + * Age: + * ``` + * + * @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' +} diff --git a/test/cache/create.test.ts b/test/cache/create.test.ts new file mode 100644 index 0000000..8fc6c74 --- /dev/null +++ b/test/cache/create.test.ts @@ -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'); + }); +}); diff --git a/test/header/interpreter.test.ts b/test/header/interpreter.test.ts index e3e8b1b..bc88f7c 100644 --- a/test/header/interpreter.test.ts +++ b/test/header/interpreter.test.ts @@ -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(); diff --git a/test/interceptors/response.test.ts b/test/interceptors/response.test.ts index 74a64ce..78255d4 100644 --- a/test/interceptors/response.test.ts +++ b/test/interceptors/response.test.ts @@ -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); + }); }); diff --git a/test/mocks/axios.ts b/test/mocks/axios.ts index ad30e65..189be6e 100644 --- a/test/mocks/axios.ts +++ b/test/mocks/axios.ts @@ -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; });