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