feat: etag and if-modified-since support (#53)

* feat(WIP): etag and if-modified-since support

* test: fixed test name

* fix: merge response headers

* test: add etag / last-modified tests

* test: add must-revalidate tests

* fix: handle expirationTime 0 as true.

* tests: refactored some tests

* test: added keepIfStale tests

* fix: remove axios error for 304 requests

* fix: possible infinite loop on validateStatus function

* tests: ignore code that is hard test from coverage

* fix: use Last-Modified header for modifiedSince

If-Modified-Since is never sent by a server, it's
a client only header. However Last-Modified is sent
by the server to indicate the last time the returned
version was modified (it might not be the last version
depending on cache configuration on intermediate
servers)

* test: use validateStatus in mock

This more closely match default axios adapter.

* fix: validateStatus handling all cases

* refactor: use cache.createdAt if the last-modified header isn't present

* test: etag with predefined value

* test: added more test cases

* fix: fixed logic in some tests

* docs: initial documentation

* fix: manual values work out of the box

This removes header requirement from server.

* docs: add details to etag and modifiedSince features

* fix: delete custom headers from response

* feat: accept all configurations from global axios

Merging global into local configuration enable user
to use global configuration for all options and remove
the need to check local and global values every time.

* fix: preserve original types from AxiosInstance

The only value axios needs is a URL, and in the second definition of the method, there is already a URL parameter, so it can be undefined.

* Fix: defaults modifiedSince back to false.

Avoids breaking changes.

* docs: fix etag examples

* docs: document internal headers

* refactor: ternary operator :)

* style: prettified code

* test: remove modifiedSince: false in tests since this is the default

* docs: fix headers examples

* docs: fixed example formatting

* tests: split tests into multiple files to test them faster

* docs: correct jsdoc empty object

Co-authored-by: Charly Koza <cka@f4-group.com>
This commit is contained in:
Arthur Fiorette 2021-11-15 10:43:20 -03:00 committed by GitHub
parent b35ae3e574
commit 48e33c5d5a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 676 additions and 195 deletions

View File

