From 17682ca0b92db3771f337203fae1cb142c16c20b Mon Sep 17 00:00:00 2001 From: Arthur Fiorette Date: Sun, 1 May 2022 10:03:30 -0300 Subject: [PATCH] Pass `currentRequest` on each access to storages (#220) (#226) * feat: initial working code * fix: better usage of currentRequest on storages * feat: added tests --- src/interceptors/request.ts | 28 +++++++----- src/interceptors/response.ts | 39 ++++++++-------- src/storage/build.ts | 17 ++++--- src/storage/types.ts | 34 +++++++++++--- src/util/update-cache.ts | 8 ++-- test/storage/is-storage.test.ts | 32 ------------- test/storage/storages.test.ts | 80 +++++++++++++++++++++++++++++++++ 7 files changed, 160 insertions(+), 78 deletions(-) delete mode 100644 test/storage/is-storage.test.ts create mode 100644 test/storage/storages.test.ts diff --git a/src/interceptors/request.ts b/src/interceptors/request.ts index 55cae5f..1c217a6 100644 --- a/src/interceptors/request.ts +++ b/src/interceptors/request.ts @@ -41,7 +41,7 @@ export function defaultRequestInterceptor(axios: AxiosCacheInstance) { const key = (config.id = axios.generateKey(config)); // Assumes that the storage handled staled responses - let cache = await axios.storage.get(key); + let cache = await axios.storage.get(key, config); // Not cached, continue the request, and mark it as fetching emptyOrStale: if (cache.state === 'empty' || cache.state === 'stale') { @@ -51,7 +51,7 @@ export function defaultRequestInterceptor(axios: AxiosCacheInstance) { * started executing. */ if (axios.waiting[key]) { - cache = (await axios.storage.get(key)) as + cache = (await axios.storage.get(key, config)) as | CachedStorageValue | LoadingStorageValue; @@ -83,17 +83,21 @@ export function defaultRequestInterceptor(axios: AxiosCacheInstance) { */ axios.waiting[key]?.catch(() => undefined); - await axios.storage.set(key, { - state: 'loading', - previous: cache.state, + await axios.storage.set( + key, + { + state: 'loading', + previous: cache.state, - // Eslint complains a lot :) - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any - data: cache.data as any, + // Eslint complains a lot :) + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any + data: cache.data as any, - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any - createdAt: cache.createdAt as any - }); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any + createdAt: cache.createdAt as any + }, + config + ); if (cache.state === 'stale') { updateStaleRequest(cache, config as ConfigWithCache); @@ -126,7 +130,7 @@ export function defaultRequestInterceptor(axios: AxiosCacheInstance) { // Just in case, the deferred doesn't exists. /* istanbul ignore if 'really hard to test' */ if (!deferred) { - await axios.storage.remove(key); + await axios.storage.remove(key, config); return config; } diff --git a/src/interceptors/response.ts b/src/interceptors/response.ts index e7a00b4..57b54e4 100644 --- a/src/interceptors/response.ts +++ b/src/interceptors/response.ts @@ -19,9 +19,9 @@ export function defaultResponseInterceptor( * * Also update the waiting list for this key by rejecting it. */ - const rejectResponse = async (responseId: string) => { + const rejectResponse = async (responseId: string, config: CacheRequestConfig) => { // Update the cache to empty to prevent infinite loading state - await axios.storage.remove(responseId); + await axios.storage.remove(responseId, config); // Reject the deferred if present axios.waiting[responseId]?.reject(null); delete axios.waiting[responseId]; @@ -59,8 +59,9 @@ export function defaultResponseInterceptor( // Request interceptor merges defaults with per request configuration const cacheConfig = response.config.cache as CacheProperties; + const config = response.config; - const cache = await axios.storage.get(id); + const cache = await axios.storage.get(id, config); if ( // If the request interceptor had a problem @@ -86,7 +87,7 @@ export function defaultResponseInterceptor( !cache.data && !(await testCachePredicate(response, cacheConfig.cachePredicate)) ) { - await rejectResponse(id); + await rejectResponse(id, config); if (__ACI_DEV__) { axios.debug?.({ @@ -125,17 +126,13 @@ export function defaultResponseInterceptor( // Cache should not be used if (expirationTime === 'dont cache') { - await rejectResponse(id); + await rejectResponse(id, config); if (__ACI_DEV__) { axios.debug?.({ id, msg: `Cache header interpreted as 'dont cache'`, - data: { - cache, - response, - expirationTime - } + data: { cache, response, expirationTime } }); } @@ -191,7 +188,7 @@ export function defaultResponseInterceptor( } // Define this key as cache on the storage - await axios.storage.set(id, newCache); + await axios.storage.set(id, newCache, config); if (__ACI_DEV__) { axios.debug?.({ @@ -206,7 +203,7 @@ export function defaultResponseInterceptor( }; const onRejected: ResponseInterceptor['onRejected'] = async (error) => { - const config = error['config'] as CacheRequestConfig; + const config = error.config as CacheRequestConfig; // config.cache should always exists, at least from global config merge. if (!config?.cache || !config.id) { @@ -220,7 +217,7 @@ export function defaultResponseInterceptor( throw error; } - const cache = await axios.storage.get(config.id); + const cache = await axios.storage.get(config.id, config); const cacheConfig = config.cache; if ( @@ -228,7 +225,7 @@ export function defaultResponseInterceptor( cache.state !== 'loading' || cache.previous !== 'stale' ) { - await rejectResponse(config.id); + await rejectResponse(config.id, config); if (__ACI_DEV__) { axios.debug?.({ @@ -267,11 +264,15 @@ export function defaultResponseInterceptor( delete axios.waiting[config.id]; // re-mark the cache as stale - await axios.storage.set(config.id, { - state: 'stale', - createdAt: Date.now(), - data: cache.data - }); + await axios.storage.set( + config.id, + { + state: 'stale', + createdAt: Date.now(), + data: cache.data + }, + config + ); if (__ACI_DEV__) { axios.debug?.({ diff --git a/src/storage/build.ts b/src/storage/build.ts index 8127190..d2e0818 100644 --- a/src/storage/build.ts +++ b/src/storage/build.ts @@ -1,3 +1,4 @@ +import type { CacheRequestConfig } from '../cache/axios'; import { Header } from '../header/headers'; import type { MaybePromise } from '../util/types'; import type { @@ -34,8 +35,14 @@ export type BuildStorage = Omit & { /** * Returns the value for the given key. This method does not have to make checks for * cache invalidation or anything. It just returns what was previous saved, if present. + * + * @param key The key to look for + * @param currentRequest The current {@link CacheRequestConfig}, if any */ - find: (key: string) => MaybePromise; + find: ( + key: string, + currentRequest?: CacheRequestConfig + ) => MaybePromise; }; /** @@ -61,8 +68,8 @@ export function buildStorage({ set, find, remove }: BuildStorage): AxiosStorage [storage]: 1, set, remove, - get: async (key) => { - const value = await find(key); + get: async (key, config) => { + const value = await find(key, config); if (!value) { return { state: 'empty' }; @@ -83,11 +90,11 @@ export function buildStorage({ set, find, remove }: BuildStorage): AxiosStorage data: value.data }; - await set(key, stale); + await set(key, stale, config); return stale; } - await remove(key); + await remove(key, config); return { state: 'empty' }; } }; diff --git a/src/storage/types.ts b/src/storage/types.ts index 6c9bcbd..f498f6e 100644 --- a/src/storage/types.ts +++ b/src/storage/types.ts @@ -1,4 +1,5 @@ import type { AxiosResponseHeaders } from 'axios'; +import type { CacheRequestConfig } from '../cache/axios'; import type { MaybePromise } from '../util/types'; export type CachedResponse = { @@ -78,13 +79,34 @@ export type AxiosStorage = { /** * Sets a new value for the given key * - * Use CacheStorage.remove(key) to define a key to 'empty' state. + * Use {@link AxiosStorage.remove} to define a key with `'empty'` state. + * + * @param key The key to look for + * @param value The value to save. + * @param currentRequest The current {@link CacheRequestConfig}, if any */ - set: (key: string, value: NotEmptyStorageValue) => MaybePromise; + set: ( + key: string, + value: NotEmptyStorageValue, + currentRequest?: CacheRequestConfig + ) => MaybePromise; - /** Removes the value for the given key */ - remove: (key: string) => MaybePromise; + /** + * Removes the value for the given key + * + * @param key The key to look for + * @param currentRequest The current {@link CacheRequestConfig}, if any + */ + remove: (key: string, currentRequest?: CacheRequestConfig) => MaybePromise; - /** Returns the value for the given key. This method make checks for cache invalidation or etc. */ - get: (key: string) => MaybePromise; + /** + * Returns the value for the given key. This method make checks for cache invalidation or etc. + * + * If the provided `find()` method returned null, this will map it to a `'empty'` storage value. + * + * @param key The key to look for + * @param currentRequest The current {@link CacheRequestConfig}, if any + * @returns The saved value for the given key. + */ + get: (key: string, currentRequest?: CacheRequestConfig) => MaybePromise; }; diff --git a/src/util/update-cache.ts b/src/util/update-cache.ts index 1ef346b..df682e0 100644 --- a/src/util/update-cache.ts +++ b/src/util/update-cache.ts @@ -13,11 +13,11 @@ export async function updateCache( const updater = entries[cacheKey]!; if (updater === 'delete') { - await storage.remove(cacheKey); + await storage.remove(cacheKey, data.config); continue; } - const value = await storage.get(cacheKey); + const value = await storage.get(cacheKey, data.config); if (value.state === 'loading') { continue; @@ -26,12 +26,12 @@ export async function updateCache( const newValue = await updater(value, data); if (newValue === 'delete') { - await storage.remove(cacheKey); + await storage.remove(cacheKey, data.config); continue; } if (newValue !== 'ignore') { - await storage.set(cacheKey, newValue); + await storage.set(cacheKey, newValue, data.config); } } } diff --git a/test/storage/is-storage.test.ts b/test/storage/is-storage.test.ts deleted file mode 100644 index 6463ed3..0000000 --- a/test/storage/is-storage.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** @jest-environment jsdom */ - -import { Axios } from 'axios'; -import { isStorage } from '../../src/storage/build'; -import { buildMemoryStorage } from '../../src/storage/memory'; -import type { AxiosStorage } from '../../src/storage/types'; -import { buildWebStorage } from '../../src/storage/web-api'; -import { mockAxios } from '../mocks/axios'; - -it('tests isStorage function', () => { - expect(isStorage(void 0)).toBe(false); - expect(isStorage(1)).toBe(false); - expect(isStorage('a')).toBe(false); - expect(isStorage({})).toBe(false); - expect(isStorage(Axios)).toBe(false); - expect(isStorage(() => 0)).toBe(false); - expect(isStorage(null)).toBe(false); - expect(isStorage(undefined)).toBe(false); - expect(isStorage({ a: 1, b: 'a' })).toBe(false); - - expect(isStorage(buildMemoryStorage())).toBe(true); - expect(isStorage(buildWebStorage(localStorage))).toBe(true); - expect(isStorage(buildWebStorage(sessionStorage))).toBe(true); -}); - -it('tests setupCache without proper storage', () => { - expect(() => - mockAxios({ - storage: {} as AxiosStorage - }) - ).toThrowError(); -}); diff --git a/test/storage/storages.test.ts b/test/storage/storages.test.ts new file mode 100644 index 0000000..1e12539 --- /dev/null +++ b/test/storage/storages.test.ts @@ -0,0 +1,80 @@ +/** @jest-environment jsdom */ + +import { Axios } from 'axios'; +import { buildStorage, isStorage } from '../../src/storage/build'; +import { buildMemoryStorage } from '../../src/storage/memory'; +import type { AxiosStorage, StorageValue } from '../../src/storage/types'; +import { buildWebStorage } from '../../src/storage/web-api'; +import { mockAxios } from '../mocks/axios'; + +it('tests isStorage function', () => { + expect(isStorage(void 0)).toBe(false); + expect(isStorage(1)).toBe(false); + expect(isStorage('a')).toBe(false); + expect(isStorage({})).toBe(false); + expect(isStorage(Axios)).toBe(false); + expect(isStorage(() => 0)).toBe(false); + expect(isStorage(null)).toBe(false); + expect(isStorage(undefined)).toBe(false); + expect(isStorage({ a: 1, b: 'a' })).toBe(false); + + expect(isStorage(buildMemoryStorage())).toBe(true); + expect(isStorage(buildWebStorage(localStorage))).toBe(true); + expect(isStorage(buildWebStorage(sessionStorage))).toBe(true); +}); + +it('tests setupCache without proper storage', () => { + expect(() => + mockAxios({ + storage: {} as AxiosStorage + }) + ).toThrowError(); +}); + +it('tests that a normal request workflow will always have a currentRequest', async () => { + const memory: Record = {}; + const isCR = 'unique-RANDOM-key-8ya5re28364ri'; + + const storage = buildStorage({ + find(key, cr) { + //@ts-expect-error ignore + expect(cr[isCR]).toBe(true); + return memory[key]; + }, + set(key, value, cr) { + //@ts-expect-error ignore + expect(cr[isCR]).toBe(true); + memory[key] = value; + }, + remove(key, cr) { + //@ts-expect-error ignore + expect(cr[isCR]).toBe(true); + delete memory[key]; + } + }); + + const axios = mockAxios({ storage }); + //@ts-expect-error ignore + axios.defaults[isCR] = true; + + const req1 = axios.get('https://api.example.com/'); + const req2 = axios.get('https://api.example.com/'); + + const [res1, res2] = await Promise.all([req1, req2]); + + expect(res1.status).toBe(200); + expect(res1.cached).toBeFalsy(); + + expect(res2.status).toBe(200); + expect(res2.cached).toBeTruthy(); + + expect(res1.id).toBe(res2.id); + + const cache = await axios.storage.get(res1.id, { + // sample of a request config. Just to the test pass. + //@ts-expect-error ignore + [isCR]: true + }); + + expect(cache.state).toBe('cached'); +});