diff --git a/README.md b/README.md index b49f4ed..564a837 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,8 @@ const resp2 = await api.get('https://api.example.com/'); - [request.cache.methods](#requestcachemethods) - [request.cache.cachePredicate](#requestcachecachepredicate) - [request.cache.update](#requestcacheupdate) + - [request.cache.etag](#requestcacheetag) + - [request.cache.modifiedSince](#requestcachemodifiedsince) - [License](#license) - [Contact](#contact) @@ -384,6 +386,20 @@ axios.get('url', { }); ``` +### request.cache.etag + +If the request should handle `ETag` and `If-None-Match support`. Use a string to force a +custom static value or true to use the previous response ETag. To use `true` (automatic +etag handling), `interpretHeader` option must be set to `true`. Default: `false` + +### request.cache.modifiedSince + +Use `If-Modified-Since` header in this request. Use a date to force a custom static value +or true to use the last cached timestamp. If never cached before, the header is not set. +If `interpretHeader` is set and a `Last-Modified` header is sent then value from that +header is used, otherwise cache creation timestamp will be sent in `If-Modified-Since`. +Default: `true` +
## License diff --git a/src/cache/axios.ts b/src/cache/axios.ts index 93931cc..1d26c3f 100644 --- a/src/cache/axios.ts +++ b/src/cache/axios.ts @@ -63,7 +63,7 @@ export interface AxiosCacheInstance extends CacheInstance, AxiosInstance { * @template D The type that the request body use */ >( - config?: CacheRequestConfig + config: CacheRequestConfig ): Promise; /** * @template T The type returned by this response diff --git a/src/cache/cache.ts b/src/cache/cache.ts index 7317296..d9ebf7e 100644 --- a/src/cache/cache.ts +++ b/src/cache/cache.ts @@ -55,9 +55,28 @@ export type CacheProperties = { * The id used is the same as the id on `CacheRequestConfig['id']`, * auto-generated or not. * - * @default {} + * @default {{}} */ update: Record; + + /** + * If the request should handle ETag and If-None-Match support. Use + * a string to force a custom value or true to use the response ETag + * + * @default false + * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag + */ + etag: string | boolean; + + /** + * Use If-Modified-Since header in this request. Use a date to force + * a custom value or true to use the last cached timestamp. If never + * cached before, the header is not set. + * + * @default false + * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-Modified-Since + */ + modifiedSince: Date | boolean; }; export interface CacheInstance { diff --git a/src/cache/create.ts b/src/cache/create.ts index 801ccf1..c417cf1 100644 --- a/src/cache/create.ts +++ b/src/cache/create.ts @@ -44,9 +44,9 @@ export function useCache( ttl: 1000 * 60 * 5, interpretHeader: false, methods: ['get'], - cachePredicate: { - statusCheck: [200, 399] - }, + cachePredicate: { statusCheck: [200, 399] }, + etag: false, + modifiedSince: false, update: {}, ...cacheOptions } diff --git a/src/interceptors/request.ts b/src/interceptors/request.ts index c9cb9f6..1fdc21a 100644 --- a/src/interceptors/request.ts +++ b/src/interceptors/request.ts @@ -1,4 +1,6 @@ +import type { AxiosRequestConfig, Method } from 'axios'; import { deferred } from 'typed-core/dist/promises/deferred'; +import type { CacheProperties } from '..'; import type { AxiosCacheInstance, CacheAxiosResponse, @@ -7,8 +9,10 @@ import type { import type { CachedResponse, CachedStorageValue, - LoadingStorageValue + LoadingStorageValue, + StaleStorageValue } from '../storage/types'; +import { Header } from '../util/headers'; import type { AxiosInterceptor } from './types'; export class CacheRequestInterceptor @@ -16,21 +20,23 @@ export class CacheRequestInterceptor { constructor(readonly axios: AxiosCacheInstance) {} - use = (): void => { + public use = (): void => { this.axios.interceptors.request.use(this.onFulfilled); }; - onFulfilled = async (config: CacheRequestConfig): Promise> => { - // Skip cache + public onFulfilled = async ( + config: CacheRequestConfig + ): Promise> => { if (config.cache === false) { return config; } - // Only cache specified methods - const allowedMethods = config.cache?.methods || this.axios.defaults.cache.methods; + // merge defaults with per request configuration + config.cache = { ...this.axios.defaults.cache, ...config.cache }; if ( - !allowedMethods.some((method) => (config.method || 'get').toLowerCase() == method) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + !this.isMethodAllowed(config.method!, config.cache) ) { return config; } @@ -41,7 +47,7 @@ export class CacheRequestInterceptor let cache = await this.axios.storage.get(key); // Not cached, continue the request, and mark it as fetching - emptyState: if (cache.state == 'empty') { + emptyOrStale: if (cache.state == 'empty' || cache.state === 'stale') { /** * This checks for simultaneous access to a new key. The js * event loop jumps on the first await statement, so the second @@ -51,7 +57,7 @@ export class CacheRequestInterceptor cache = (await this.axios.storage.get(key)) as | CachedStorageValue | LoadingStorageValue; - break emptyState; + break emptyOrStale; } // Create a deferred to resolve other requests for the same key when it's completed @@ -65,9 +71,18 @@ export class CacheRequestInterceptor await this.axios.storage.set(key, { state: 'loading', - ttl: config.cache?.ttl + data: cache.data }); + if (cache.state === 'stale') { + //@ts-expect-error type infer couldn't resolve this + this.setRevalidationHeaders(cache, config); + } + + config.validateStatus = CacheRequestInterceptor.createValidateStatus( + config.validateStatus + ); + return config; } @@ -77,6 +92,7 @@ export class CacheRequestInterceptor const deferred = this.axios.waiting[key]; // Just in case, the deferred doesn't exists. + /* istanbul ignore if 'really hard to test' */ if (!deferred) { await this.axios.storage.remove(key); return config; @@ -109,4 +125,56 @@ export class CacheRequestInterceptor return config; }; + + private isMethodAllowed = ( + method: Method, + properties: Partial + ): boolean => { + const requestMethod = method.toLowerCase(); + + for (const method of properties.methods || []) { + if (method.toLowerCase() === requestMethod) { + return true; + } + } + + return false; + }; + + private setRevalidationHeaders = ( + cache: StaleStorageValue, + config: CacheRequestConfig & { cache: Partial } + ): void => { + config.headers ||= {}; + + const { etag, modifiedSince } = config.cache; + + if (etag) { + const etagValue = etag === true ? cache.data?.headers[Header.ETag] : etag; + if (etagValue) { + config.headers[Header.IfNoneMatch] = etagValue; + } + } + + if (modifiedSince) { + config.headers[Header.IfModifiedSince] = + modifiedSince === true + ? // If last-modified is not present, use the createdAt timestamp + cache.data.headers[Header.LastModified] || + new Date(cache.createdAt).toUTCString() + : modifiedSince.toUTCString(); + } + }; + + /** + * Creates a new validateStatus function that will use the one + * already used and also accept status code 304. + */ + static createValidateStatus = (oldValidate?: AxiosRequestConfig['validateStatus']) => { + return (status: number): boolean => { + return oldValidate + ? oldValidate(status) || status === 304 + : (status >= 200 && status < 300) || status === 304; + }; + }; } diff --git a/src/interceptors/response.ts b/src/interceptors/response.ts index 461d391..f4bdf64 100644 --- a/src/interceptors/response.ts +++ b/src/interceptors/response.ts @@ -4,6 +4,7 @@ import type { AxiosCacheInstance, CacheAxiosResponse } from '../cache/axios'; import type { CacheProperties } from '../cache/cache'; import type { CachedStorageValue } from '../storage/types'; import { checkPredicateObject } from '../util/cache-predicate'; +import { Header } from '../util/headers'; import { updateCache } from '../util/update-cache'; import type { AxiosInterceptor } from './types'; @@ -12,16 +13,128 @@ export class CacheResponseInterceptor { constructor(readonly axios: AxiosCacheInstance) {} - use = (): void => { + public use = (): void => { this.axios.interceptors.response.use(this.onFulfilled); }; + public onFulfilled = async ( + axiosResponse: AxiosResponse + ): Promise> => { + const response = this.cachedResponse(axiosResponse); + + // Response is already cached + if (response.cached) { + return response; + } + + // Skip cache + // either false or weird behavior, config.cache should always exists, from global config merge at least + if (!response.config.cache) { + return { ...response, cached: false }; + } + + const cacheConfig = response.config.cache as CacheProperties; + + const cache = await this.axios.storage.get(response.id); + + if ( + // If the request interceptor had a problem + cache.state === 'stale' || + cache.state === 'empty' || + // Should not hit here because of later response.cached check + cache.state === 'cached' + ) { + return response; + } + + // Config told that this response should be cached. + if ( + // For 'loading' values (post stale), this check was already run in the past. + !cache.data && + !this.testCachePredicate(response, cacheConfig) + ) { + await this.rejectResponse(response.id); + return response; + } + + // avoid remnant headers from remote server to break implementation + delete response.headers[Header.XAxiosCacheEtag]; + delete response.headers[Header.XAxiosCacheLastModified]; + + if (cacheConfig.etag && cacheConfig.etag !== true) { + response.headers[Header.XAxiosCacheEtag] = cacheConfig.etag; + } + + if (cacheConfig.modifiedSince) { + response.headers[Header.XAxiosCacheLastModified] = + cacheConfig.modifiedSince === true + ? 'use-cache-timestamp' + : cacheConfig.modifiedSince.toUTCString(); + } + + let ttl = cacheConfig.ttl || -1; // always set from global config + + if (cacheConfig?.interpretHeader) { + const expirationTime = this.axios.headerInterpreter(response.headers); + + // Cache should not be used + if (expirationTime === false) { + await this.rejectResponse(response.id); + return response; + } + + ttl = expirationTime || expirationTime === 0 ? expirationTime : ttl; + } + + const data = + response.status == 304 && cache.data + ? (() => { + // Rust syntax <3 + response.cached = true; + response.data = cache.data.data; + response.status = cache.data.status; + response.statusText = cache.data.statusText; + + // We may have new headers. + response.headers = { + ...cache.data.headers, + ...response.headers + }; + + return cache.data; + })() + : extract(response, ['data', 'headers', 'status', 'statusText']); + + const newCache: CachedStorageValue = { + state: 'cached', + ttl, + createdAt: Date.now(), + data + }; + + // Update other entries before updating himself + if (cacheConfig?.update) { + updateCache(this.axios.storage, response.data, cacheConfig.update); + } + + const deferred = this.axios.waiting[response.id]; + + // Resolve all other requests waiting for this response + await deferred?.resolve(newCache.data); + delete this.axios.waiting[response.id]; + + // Define this key as cache on the storage + await this.axios.storage.set(response.id, newCache); + + // Return the response with cached as false, because it was not cached at all + return response; + }; + private testCachePredicate = ( response: AxiosResponse, - cache?: Partial + cache: CacheProperties ): boolean => { - const cachePredicate = - cache?.cachePredicate || this.axios.defaults.cache.cachePredicate; + const cachePredicate = cache.cachePredicate; return ( (typeof cachePredicate === 'function' && cachePredicate(response)) || @@ -42,88 +155,15 @@ export class CacheResponseInterceptor delete this.axios.waiting[key]; }; - onFulfilled = async ( - axiosResponse: AxiosResponse - ): Promise> => { - const key = this.axios.generateKey(axiosResponse.config); - - const response: CacheAxiosResponse = { - id: key, - + private cachedResponse = (response: AxiosResponse): CacheAxiosResponse => { + return { + id: this.axios.generateKey(response.config), /** * The request interceptor response.cache will return true or * undefined. And true only when the response was cached. */ - cached: (axiosResponse as CacheAxiosResponse).cached || false, - ...axiosResponse + cached: (response as CacheAxiosResponse).cached || false, + ...response }; - - // Skip cache - if (response.config.cache === false) { - return { ...response, cached: false }; - } - - // Response is already cached - if (response.cached) { - return response; - } - - const cache = await this.axios.storage.get(key); - - /** - * From now on, the cache and response represents the state of the - * first response to a request, which has not yet been cached or - * processed before. - */ - if (cache.state !== 'loading') { - return response; - } - - // Config told that this response should be cached. - if (!this.testCachePredicate(response, response.config.cache)) { - await this.rejectResponse(key); - return response; - } - - let ttl = response.config.cache?.ttl || this.axios.defaults.cache.ttl; - - if ( - response.config.cache?.interpretHeader || - this.axios.defaults.cache.interpretHeader - ) { - const expirationTime = this.axios.headerInterpreter(response.headers); - - // Cache should not be used - if (expirationTime === false) { - await this.rejectResponse(key); - return response; - } - - ttl = expirationTime ? expirationTime : ttl; - } - - const newCache: CachedStorageValue = { - state: 'cached', - ttl: ttl, - createdAt: Date.now(), - data: extract(response, ['data', 'headers', 'status', 'statusText']) - }; - - // Update other entries before updating himself - if (response.config.cache?.update) { - updateCache(this.axios.storage, response.data, response.config.cache.update); - } - - const deferred = this.axios.waiting[key]; - - // Resolve all other requests waiting for this response - await deferred?.resolve(newCache.data); - delete this.axios.waiting[key]; - - // Define this key as cache on the storage - await this.axios.storage.set(key, newCache); - - // Return the response with cached as false, because it was not cached at all - return response; }; } diff --git a/src/storage/browser.ts b/src/storage/browser.ts index 03fbfe5..b73640a 100644 --- a/src/storage/browser.ts +++ b/src/storage/browser.ts @@ -1,12 +1,13 @@ +import type { NotEmptyStorageValue } from '..'; import { AxiosStorage } from './storage'; -import type { EmptyStorageValue, StorageValue } from './types'; +import type { StorageValue } from './types'; export class BrowserAxiosStorage extends AxiosStorage { public static DEFAULT_KEY_PREFIX = 'a-c-i'; /** - * @param storage any browser storage, like sessionStorage or localStorage - * @param prefix the key prefix to use on all keys. + * @param storage Any browser storage, like sessionStorage or localStorage + * @param prefix The key prefix to use on all keys. */ constructor( readonly storage: Storage, @@ -15,30 +16,16 @@ export class BrowserAxiosStorage extends AxiosStorage { super(); } - public get = (key: string): StorageValue => { - const prefixedKey = `${this.prefix}:${key}`; - - const json = this.storage.getItem(prefixedKey); - - if (!json) { - return { state: 'empty' }; - } - - const parsed = JSON.parse(json); - - if (!AxiosStorage.isValid(parsed)) { - this.storage.removeItem(prefixedKey); - return { state: 'empty' }; - } - - return parsed; + public find = async (key: string): Promise => { + const json = this.storage.getItem(`${this.prefix}:${key}`); + return json ? JSON.parse(json) : { state: 'empty' }; }; - public set = (key: string, value: Exclude): void => { + public set = async (key: string, value: NotEmptyStorageValue): Promise => { return this.storage.setItem(`${this.prefix}:${key}`, JSON.stringify(value)); }; - public remove = (key: string): void | Promise => { + public remove = async (key: string): Promise => { return this.storage.removeItem(`${this.prefix}:${key}`); }; } diff --git a/src/storage/memory.ts b/src/storage/memory.ts index 2eb02db..63c61d6 100644 --- a/src/storage/memory.ts +++ b/src/storage/memory.ts @@ -1,31 +1,20 @@ import { AxiosStorage } from './storage'; -import type { CachedStorageValue, LoadingStorageValue, StorageValue } from './types'; +import type { NotEmptyStorageValue, StorageValue } from './types'; export class MemoryAxiosStorage extends AxiosStorage { constructor(readonly storage: Record = {}) { super(); } - public get = (key: string): StorageValue => { - const value = this.storage[key]; - - if (!value) { - return { state: 'empty' }; - } - - if (!AxiosStorage.isValid(value)) { - this.remove(key); - return { state: 'empty' }; - } - - return value; + public find = async (key: string): Promise => { + return this.storage[key] || { state: 'empty' }; }; - public set = (key: string, value: CachedStorageValue | LoadingStorageValue): void => { + public set = async (key: string, value: NotEmptyStorageValue): Promise => { this.storage[key] = value; }; - public remove = (key: string): void => { + public remove = async (key: string): Promise => { delete this.storage[key]; }; } diff --git a/src/storage/storage.ts b/src/storage/storage.ts index b53a552..0264af9 100644 --- a/src/storage/storage.ts +++ b/src/storage/storage.ts @@ -1,38 +1,65 @@ -import type { EmptyStorageValue, StorageValue } from './types'; +import type { CachedStorageValue, NotEmptyStorageValue } from '..'; +import { Header } from '../util/headers'; +import type { StaleStorageValue, StorageValue } from './types'; export abstract class AxiosStorage { /** - * Returns the cached value for the given key. Must handle cache - * miss and staling by returning a new `StorageValue` with `empty` state. - * - * @see {AxiosStorage#isValid} + * Returns the cached value for the given key. The get method is + * what takes care to invalidate the values. */ - public abstract get: (key: string) => Promise | StorageValue; + protected abstract find: (key: string) => Promise; /** * Sets a new value for the given key * * Use CacheStorage.remove(key) to define a key to 'empty' state. */ - public abstract set: ( - key: string, - value: Exclude - ) => Promise | void; + public abstract set: (key: string, value: NotEmptyStorageValue) => Promise; /** * Removes the value for the given key */ - public abstract remove: (key: string) => Promise | void; + public abstract remove: (key: string) => Promise; - /** - * Returns true if a storage value can still be used by checking his - * createdAt and ttl values. - */ - static isValid = (value?: StorageValue): boolean | 'unknown' => { - if (value?.state === 'cached') { - return value.createdAt + value.ttl > Date.now(); + public get = async (key: string): Promise => { + const value = await this.find(key); + + if ( + value.state !== 'cached' || + // Cached and fresh value + value.createdAt + value.ttl > Date.now() + ) { + return value; } - return true; + // Check if his can stale value. + if (AxiosStorage.keepIfStale(value)) { + const stale: StaleStorageValue = { + data: value.data, + state: 'stale', + createdAt: value.createdAt + }; + await this.set(key, stale); + return stale; + } + + await this.remove(key); + return { state: 'empty' }; + }; + + /** + * Returns true if a invalid cache should still be kept + */ + static keepIfStale = ({ data }: CachedStorageValue): boolean => { + if (data?.headers) { + return ( + Header.ETag in data.headers || + Header.LastModified in data.headers || + Header.XAxiosCacheEtag in data.headers || + Header.XAxiosCacheLastModified in data.headers + ); + } + + return false; }; } diff --git a/src/storage/types.ts b/src/storage/types.ts index 8d6eca1..2fe7d50 100644 --- a/src/storage/types.ts +++ b/src/storage/types.ts @@ -8,7 +8,20 @@ export type CachedResponse = { /** * The value returned for a given key. */ -export type StorageValue = CachedStorageValue | LoadingStorageValue | EmptyStorageValue; +export type StorageValue = + | StaleStorageValue + | CachedStorageValue + | LoadingStorageValue + | EmptyStorageValue; + +export type NotEmptyStorageValue = Exclude; + +export type StaleStorageValue = { + data: CachedResponse; + ttl?: undefined; + createdAt: number; + state: 'stale'; +}; export type CachedStorageValue = { data: CachedResponse; @@ -22,7 +35,11 @@ export type CachedStorageValue = { }; export type LoadingStorageValue = { - data?: undefined; + /** + * Only present if the previous state was `stale`. So, in case the + * new response comes without a value, this data is used + */ + data?: CachedResponse; ttl?: number; /** diff --git a/src/util/headers.ts b/src/util/headers.ts index 32d9687..2064be6 100644 --- a/src/util/headers.ts +++ b/src/util/headers.ts @@ -8,6 +8,15 @@ export enum Header { */ IfModifiedSince = 'if-modified-since', + /** + * ```txt + * Last-Modified: , :: GMT + * ``` + * + * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Last-Modified + */ + LastModified = 'last-modified', + /** * ```txt * If-None-Match: "" @@ -26,8 +35,8 @@ export enum Header { /** * ```txt - * ETag: W / ''; - * ETag: ''; + * ETag: W/"" + * ETag: "" * ``` * * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag @@ -60,5 +69,32 @@ export enum Header { * * @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type */ - ContentType = 'content-type' + ContentType = 'content-type', + + /** + * Used internally to mark the cache item as being revalidatable and + * enabling stale cache state Contains a string of ASCII characters + * that can be used as ETag for `If-Match` header Provided by user + * using `cache.etag` value. + * + * ```txt + * X-Axios-Cache-Etag: "" + * ``` + */ + XAxiosCacheEtag = 'x-axios-cache-etag', + + /** + * Used internally to mark the cache item as being revalidatable and + * enabling stale cache state may contain `'use-cache-timestamp'` if + * `cache.modifiedSince` is `true`, otherwise will contain a date + * from `cache.modifiedSince`. If a date is provided, it can be used + * for `If-Modified-Since` header, otherwise the cache timestamp can + * be used for `If-Modified-Since` header. + * + * ```txt + * X-Axios-Cache-Last-Modified: , :: GMT + * X-Axios-Cache-Last-Modified: use-cache-timestamp + * ``` + */ + XAxiosCacheLastModified = 'x-axios-cache-last-modified' } diff --git a/src/util/update-cache.ts b/src/util/update-cache.ts index 13a832a..c9ac749 100644 --- a/src/util/update-cache.ts +++ b/src/util/update-cache.ts @@ -1,13 +1,21 @@ import type { AxiosStorage } from '../storage/storage'; -import type { CachedStorageValue, EmptyStorageValue } from '../storage/types'; +import type { + CachedStorageValue, + LoadingStorageValue, + StorageValue +} from '../storage/types'; export type CacheUpdater = | 'delete' | (( - cached: EmptyStorageValue | CachedStorageValue, + cached: Exclude, newData: any ) => CachedStorageValue | void); +/** + * Function to update all caches, from CacheProperties.update, with + * the new data. + */ export async function updateCache( storage: AxiosStorage, data: T, @@ -17,7 +25,7 @@ export async function updateCache( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const value = entries[cacheKey]!; - if (value == 'delete') { + if (value === 'delete') { await storage.remove(cacheKey); continue; } diff --git a/test/header/interpreter.test.ts b/test/header/interpreter.test.ts index 3dbb5e3..3cefc2c 100644 --- a/test/header/interpreter.test.ts +++ b/test/header/interpreter.test.ts @@ -39,7 +39,7 @@ describe('tests header interpreter', () => { expect(result).toBe(10 * 1000); }); - it('tests with maxAge=10 and age=7 headers', () => { + it('tests with maxAge=10 and age=3 headers', () => { const result = defaultHeaderInterpreter({ [Header.CacheControl]: 'max-age=10', [Header.Age]: '3' diff --git a/test/interceptors/etag.test.ts b/test/interceptors/etag.test.ts new file mode 100644 index 0000000..fab5849 --- /dev/null +++ b/test/interceptors/etag.test.ts @@ -0,0 +1,81 @@ +import { Header } from '../../src/util/headers'; +import { mockAxios } from '../mocks/axios'; +import { sleep } from '../utils'; + +describe('ETag handling', () => { + it('tests etag header handling', async () => { + const axios = mockAxios({}, { etag: 'fakeEtag', 'cache-control': 'max-age=1' }); + const config = { cache: { interpretHeader: true, etag: true } }; + + // initial request + await axios.get('', config); + + const response = await axios.get('', config); + expect(response.cached).toBe(true); + expect(response.data).toBe(true); + + // Sleep entire max age time. + await sleep(1000); + + const response2 = await axios.get('', config); + // from revalidation + expect(response2.cached).toBe(true); + // ensure value from stale cache is kept + expect(response2.data).toBe(true); + }); + + it('tests etag header handling in global config', async () => { + const axios = mockAxios( + { interpretHeader: true, etag: true }, + { etag: 'fakeEtag', 'cache-control': 'max-age=1' } + ); + + // initial request + await axios.get(''); + + const response = await axios.get(''); + expect(response.cached).toBe(true); + expect(response.data).toBe(true); + + // Sleep entire max age time. + await sleep(1000); + + const response2 = await axios.get(''); + // from revalidation + expect(response2.cached).toBe(true); + // ensure value from stale cache is kept + expect(response2.data).toBe(true); + }); + + it('tests "must revalidate" handling with etag', async () => { + const axios = mockAxios({}, { etag: 'fakeEtag', 'cache-control': 'must-revalidate' }); + const config = { cache: { interpretHeader: true, etag: true } }; + + await axios.get('', config); + + // 0ms cache + await sleep(1); + + const response = await axios.get('', config); + // from etag revalidation + expect(response.cached).toBe(true); + expect(response.data).toBe(true); + }); + + it('tests custom e-tag', async () => { + const axios = mockAxios({ ttl: 0 }, { etag: 'fake-etag-2' }); + const config = { cache: { interpretHeader: true, etag: 'fake-etag' } }; + + const response = await axios.get('', config); + expect(response.cached).toBe(false); + expect(response.data).toBe(true); + expect(response.config.headers?.[Header.IfModifiedSince]).toBeUndefined(); + expect(response.headers?.[Header.LastModified]).toBeUndefined(); + + const response2 = await axios.get('', config); + expect(response2.cached).toBe(true); + expect(response2.data).toBe(true); + expect(response2.config.headers?.[Header.IfNoneMatch]).toBe('fake-etag'); + expect(response2.headers?.[Header.ETag]).toBe('fake-etag-2'); + }); +}); diff --git a/test/interceptors/last-modified.test.ts b/test/interceptors/last-modified.test.ts new file mode 100644 index 0000000..14f541a --- /dev/null +++ b/test/interceptors/last-modified.test.ts @@ -0,0 +1,101 @@ +import { Header } from '../../src/util/headers'; +import { mockAxios } from '../mocks/axios'; +import { sleep } from '../utils'; + +describe('Last-Modified handling', () => { + it('tests last modified header handling', async () => { + const axios = mockAxios( + {}, + { + 'last-modified': 'Wed, 21 Oct 2015 07:28:00 GMT', + 'cache-control': 'max-age=1' + } + ); + + const config = { cache: { interpretHeader: true, modifiedSince: true } }; + + await axios.get('', config); + + const response = await axios.get('', config); + expect(response.cached).toBe(true); + expect(response.data).toBe(true); + + // Sleep entire max age time. + await sleep(1000); + + const response2 = await axios.get('', config); + // from revalidation + expect(response2.cached).toBe(true); + expect(response2.status).toBe(200); + }); + + it('tests last modified header handling in global config', async () => { + const axios = mockAxios( + { interpretHeader: true, modifiedSince: true }, + { + 'last-modified': 'Wed, 21 Oct 2015 07:28:00 GMT', + 'cache-control': 'max-age=1' + } + ); + + await axios.get(''); + + const response = await axios.get(''); + expect(response.cached).toBe(true); + expect(response.data).toBe(true); + + // Sleep entire max age time. + await sleep(1000); + + const response2 = await axios.get(''); + // from revalidation + expect(response2.cached).toBe(true); + expect(response2.status).toBe(200); + }); + + it('tests modifiedSince as date', async () => { + const axios = mockAxios({ ttl: 0 }); + + const config = { + cache: { modifiedSince: new Date(2014, 1, 1) } + }; + + const response = await axios.get('', config); + expect(response.cached).toBe(false); + expect(response.data).toBe(true); + expect(response.config.headers?.[Header.IfModifiedSince]).toBeUndefined(); + expect(response.headers?.[Header.XAxiosCacheLastModified]).toBeDefined(); + + const response2 = await axios.get('', config); + expect(response2.cached).toBe(true); + expect(response2.data).toBe(true); + expect(response2.config.headers?.[Header.IfModifiedSince]).toBeDefined(); + expect(response2.headers?.[Header.XAxiosCacheLastModified]).toBeDefined(); + }); + + it('tests modifiedSince using cache timestamp', async () => { + const axios = mockAxios( + {}, + { + 'cache-control': 'must-revalidate' + } + ); + + const config = { + cache: { interpretHeader: true, modifiedSince: true } + }; + + await axios.get('', config); + const response = await axios.get('', config); + + const modifiedSince = response.config.headers?.[Header.IfModifiedSince]; + + if (!modifiedSince) { + throw new Error('modifiedSince is not defined'); + } + const milliseconds = Date.parse(modifiedSince); + + expect(typeof milliseconds).toBe('number'); + expect(milliseconds).toBeLessThan(Date.now()); + }); +}); diff --git a/test/interceptors/request.test.ts b/test/interceptors/request.test.ts index 5403eae..c75ed32 100644 --- a/test/interceptors/request.test.ts +++ b/test/interceptors/request.test.ts @@ -1,4 +1,6 @@ +import { CacheRequestInterceptor } from '../../src/interceptors/request'; import { mockAxios } from '../mocks/axios'; +import { sleep } from '../utils'; describe('test request interceptor', () => { it('tests against specified methods', async () => { @@ -84,4 +86,53 @@ describe('test request interceptor', () => { const response4 = await axios.get('', { id: 'random-id' }); expect(response4.cached).toBe(true); }); + + it('test cache expiration', async () => { + const axios = mockAxios({}, { 'cache-control': 'max-age=1' }); + + await axios.get('', { cache: { interpretHeader: true } }); + + const resultCache = await axios.get(''); + expect(resultCache.cached).toBe(true); + + // Sleep entire max age time. + await sleep(1000); + + const response2 = await axios.get(''); + expect(response2.cached).toBe(false); + }); + + it('tests "must revalidate" handling without any headers to do so', async () => { + const axios = mockAxios({}, { 'cache-control': 'must-revalidate' }); + const config = { cache: { interpretHeader: true } }; + await axios.get('', config); + + // 0ms cache + await sleep(1); + + const response = await axios.get('', config); + // nothing to use for revalidation + expect(response.cached).toBe(false); + }); + + it('tests validate-status function', async () => { + const { createValidateStatus } = CacheRequestInterceptor; + + const def = createValidateStatus(); + expect(def(200)).toBe(true); + expect(def(345)).toBe(false); + expect(def(304)).toBe(true); + + const only200 = createValidateStatus((s) => s >= 200 && s < 300); + expect(only200(200)).toBe(true); + expect(only200(299)).toBe(true); + expect(only200(304)).toBe(true); + expect(only200(345)).toBe(false); + + const randomValue = createValidateStatus((s) => s >= 405 && s <= 410); + expect(randomValue(200)).toBe(false); + expect(randomValue(404)).toBe(false); + expect(randomValue(405)).toBe(true); + expect(randomValue(304)).toBe(true); + }); }); diff --git a/test/mocks/axios.ts b/test/mocks/axios.ts index 189be6e..a8f1994 100644 --- a/test/mocks/axios.ts +++ b/test/mocks/axios.ts @@ -1,5 +1,6 @@ import { AxiosCacheInstance, CacheProperties, createCache } from '../../src'; import type { CacheInstance } from '../../src/cache/cache'; +import { Header } from '../../src/util/headers'; export function mockAxios( options: Partial & Partial = {}, @@ -13,10 +14,18 @@ export function mockAxios( axios.interceptors.request.use((config) => { config.adapter = async (config) => { await 0; // Jumps to next tick of nodejs event loop + + const should304 = + config.headers?.[Header.IfNoneMatch] || config.headers?.[Header.IfModifiedSince]; + const status = should304 ? 304 : 200; + + // real axios would throw an error here. + config.validateStatus?.(status); + return { data: true, - status: 200, - statusText: '200 OK', + status, + statusText: should304 ? '304 Not Modified' : '200 OK', headers, config }; diff --git a/test/storage/storages.ts b/test/storage/storages.ts index 011c769..16d6451 100644 --- a/test/storage/storages.ts +++ b/test/storage/storages.ts @@ -1,5 +1,5 @@ import type { AxiosStorage } from '../../src/storage/storage'; -import { EMPTY_RESPONSE } from '../constants'; +import { EMPTY_RESPONSE } from '../utils'; export function testStorage(name: string, Storage: () => AxiosStorage): void { it(`tests ${name} storage methods`, async () => { diff --git a/test/storage/util.test.ts b/test/storage/util.test.ts index 05ca4c0..aaf0aa9 100644 --- a/test/storage/util.test.ts +++ b/test/storage/util.test.ts @@ -1,37 +1,66 @@ import { AxiosStorage } from '../../src/storage/storage'; +import { Header } from '../../src/util/headers'; -describe('tests common storages', () => { - it('tests isCacheValid with empty state', () => { - const invalid = AxiosStorage.isValid({ state: 'empty' }); - - expect(invalid).toBe(true); - }); - - it('tests isCacheValid with loading state', () => { - const invalid = AxiosStorage.isValid({ state: 'loading' }); - - expect(invalid).toBe(true); - }); - - it('tests isCacheValid with overdue cached state', () => { - const isValid = AxiosStorage.isValid({ +describe('tests abstract storages', () => { + it('tests storage keep if stale method', () => { + const etag = AxiosStorage.keepIfStale({ state: 'cached', - data: {} as any, // doesn't matter - createdAt: Date.now() - 2000, // 2 seconds in the past - ttl: 1000 // 1 second + // Reverse to be ~infinity + createdAt: 1, + ttl: Date.now(), + data: { + status: 200, + statusText: '200 OK', + data: true, + headers: { + [Header.ETag]: 'W/"123"' + } + } }); + expect(etag).toBe(true); - expect(isValid).toBe(false); - }); - - it('tests isCacheValid with cached state', () => { - const isValid = AxiosStorage.isValid({ + const modifiedSince = AxiosStorage.keepIfStale({ state: 'cached', - data: {} as any, // doesn't matter - createdAt: Date.now(), - ttl: 1000 // 1 second + // Reverse to be ~infinity + createdAt: 1, + ttl: Date.now(), + data: { + status: 200, + statusText: '200 OK', + data: true, + headers: { + [Header.LastModified]: new Date().toUTCString() + } + } }); + expect(modifiedSince).toBe(true); - expect(isValid).toBe(true); + const empty = AxiosStorage.keepIfStale({ + state: 'cached', + // Reverse to be ~infinity + createdAt: 1, + ttl: Date.now(), + data: { + status: 200, + statusText: '200 OK', + data: true, + headers: {} + } + }); + expect(empty).toBe(false); + + const rest = AxiosStorage.keepIfStale({ + state: 'cached', + // Reverse to be ~infinity + createdAt: 1, + ttl: Date.now(), + data: { + status: 200, + statusText: '200 OK', + data: true, + headers: undefined as any + } + }); + expect(rest).toBe(false); }); }); diff --git a/test/util/cache-predicate.test.ts b/test/util/cache-predicate.test.ts index 85b1119..4a403e4 100644 --- a/test/util/cache-predicate.test.ts +++ b/test/util/cache-predicate.test.ts @@ -1,5 +1,5 @@ import { checkPredicateObject } from '../../src/util/cache-predicate'; -import { createResponse } from '../constants'; +import { createResponse } from '../utils'; describe('tests cache predicate object', () => { it('tests statusCheck with tuples', () => { diff --git a/test/util/update-cache.test.ts b/test/util/update-cache.test.ts index 71e9e61..6edfc73 100644 --- a/test/util/update-cache.test.ts +++ b/test/util/update-cache.test.ts @@ -1,7 +1,7 @@ import type { AxiosCacheInstance, CachedStorageValue } from '../../src'; import { updateCache } from '../../src/util/update-cache'; -import { EMPTY_RESPONSE } from '../constants'; import { mockAxios } from '../mocks/axios'; +import { EMPTY_RESPONSE } from '../utils'; const KEY = 'cacheKey'; const EMPTY_STATE = { state: 'empty' }; diff --git a/test/constants.ts b/test/utils.ts similarity index 77% rename from test/constants.ts rename to test/utils.ts index d5d9b0e..b9274bd 100644 --- a/test/constants.ts +++ b/test/utils.ts @@ -12,3 +12,6 @@ export const createResponse = ( ): AxiosResponse => { return { ...EMPTY_RESPONSE, config: {}, data: {} as R, request: {}, ...config }; }; + +export const sleep = (ms: number): Promise => + new Promise((res) => setTimeout(res, ms));