fix: fixed a log of bugs and added unit tests

This commit is contained in:
Hazork 2021-09-13 12:08:43 -03:00
parent 1c10b41c98
commit 2bbc9cb858
18 changed files with 2405 additions and 111 deletions

3
.gitignore vendored
View File

@ -2,4 +2,5 @@
/dist
/ignore
/.vscode
/package-lock.json
/package-lock.json
/coverage

View File

@ -1,2 +1,3 @@
/ignore
/node_modules
/node_modules
/coverage

5
jest.config.js Normal file
View File

@ -0,0 +1,5 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node'
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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
View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["src"]
}

View File

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

2164
yarn.lock

File diff suppressed because it is too large Load Diff