@ -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`
<br />
## License

2
src/cache/axios.ts vendored
View File

@ -63,7 +63,7 @@ export interface AxiosCacheInstance extends CacheInstance, AxiosInstance {
* @template D The type that the request body use
*/
<T = any, D = any, R = CacheAxiosResponse<T, D>>(
config?: CacheRequestConfig<D>
config: CacheRequestConfig<D>
): Promise<R>;
/**
* @template T The type returned by this response

21
src/cache/cache.ts vendored
View File

@ -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<string, CacheUpdater>;
/**
* 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 {

6
src/cache/create.ts vendored
View File

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

View File

@ -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<D>
@ -16,21 +20,23 @@ export class CacheRequestInterceptor<D>
{
constructor(readonly axios: AxiosCacheInstance) {}
use = (): void => {
public use = (): void => {
this.axios.interceptors.request.use(this.onFulfilled);
};
onFulfilled = async (config: CacheRequestConfig<D>): Promise<CacheRequestConfig<D>> => {
// Skip cache
public onFulfilled = async (
config: CacheRequestConfig<D>
): Promise<CacheRequestConfig<D>> => {
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<D>
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<D>
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<D>
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<D>
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<D>
return config;
};
private isMethodAllowed = (
method: Method,
properties: Partial<CacheProperties>
): 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<D> & { cache: Partial<CacheProperties> }
): 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;
};
};
}

View File

@ -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<R, D>
{
constructor(readonly axios: AxiosCacheInstance) {}
use = (): void => {
public use = (): void => {
this.axios.interceptors.response.use(this.onFulfilled);
};
public onFulfilled = async (
axiosResponse: AxiosResponse<R, D>
): Promise<CacheAxiosResponse<R, D>> => {
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 = <R>(
response: AxiosResponse<R>,
cache?: Partial<CacheProperties>
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<R, D>
delete this.axios.waiting[key];
};
onFulfilled = async (
axiosResponse: AxiosResponse<R, D>
): Promise<CacheAxiosResponse<R, D>> => {
const key = this.axios.generateKey(axiosResponse.config);
const response: CacheAxiosResponse<R, D> = {
id: key,
private cachedResponse = (response: AxiosResponse<R, D>): CacheAxiosResponse<R, D> => {
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<R, D>).cached || false,
...axiosResponse
cached: (response as CacheAxiosResponse<R, D>).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;
};
}

View File

@ -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<StorageValue> => {
const json = this.storage.getItem(`${this.prefix}:${key}`);
return json ? JSON.parse(json) : { state: 'empty' };
};
public set = (key: string, value: Exclude<StorageValue, EmptyStorageValue>): void => {
public set = async (key: string, value: NotEmptyStorageValue): Promise<void> => {
return this.storage.setItem(`${this.prefix}:${key}`, JSON.stringify(value));
};
public remove = (key: string): void | Promise<void> => {
public remove = async (key: string): Promise<void> => {
return this.storage.removeItem(`${this.prefix}:${key}`);
};
}

View File

@ -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<string, StorageValue> = {}) {
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<StorageValue> => {
return this.storage[key] || { state: 'empty' };
};
public set = (key: string, value: CachedStorageValue | LoadingStorageValue): void => {
public set = async (key: string, value: NotEmptyStorageValue): Promise<void> => {
this.storage[key] = value;
};
public remove = (key: string): void => {
public remove = async (key: string): Promise<void> => {
delete this.storage[key];
};
}

View File

@ -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> | StorageValue;
protected abstract find: (key: string) => Promise<StorageValue>;
/**
* 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<StorageValue, EmptyStorageValue>
) => Promise<void> | void;
public abstract set: (key: string, value: NotEmptyStorageValue) => Promise<void>;
/**
* Removes the value for the given key
*/
public abstract remove: (key: string) => Promise<void> | void;
public abstract remove: (key: string) => Promise<void>;
/**
* 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<StorageValue> => {
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;
};
}

View File

@ -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<StorageValue, EmptyStorageValue>;
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;
/**

View File

@ -8,6 +8,15 @@ export enum Header {
*/
IfModifiedSince = 'if-modified-since',
/**
* ```txt
* Last-Modified: <day-name>, <day> <month> <year> <hour>:<minute>:<second> GMT
* ```
*
* @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Last-Modified
*/
LastModified = 'last-modified',
/**
* ```txt
* If-None-Match: "<etag_value>"
@ -26,8 +35,8 @@ export enum Header {
/**
* ```txt
* ETag: W / '<etag_value>';
* ETag: '<etag_value>';
* ETag: W/"<etag_value>"
* ETag: "<etag_value>"
* ```
*
* @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: "<etag_value>"
* ```
*/
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: <day-name>, <day> <month> <year> <hour>:<minute>:<second> GMT
* X-Axios-Cache-Last-Modified: use-cache-timestamp
* ```
*/
XAxiosCacheLastModified = 'x-axios-cache-last-modified'
}

View File

@ -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<StorageValue, LoadingStorageValue>,
newData: any
) => CachedStorageValue | void);
/**
* Function to update all caches, from CacheProperties.update, with
* the new data.
*/
export async function updateCache<T = any>(
storage: AxiosStorage,
data: T,
@ -17,7 +25,7 @@ export async function updateCache<T = any>(
// 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;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<CacheInstance> & Partial<CacheProperties> = {},
@ -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
};

View File

@ -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 () => {

View File

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

View File

@ -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', () => {

View File

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

View File

@ -12,3 +12,6 @@ export const createResponse = <R>(
): AxiosResponse<R> => {
return { ...EMPTY_RESPONSE, config: {}, data: {} as R, request: {}, ...config };
};
export const sleep = (ms: number): Promise<void> =>
new Promise((res) => setTimeout(res, ms));