mirror of
https://github.com/arthurfiorette/axios-cache-interceptor.git
synced 2025-12-08 17:36:16 +00:00
fix: fixed a log of bugs and added unit tests
This commit is contained in:
parent
1c10b41c98
commit
2bbc9cb858
3
.gitignore
vendored
3
.gitignore
vendored
@ -2,4 +2,5 @@
|
||||
/dist
|
||||
/ignore
|
||||
/.vscode
|
||||
/package-lock.json
|
||||
/package-lock.json
|
||||
/coverage
|
||||
@ -1,2 +1,3 @@
|
||||
/ignore
|
||||
/node_modules
|
||||
/node_modules
|
||||
/coverage
|
||||
5
jest.config.js
Normal file
5
jest.config.js
Normal file
@ -0,0 +1,5 @@
|
||||
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node'
|
||||
};
|
||||
@ -4,7 +4,10 @@
|
||||
"description": "Cache interceptor for axios",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "tsc --p tsconfig.build.json",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:coverage": "jest --coverage",
|
||||
"prettify": "prettier . --write",
|
||||
"lint": "tsc --noEmit && eslint . --ext .ts",
|
||||
"lint:fix": "eslint . --ext .ts --fix"
|
||||
@ -37,6 +40,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@arthurfiorette/prettier-config": "^1.0.5",
|
||||
"@types/jest": "^27.0.1",
|
||||
"@types/node": "^16.7.10",
|
||||
"@typescript-eslint/eslint-plugin": "^4.30.0",
|
||||
"@typescript-eslint/parser": "^4.30.0",
|
||||
@ -44,9 +48,11 @@
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"jest": "^27.2.0",
|
||||
"prettier": "^2.3.2",
|
||||
"prettier-plugin-jsdoc": "^0.3.23",
|
||||
"prettier-plugin-organize-imports": "^2.3.3",
|
||||
"ts-jest": "^27.0.5",
|
||||
"typescript": "^4.4.3"
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,11 +6,16 @@ import type {
|
||||
AxiosResponse,
|
||||
Method
|
||||
} from 'axios';
|
||||
import { Deferred } from 'src/util/deferred';
|
||||
import { KeyGenerator } from 'src/util/key-generator';
|
||||
import { HeaderInterpreter } from '../header';
|
||||
import { CachedResponse, CacheStorage } from '../storage/types';
|
||||
import {
|
||||
CachedResponse,
|
||||
CachedStorageValue,
|
||||
CacheStorage,
|
||||
EmptyStorageValue
|
||||
} from '../storage/types';
|
||||
import { CachePredicate } from '../util/cache-predicate';
|
||||
import { Deferred } from '../util/deferred';
|
||||
import { KeyGenerator } from '../util/key-generator';
|
||||
|
||||
export type DefaultCacheRequestConfig = AxiosRequestConfig & {
|
||||
cache: CacheProperties;
|
||||
@ -59,7 +64,12 @@ export type CacheProperties = {
|
||||
* @default {}
|
||||
*/
|
||||
update: {
|
||||
[id: string]: 'delete' | ((oldValue: any, atual: any) => any | undefined);
|
||||
[id: string]:
|
||||
| 'delete'
|
||||
| ((
|
||||
cached: EmptyStorageValue | CachedStorageValue,
|
||||
newData: any
|
||||
) => CachedStorageValue | undefined);
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -1,12 +1,15 @@
|
||||
import { CachedResponse } from 'src/storage/types';
|
||||
import { AxiosCacheInstance } from '../axios/types';
|
||||
import { CachedResponse } from '../storage/types';
|
||||
import { Deferred } from '../util/deferred';
|
||||
import { CACHED_RESPONSE_STATUS, CACHED_RESPONSE_STATUS_TEXT } from '../util/status-codes';
|
||||
|
||||
export function applyRequestInterceptor(axios: AxiosCacheInstance): void {
|
||||
axios.interceptors.request.use(async (config) => {
|
||||
// Only cache specified methods
|
||||
if (config.cache?.methods?.some((method) => (config.method || 'get').toLowerCase() == method)) {
|
||||
if (
|
||||
config.cache?.methods &&
|
||||
!config.cache.methods.some((method) => (config.method || 'get').toLowerCase() == method)
|
||||
) {
|
||||
return config;
|
||||
}
|
||||
|
||||
@ -19,30 +22,26 @@ export function applyRequestInterceptor(axios: AxiosCacheInstance): void {
|
||||
axios.waiting[key] = new Deferred();
|
||||
|
||||
await axios.storage.set(key, {
|
||||
state: 'loading',
|
||||
data: null,
|
||||
// The cache header will be set after the response has been read, until that time, the expiration will be -1
|
||||
expiration: config.cache?.interpretHeader
|
||||
? -1
|
||||
: config.cache?.maxAge || axios.defaults.cache.maxAge
|
||||
state: 'loading'
|
||||
});
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
// Only check for expiration if the cache exists, because if it is loading, the expiration value may be -1.
|
||||
if (cache.state === 'cached' && cache.expiration < Date.now()) {
|
||||
await axios.storage.remove(key);
|
||||
return config;
|
||||
}
|
||||
|
||||
let data = {} as CachedResponse;
|
||||
let data: CachedResponse = {};
|
||||
|
||||
if (cache.state === 'loading') {
|
||||
const deferred = axios.waiting[key];
|
||||
|
||||
// If the deferred is undefined, means that the
|
||||
// outside has removed that key from the waiting list
|
||||
if (!deferred) {
|
||||
await axios.storage.remove(key);
|
||||
return config;
|
||||
}
|
||||
|
||||
|
||||
@ -1,67 +1,69 @@
|
||||
import { AxiosCacheInstance } from '../axios/types';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { AxiosCacheInstance, CacheRequestConfig } from '../axios/types';
|
||||
import { CachedStorageValue } from '../storage/types';
|
||||
import { checkPredicateObject } from '../util/cache-predicate';
|
||||
import { updateCache } from '../util/update-cache';
|
||||
|
||||
export function applyResponseInterceptor(axios: AxiosCacheInstance): void {
|
||||
const testCachePredicate = (response: AxiosResponse, config: CacheRequestConfig): boolean => {
|
||||
const cachePredicate = config.cache?.cachePredicate || axios.defaults.cache.cachePredicate;
|
||||
|
||||
return (
|
||||
(typeof cachePredicate === 'function' && cachePredicate(response)) ||
|
||||
(typeof cachePredicate === 'object' && checkPredicateObject(response, cachePredicate))
|
||||
);
|
||||
};
|
||||
|
||||
axios.interceptors.response.use(async (response) => {
|
||||
const key = axios.generateKey(response.config);
|
||||
const cache = await axios.storage.get(key);
|
||||
|
||||
// Response is empty or was already cached
|
||||
if (cache.state !== 'loading') {
|
||||
return response;
|
||||
}
|
||||
|
||||
// Config told that this response should be cached.
|
||||
if (!testCachePredicate(response, response.config)) {
|
||||
// Update the cache to empty to prevent infinite loading state
|
||||
await axios.storage.remove(key);
|
||||
return response;
|
||||
}
|
||||
|
||||
let expiration = Date.now() + (response.config.cache?.maxAge || axios.defaults.cache.maxAge);
|
||||
|
||||
if (response.config.cache?.interpretHeader) {
|
||||
const expirationTime = axios.headerInterpreter(response.headers['cache-control']);
|
||||
|
||||
// Cache should not be used
|
||||
if (expirationTime === false) {
|
||||
// Update the cache to empty to prevent infinite loading state
|
||||
await axios.storage.remove(key);
|
||||
return response;
|
||||
}
|
||||
|
||||
expiration = expirationTime ? expirationTime : expiration;
|
||||
}
|
||||
|
||||
const newCache: CachedStorageValue = {
|
||||
data: { body: response.data, headers: response.headers },
|
||||
state: 'cached',
|
||||
expiration: expiration
|
||||
};
|
||||
|
||||
// Update other entries before updating himself
|
||||
if (response.config.cache?.update) {
|
||||
updateCache(axios, response.data, response.config.cache.update);
|
||||
}
|
||||
|
||||
const cachePredicate =
|
||||
response.config.cache?.cachePredicate || axios.defaults.cache.cachePredicate;
|
||||
|
||||
// Config told that this response should be cached.
|
||||
if (typeof cachePredicate === 'function') {
|
||||
if (!cachePredicate(response)) {
|
||||
return response;
|
||||
}
|
||||
} else {
|
||||
if (!checkPredicateObject(response, cachePredicate)) {
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
const key = axios.generateKey(response.config);
|
||||
const cache = await axios.storage.get(key);
|
||||
|
||||
// Response already is in cache or received without
|
||||
// being intercepted in the response
|
||||
if (cache.state === 'cached' || cache.state === 'empty') {
|
||||
return response;
|
||||
}
|
||||
|
||||
const defaultMaxAge = response.config.cache?.maxAge || axios.defaults.cache.maxAge;
|
||||
cache.expiration = cache.expiration || defaultMaxAge;
|
||||
let saveCache = true;
|
||||
|
||||
if (response.config.cache?.interpretHeader) {
|
||||
const expirationTime = axios.headerInterpreter(response.headers['cache-control']);
|
||||
|
||||
// Header told that this response should not be cached.
|
||||
if (expirationTime === false) {
|
||||
saveCache = false;
|
||||
} else {
|
||||
cache.expiration = expirationTime ? expirationTime : defaultMaxAge;
|
||||
}
|
||||
}
|
||||
|
||||
const data = { body: response.data, headers: response.headers };
|
||||
const deferred = axios.waiting[key];
|
||||
|
||||
// Resolve all other requests waiting for this response
|
||||
if (deferred) {
|
||||
deferred.resolve(data);
|
||||
await deferred.resolve(newCache.data);
|
||||
}
|
||||
|
||||
if (saveCache) {
|
||||
await axios.storage.set(key, {
|
||||
data,
|
||||
expiration: cache.expiration,
|
||||
state: 'cached'
|
||||
});
|
||||
}
|
||||
await axios.storage.set(key, newCache);
|
||||
|
||||
return response;
|
||||
});
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { EmptyStorageValue } from '.';
|
||||
import { CacheStorage, StorageValue } from './types';
|
||||
|
||||
export class MemoryStorage implements CacheStorage {
|
||||
@ -10,7 +11,7 @@ export class MemoryStorage implements CacheStorage {
|
||||
return value;
|
||||
}
|
||||
|
||||
const empty = { data: null, expiration: -1, state: 'empty' } as const;
|
||||
const empty: EmptyStorageValue = { state: 'empty' };
|
||||
this.storage.set(key, empty);
|
||||
return empty;
|
||||
};
|
||||
|
||||
@ -6,8 +6,10 @@ export interface CacheStorage {
|
||||
get: (key: string) => Promise<StorageValue>;
|
||||
/**
|
||||
* Sets a new value for the given key
|
||||
*
|
||||
* Use CacheStorage.remove(key) to define a key to 'empty' state.
|
||||
*/
|
||||
set: (key: string, value: StorageValue) => Promise<void>;
|
||||
set: (key: string, value: LoadingStorageValue | CachedStorageValue) => Promise<void>;
|
||||
/**
|
||||
* Removes the value for the given key
|
||||
*/
|
||||
@ -15,29 +17,29 @@ export interface CacheStorage {
|
||||
}
|
||||
|
||||
export type CachedResponse = {
|
||||
headers: any;
|
||||
body: any;
|
||||
headers?: any;
|
||||
body?: any;
|
||||
};
|
||||
|
||||
/**
|
||||
* The value returned for a given key.
|
||||
*/
|
||||
export type StorageValue =
|
||||
| {
|
||||
data: CachedResponse;
|
||||
expiration: number;
|
||||
state: 'cached';
|
||||
}
|
||||
| {
|
||||
data: null;
|
||||
/**
|
||||
* If interpretHeader is used, this value will be `-1`until the response is received
|
||||
*/
|
||||
expiration: number;
|
||||
state: 'loading';
|
||||
}
|
||||
| {
|
||||
data: null;
|
||||
expiration: -1;
|
||||
state: 'empty';
|
||||
};
|
||||
export type StorageValue = CachedStorageValue | LoadingStorageValue | EmptyStorageValue;
|
||||
|
||||
export type CachedStorageValue = {
|
||||
data: CachedResponse;
|
||||
expiration: number;
|
||||
state: 'cached';
|
||||
};
|
||||
|
||||
export type LoadingStorageValue = {
|
||||
data?: undefined;
|
||||
expiration?: undefined;
|
||||
state: 'loading';
|
||||
};
|
||||
|
||||
export type EmptyStorageValue = {
|
||||
data?: undefined;
|
||||
expiration?: undefined;
|
||||
state: 'empty';
|
||||
};
|
||||
|
||||
@ -7,7 +7,7 @@ export abstract class WindowStorageWrapper implements CacheStorage {
|
||||
|
||||
get = async (key: string): Promise<StorageValue> => {
|
||||
const json = this.storage.getItem(this.prefix + key);
|
||||
return json ? JSON.parse(json) : { data: null, expiration: -1, state: 'empty' };
|
||||
return json ? JSON.parse(json) : { state: 'empty' };
|
||||
};
|
||||
|
||||
set = async (key: string, value: StorageValue): Promise<void> => {
|
||||
|
||||
@ -3,7 +3,7 @@ export type MaybePromise<T> = T | PromiseLike<T>;
|
||||
/**
|
||||
* Represents the completion of an asynchronous operation that can be completed later.
|
||||
*/
|
||||
export class Deferred<T> {
|
||||
export class Deferred<T> implements PromiseLike<T> {
|
||||
readonly promise: Promise<T>;
|
||||
private _resolve: (value: MaybePromise<T>) => void = () => {};
|
||||
private _reject: (reason?: any) => void = () => {};
|
||||
|
||||
@ -12,6 +12,11 @@ export async function updateCache(
|
||||
}
|
||||
|
||||
const oldValue = await axios.storage.get(cacheKey);
|
||||
|
||||
if (oldValue.state === 'loading') {
|
||||
throw new Error('cannot update the cache while loading');
|
||||
}
|
||||
|
||||
const newValue = value(oldValue, data);
|
||||
|
||||
if (newValue === undefined) {
|
||||
|
||||
40
test/mocks/axios.ts
Normal file
40
test/mocks/axios.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import axios from 'axios';
|
||||
import { AxiosCacheInstance, CacheProperties, createCache } from '../../src';
|
||||
import CacheInstance from '../../src/axios/types';
|
||||
|
||||
export const axiosMock = {
|
||||
/**
|
||||
* Simple request url to be used, doesn't matter at all because network
|
||||
* requests are intercepted by a custom adapter.
|
||||
*/
|
||||
url: 'https://github.com/ArthurFiorette/axios-cache-interceptor/',
|
||||
statusCode: -1,
|
||||
statusText: '-1 Intercepted'
|
||||
};
|
||||
|
||||
export function mockAxios(
|
||||
options?: Partial<CacheInstance> & Partial<CacheProperties>
|
||||
): AxiosCacheInstance {
|
||||
// A simple jest that resolves every request with this response
|
||||
const api = axios.create();
|
||||
|
||||
const cachedApi = createCache(api, {
|
||||
// Defaults to cache every request
|
||||
cachePredicate: () => true,
|
||||
...options
|
||||
});
|
||||
|
||||
cachedApi.interceptors.request.use((config) => {
|
||||
config.adapter = async (config) => ({
|
||||
data: true,
|
||||
status: axiosMock.statusCode,
|
||||
statusText: axiosMock.statusText,
|
||||
headers: {},
|
||||
config
|
||||
});
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
return cachedApi;
|
||||
}
|
||||
32
test/util/status-codes.test.ts
Normal file
32
test/util/status-codes.test.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { AxiosCacheInstance } from '../../src';
|
||||
import { StatusCodes } from '../../src/';
|
||||
import { axiosMock, mockAxios } from '../mocks/axios';
|
||||
|
||||
const KEY = 'cacheKey';
|
||||
|
||||
describe('Tests cached status code', () => {
|
||||
let axios: AxiosCacheInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
axios = mockAxios({});
|
||||
|
||||
axios.storage.set(KEY, {
|
||||
data: { body: true },
|
||||
expiration: Infinity,
|
||||
state: 'cached'
|
||||
});
|
||||
});
|
||||
|
||||
it('test response status code', async () => {
|
||||
const firstResponse = await axios.get(axiosMock.url);
|
||||
expect(firstResponse.status).toBe(axiosMock.statusCode);
|
||||
expect(firstResponse.statusText).toBe(axiosMock.statusText);
|
||||
|
||||
const secondResponse = await axios.get(axiosMock.url);
|
||||
expect(secondResponse.status).not.toBe(axiosMock.statusCode);
|
||||
expect(secondResponse.statusText).not.toBe(axiosMock.statusText);
|
||||
|
||||
expect(secondResponse.status).toBe(StatusCodes.CACHED_RESPONSE_STATUS);
|
||||
expect(secondResponse.statusText).toBe(StatusCodes.CACHED_RESPONSE_STATUS_TEXT);
|
||||
});
|
||||
});
|
||||
58
test/util/update-cache.test.ts
Normal file
58
test/util/update-cache.test.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { AxiosCacheInstance, StorageValue } from '../../src';
|
||||
import { updateCache } from '../../src/util/update-cache';
|
||||
import { mockAxios } from '../mocks/axios';
|
||||
|
||||
const KEY = 'cacheKey';
|
||||
const EMPTY_STATE = { state: 'empty' };
|
||||
const DEFAULT_DATA = 'random-data';
|
||||
const INITIAL_DATA: StorageValue = { data: { body: true }, expiration: Infinity, state: 'cached' };
|
||||
|
||||
describe('Tests update-cache', () => {
|
||||
let axios: AxiosCacheInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
axios = mockAxios({});
|
||||
|
||||
axios.storage.set(KEY, INITIAL_DATA);
|
||||
});
|
||||
|
||||
it('tests for delete key', async () => {
|
||||
await updateCache(axios, DEFAULT_DATA, {
|
||||
[KEY]: 'delete'
|
||||
});
|
||||
|
||||
const response = await axios.storage.get(KEY);
|
||||
|
||||
expect(response).not.toBeFalsy();
|
||||
expect(response).toStrictEqual(EMPTY_STATE);
|
||||
});
|
||||
|
||||
it('tests for returning undefined', async () => {
|
||||
await updateCache(axios, DEFAULT_DATA, {
|
||||
[KEY]: () => undefined
|
||||
});
|
||||
|
||||
const response = await axios.storage.get(KEY);
|
||||
|
||||
expect(response).not.toBeFalsy();
|
||||
expect(response).toStrictEqual(EMPTY_STATE);
|
||||
});
|
||||
|
||||
it('tests for returning an new value', async () => {
|
||||
await updateCache(axios, DEFAULT_DATA, {
|
||||
[KEY]: (cached, newData) => ({
|
||||
state: 'cached',
|
||||
expiration: Infinity,
|
||||
data: { body: `${cached.data?.body}:${newData}` }
|
||||
})
|
||||
});
|
||||
|
||||
const response = await axios.storage.get(KEY);
|
||||
|
||||
expect(response).not.toBeFalsy();
|
||||
expect(response).not.toStrictEqual(EMPTY_STATE);
|
||||
|
||||
expect(response.state).toBe('cached');
|
||||
expect(response.data?.body).toBe(`${INITIAL_DATA.data.body}:${DEFAULT_DATA}`);
|
||||
});
|
||||
});
|
||||
4
tsconfig.build.json
Normal file
4
tsconfig.build.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["src"]
|
||||
}
|
||||
@ -22,7 +22,7 @@
|
||||
// "noEmit": true, /* Do not emit outputs. */
|
||||
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
|
||||
"downlevelIteration": true /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */,
|
||||
"isolatedModules": true /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */,
|
||||
// "isolatedModules": false /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */,
|
||||
|
||||
/* Strict Type-Checking Options */
|
||||
"strict": true /* Enable all strict type-checking options. */,
|
||||
@ -44,7 +44,7 @@
|
||||
|
||||
/* Module Resolution Options */
|
||||
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
|
||||
"baseUrl": "./" /* Base directory to resolve non-absolute module names. */,
|
||||
// "baseUrl": "./" /* Base directory to resolve non-absolute module names. */,
|
||||
// "paths": {} /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */,
|
||||
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
|
||||
// "typeRoots": [], /* List of folders to include type definitions from. */
|
||||
@ -68,5 +68,5 @@
|
||||
"skipLibCheck": true /* Skip type checking of declaration files. */,
|
||||
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "test"]
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user