feat: suupport for async CacheUpdater

This commit is contained in:
arthurfiorette 2022-01-04 15:28:38 -03:00
parent ce2f5976bc
commit cfbd601b98
No known key found for this signature in database
GPG Key ID: 9D190CD53C53C555
7 changed files with 218 additions and 107 deletions

View File

@ -1,10 +1,9 @@
import type { AxiosResponse } from 'axios';
import type { CacheProperties } from '..';
import type { CacheAxiosResponse, CacheProperties } from '..';
import type { CachePredicateObject } from './types';
/** Returns true if the response should be cached */
export function shouldCacheResponse<R>(
response: AxiosResponse<R>,
export function shouldCacheResponse<R, D>(
response: CacheAxiosResponse<R, D>,
{ cachePredicate }: CacheProperties
) {
if (typeof cachePredicate === 'function') {
@ -14,9 +13,9 @@ export function shouldCacheResponse<R>(
return isCachePredicateValid(response, cachePredicate);
}
export function isCachePredicateValid<R>(
response: AxiosResponse<R>,
{ statusCheck, containsHeaders, responseMatch }: CachePredicateObject
export function isCachePredicateValid<R, D>(
response: CacheAxiosResponse<R, D>,
{ statusCheck, containsHeaders, responseMatch }: CachePredicateObject<R, D>
): boolean {
if (statusCheck) {
if (typeof statusCheck === 'function') {
@ -60,7 +59,7 @@ export function isCachePredicateValid<R>(
}
}
if (responseMatch && !responseMatch(response.data)) {
if (responseMatch && !responseMatch(response)) {
return false;
}

View File

@ -1,11 +1,15 @@
import type { AxiosResponse } from 'axios';
import type { CacheRequestConfig } from '../cache/axios';
import type { CacheAxiosResponse, CacheRequestConfig } from '../cache/axios';
import type {
CachedStorageValue,
LoadingStorageValue,
StorageValue
} from '../storage/types';
export type CachePredicate =
| CachePredicateObject
| (<R>(response: AxiosResponse<R>) => boolean);
export type CachePredicate<R = any, D = any> =
| CachePredicateObject<R, D>
| (<R, D>(response: CacheAxiosResponse<R, D>) => boolean);
export type CachePredicateObject = {
export type CachePredicateObject<R = any, D = any> = {
/**
* The status predicate, if a tuple is returned, the first and seconds value means the
* interval (inclusive) accepted. Can also be a function.
@ -19,8 +23,19 @@ export type CachePredicateObject = {
containsHeaders?: Record<string, true | string | ((header: string) => boolean)>;
/** Check if the desired response matches this predicate. */
responseMatch?: <T = any>(res: T | undefined) => boolean;
responseMatch?: (res: CacheAxiosResponse<R, D>) => boolean;
};
/** A simple function that receives a cache request config and should return a string id for it. */
export type KeyGenerator = <R>(options: CacheRequestConfig<R>) => string;
export type KeyGenerator = <R = any, D = any>(
options: CacheRequestConfig<R, D>
) => string;
type MaybePromise<T> = T | Promise<T> | PromiseLike<T>;
export type CacheUpdater<R, D> =
| 'delete'
| ((
cached: Exclude<StorageValue, LoadingStorageValue>,
response: CacheAxiosResponse<R, D>
) => MaybePromise<CachedStorageValue | 'delete' | 'ignore'>);

View File

@ -1,22 +1,12 @@
import type {
AxiosStorage,
CachedStorageValue,
LoadingStorageValue,
StorageValue
} from '../storage/types';
export type CacheUpdater =
| 'delete'
| ((
cached: Exclude<StorageValue, LoadingStorageValue>,
newData: any
) => CachedStorageValue | void);
import type { AxiosStorage } from '..';
import type { CacheAxiosResponse } from '../cache/axios';
import type { CacheUpdater } from './types';
/** Function to update all caches, from CacheProperties.update, with the new data. */
export async function updateCache<T = any>(
export async function updateCache<T, D>(
storage: AxiosStorage,
data: T,
entries: Record<string, CacheUpdater>
data: CacheAxiosResponse<T, D>,
entries: Record<string, CacheUpdater<T, D>>
): Promise<void> {
for (const cacheKey in entries) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@ -30,16 +20,18 @@ export async function updateCache<T = any>(
const oldValue = await storage.get(cacheKey);
if (oldValue.state === 'loading') {
throw new Error('cannot update the cache while loading');
continue;
}
const newValue = value(oldValue, data);
const newValue = await value(oldValue, data);
if (newValue === undefined) {
if (newValue === 'delete') {
await storage.remove(cacheKey);
continue;
}
await storage.set(cacheKey, newValue);
if (newValue !== 'ignore') {
await storage.set(cacheKey, newValue);
}
}
}

View File

@ -1,4 +1,6 @@
import type { CachedStorageValue } from '../../src';
import { isCachePredicateValid } from '../../src/util/cache-predicate';
import { mockAxios } from '../mocks/axios';
import { createResponse } from '../utils';
describe('tests cache predicate object', () => {
@ -94,14 +96,50 @@ describe('tests cache predicate object', () => {
});
const testStrict = isCachePredicateValid(response, {
responseMatch: (data: any) => data && data.a === true && data.b === 1
responseMatch: ({ data }) => data && data.a === true && data.b === 1
});
const testError = isCachePredicateValid(response, {
responseMatch: (data: any) => data && (data.a !== true || data.b !== 1)
responseMatch: ({ data }) => data && (data.a !== true || data.b !== 1)
});
expect(testStrict).toBeTruthy();
expect(testError).toBeFalsy();
});
it('tests generics and typescript types', () => {
() => {
const axios = mockAxios();
axios.get<{ a: boolean; b: number }>('/', {
cache: {
ttl: ({ data }) => {
return data.b;
},
cachePredicate: {
responseMatch: ({ data }) => {
return data.a;
}
},
update: {
id: (
_,
{ data: { a, b }, headers, status, statusText }
): CachedStorageValue => {
return {
state: 'cached',
ttl: Infinity,
createdAt: Date.now(),
data: {
headers,
status,
statusText,
data: { a, b }
}
};
}
}
}
});
};
});
});

View File

@ -87,4 +87,30 @@ describe('tests key generation', () => {
expect(keyABOrder).toBe(keyBAOrder);
});
it('tests argument replacement', () => {
const key = defaultKeyGenerator({
baseURL: 'http://example.com',
url: '',
params: { a: 1, b: 2 }
});
expect(key).toBe('get::http://example.com::{"a":1,"b":2}');
const groups = [
['http://example.com', '/http://example.com'],
['http://example.com', '/http://example.com/'],
['http://example.com/', '/http://example.com'],
['http://example.com/', '/http://example.com/']
];
for (const [first, second] of groups) {
expect(defaultKeyGenerator({ url: first })).toBe(
defaultKeyGenerator({ url: second })
);
expect(defaultKeyGenerator({ baseURL: first })).toBe(
defaultKeyGenerator({ baseURL: second })
);
}
});
});

View File

@ -1,88 +1,123 @@
import type { AxiosCacheInstance, CachedStorageValue } from '../../src';
import { updateCache } from '../../src/util/update-cache';
import type { CachedStorageValue } from '../../src';
import { defaultKeyGenerator } from '../../src/util/key-generator';
import { mockAxios } from '../mocks/axios';
import { EMPTY_RESPONSE } from '../utils';
const KEY = 'cacheKey';
const EMPTY_STATE = { state: 'empty' };
const DEFAULT_DATA = 'random-data';
const INITIAL_DATA: CachedStorageValue = {
data: {
...EMPTY_RESPONSE,
data: true
},
const cacheKey = defaultKeyGenerator({ url: 'https://example.com/' });
const cachedValue: CachedStorageValue = {
createdAt: Date.now(),
ttl: Infinity,
state: 'cached'
state: 'cached',
ttl: Infinity, // never expires
data: {
data: 'value',
headers: {},
status: 200,
statusText: '200 OK'
}
};
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.storage, DEFAULT_DATA, {
[KEY]: 'delete'
const axios = mockAxios({});
await axios.storage.set(cacheKey, cachedValue);
await axios.get('other-key', {
cache: { update: { [cacheKey]: 'delete' } }
});
const response = await axios.storage.get(KEY);
const cacheValue1 = await axios.storage.get(cacheKey);
expect(cacheValue1).toStrictEqual({ state: 'empty' });
expect(response).not.toBeFalsy();
expect(response).toStrictEqual(EMPTY_STATE);
});
//
it('tests for returning undefined', async () => {
await updateCache(axios.storage, DEFAULT_DATA, {
[KEY]: () => undefined
});
await axios.storage.set(cacheKey, cachedValue);
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.storage, DEFAULT_DATA, {
[KEY]: (cached, newData) => ({
state: 'cached',
ttl: Infinity,
createdAt: Date.now(),
data: {
...EMPTY_RESPONSE,
data: `${cached.data?.data}:${newData}`
await axios.get('other-key2', {
cache: {
update: {
[cacheKey]: () => 'delete'
}
})
}
});
const response = await axios.storage.get(KEY);
const cacheValue2 = await axios.storage.get(cacheKey);
expect(cacheValue2).toStrictEqual({ state: 'empty' });
expect(response).not.toBeFalsy();
expect(response).not.toStrictEqual(EMPTY_STATE);
//
expect(response.state).toBe('cached');
expect(response.data?.data).toBe(`${INITIAL_DATA.data?.data}:${DEFAULT_DATA}`);
await axios.storage.set(cacheKey, cachedValue);
await axios.get('other-key3', {
cache: { update: { [cacheKey]: () => Promise.resolve('delete') } }
});
const cacheValue3 = await axios.storage.get(cacheKey);
expect(cacheValue3).toStrictEqual({ state: 'empty' });
});
it('check if the state is loading while updating', async () => {
axios.storage.set(KEY, { state: 'loading' });
it('tests for ignore key', async () => {
const axios = mockAxios({});
await axios.storage.set(cacheKey, cachedValue);
const result = updateCache(axios.storage, DEFAULT_DATA, {
[KEY]: (cached, newData) => ({
state: 'cached',
ttl: Infinity,
createdAt: Date.now(),
data: {
...EMPTY_RESPONSE,
data: `${cached.data?.data}:${newData}`
}
})
await axios.get('other-key', {
cache: { update: { [cacheKey]: () => 'ignore' } }
});
expect(result).rejects.toThrowError();
const cacheValue = await axios.storage.get(cacheKey);
expect(cacheValue).toStrictEqual(cachedValue);
//
await axios.get('other-key2', {
cache: { update: { [cacheKey]: async () => Promise.resolve('ignore') } }
});
const cacheValue2 = await axios.storage.get(cacheKey);
expect(cacheValue2).toStrictEqual(cachedValue);
});
it('tests for new cached storage value', async () => {
const axios = mockAxios({});
await axios.storage.set(cacheKey, cachedValue);
await axios.get('other-key', {
cache: {
update: {
[cacheKey]: (cached) => {
if (cached.state !== 'cached') {
return 'ignore';
}
return {
...cached,
data: { ...cached.data, data: 1 }
};
}
}
}
});
const cacheValue = await axios.storage.get(cacheKey);
expect(cacheValue).not.toStrictEqual(cachedValue);
expect(cacheValue.data?.data).toBe(1);
});
it('tests updateCache with key is loading', async () => {
const axios = mockAxios({});
await axios.storage.set(cacheKey, { state: 'loading' });
const handler = jest.fn();
await axios.get('other-key', {
cache: {
update: {
[cacheKey]: handler
}
}
});
expect(handler).not.toHaveBeenCalled();
const cacheValue = await axios.storage.get(cacheKey);
expect(cacheValue.state).toBe('loading');
});
});

View File

@ -1,4 +1,4 @@
import type { AxiosResponse } from 'axios';
import type { CacheAxiosResponse } from '../src/cache/axios';
export const EMPTY_RESPONSE = {
headers: {},
@ -7,10 +7,16 @@ export const EMPTY_RESPONSE = {
data: true
};
export const createResponse = <R>(
config: Partial<AxiosResponse<R>>
): AxiosResponse<R> => {
return { ...EMPTY_RESPONSE, config: {}, data: {} as R, request: {}, ...config };
export const createResponse = <R>(config: Partial<CacheAxiosResponse<R>>) => {
return {
...EMPTY_RESPONSE,
config: {},
data: {} as R,
request: {},
id: 'empty-id',
cached: true,
...config
};
};
export const sleep = (ms: number): Promise<void> =>