feat: storage abstractions (#52)

* refactor: better support for different storages

* test: updated tests

* feat: export AxiosStorage

* style: fix linting
This commit is contained in:
Arthur Fiorette 2021-11-11 16:25:51 -03:00 committed by GitHub
parent 76a8af7433
commit b35ae3e574
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 121 additions and 141 deletions

9
src/cache/cache.ts vendored
View File

@ -2,7 +2,8 @@ import type { Method } from 'axios';
import type { Deferred } from 'typed-core/dist/promises/deferred';
import type { HeadersInterpreter } from '../header/types';
import type { AxiosInterceptor } from '../interceptors/types';
import type { CachedResponse, CacheStorage } from '../storage/types';
import type { AxiosStorage } from '../storage/storage';
import type { CachedResponse } from '../storage/types';
import type { CachePredicate, KeyGenerator } from '../util/types';
import type { CacheUpdater } from '../util/update-cache';
import type { CacheAxiosResponse, CacheRequestConfig } from './axios';
@ -54,7 +55,7 @@ export type CacheProperties = {
* The id used is the same as the id on `CacheRequestConfig['id']`,
* auto-generated or not.
*
* @default
* @default {}
*/
update: Record<string, CacheUpdater>;
};
@ -63,9 +64,9 @@ export interface CacheInstance {
/**
* The storage to save the cache data.
*
* @default new MemoryStorage()
* @default new MemoryAxiosStorage()
*/
storage: CacheStorage;
storage: AxiosStorage;
/**
* The function used to create different keys for each request.

4
src/cache/create.ts vendored
View File

@ -2,7 +2,7 @@ import Axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
import { defaultHeaderInterpreter } from '../header/interpreter';
import { CacheRequestInterceptor } from '../interceptors/request';
import { CacheResponseInterceptor } from '../interceptors/response';
import { MemoryStorage } from '../storage/memory';
import { MemoryAxiosStorage } from '../storage/memory';
import { defaultKeyGenerator } from '../util/key-generator';
import type { AxiosCacheInstance } from './axios';
import type { CacheInstance, CacheProperties } from './cache';
@ -28,7 +28,7 @@ export function useCache(
): AxiosCacheInstance {
const axiosCache = axios as AxiosCacheInstance;
axiosCache.storage = storage || new MemoryStorage();
axiosCache.storage = storage || new MemoryAxiosStorage({});
axiosCache.generateKey = generateKey || defaultKeyGenerator;
axiosCache.waiting = waiting || {};
axiosCache.headerInterpreter = headerInterpreter || defaultHeaderInterpreter;

View File

@ -3,5 +3,6 @@ export * from './cache/cache';
export * from './cache/create';
export * from './header/types';
export * from './interceptors/types';
export * from './storage/storage';
export * from './storage/types';
export * from './util/types';

44
src/storage/browser.ts Normal file
View File

@ -0,0 +1,44 @@
import { AxiosStorage } from './storage';
import type { EmptyStorageValue, 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.
*/
constructor(
readonly storage: Storage,
readonly prefix: string = BrowserAxiosStorage.DEFAULT_KEY_PREFIX
) {
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 set = (key: string, value: Exclude<StorageValue, EmptyStorageValue>): void => {
return this.storage.setItem(`${this.prefix}:${key}`, JSON.stringify(value));
};
public remove = (key: string): void | Promise<void> => {
return this.storage.removeItem(`${this.prefix}:${key}`);
};
}

View File

@ -1,17 +1,19 @@
import type { CacheStorage, StorageValue } from './types';
import { isCacheValid } from './util';
import { AxiosStorage } from './storage';
import type { CachedStorageValue, LoadingStorageValue, StorageValue } from './types';
export class MemoryStorage implements CacheStorage {
private readonly storage: Map<string, StorageValue> = new Map();
export class MemoryAxiosStorage extends AxiosStorage {
constructor(readonly storage: Record<string, StorageValue> = {}) {
super();
}
get = async (key: string): Promise<StorageValue> => {
const value = this.storage.get(key);
public get = (key: string): StorageValue => {
const value = this.storage[key];
if (!value) {
return { state: 'empty' };
}
if (isCacheValid(value) === false) {
if (!AxiosStorage.isValid(value)) {
this.remove(key);
return { state: 'empty' };
}
@ -19,11 +21,11 @@ export class MemoryStorage implements CacheStorage {
return value;
};
set = async (key: string, value: StorageValue): Promise<void> => {
this.storage.set(key, value);
public set = (key: string, value: CachedStorageValue | LoadingStorageValue): void => {
this.storage[key] = value;
};
remove = async (key: string): Promise<void> => {
this.storage.delete(key);
public remove = (key: string): void => {
delete this.storage[key];
};
}

38
src/storage/storage.ts Normal file
View File

@ -0,0 +1,38 @@
import type { EmptyStorageValue, 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}
*/
public abstract get: (key: string) => Promise<StorageValue> | StorageValue;
/**
* 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<StorageValue, EmptyStorageValue>
) => Promise<void> | void;
/**
* Removes the value for the given key
*/
public abstract remove: (key: string) => Promise<void> | void;
/**
* 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();
}
return true;
};
}

View File

@ -1,23 +1,3 @@
export interface CacheStorage {
/**
* Returns the cached value for the given key. Must handle cache
* miss and staling by returning a new `StorageValue` with `empty` state.
*/
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: LoadingStorageValue | CachedStorageValue) => Promise<void>;
/**
* Removes the value for the given key
*/
remove: (key: string) => Promise<void>;
}
export type CachedResponse = {
data?: any;
headers: Record<string, string>;

View File

@ -1,17 +0,0 @@
import type { StorageValue } from './types';
/**
* Returns true if a storage value can still be used by checking his
* createdAt and ttl values. Returns `'unknown'` when the cache.state
* is different from `'cached'`
*
* @param value The stored value
* @returns True if the cache can still be used of falsy otherwise
*/
export function isCacheValid(value: StorageValue): boolean | 'unknown' {
if (!value || value.state !== 'cached') {
return 'unknown';
}
return value.createdAt + value.ttl > Date.now();
}

View File

@ -1,66 +0,0 @@
import type { CacheStorage, StorageValue } from './types';
import { isCacheValid } from './util';
/**
* The key prefix used in WindowStorageWrapper to prevent key
* collisions with other code.
*/
export const DEFAULT_KEY_PREFIX = 'axios-cache-interceptor';
/**
* A storage that uses any {@link Storage} as his storage.
*
* **Note**: All storage keys used are prefixed with `prefix` value.
*/
export abstract class WindowStorageWrapper implements CacheStorage {
/**
* Creates a new instance of WindowStorageWrapper
*
* @param storage The storage to interact
* @param prefix The prefix to use for all keys or
* `DEFAULT_KEY_PREFIX` if not provided.
* @see DEFAULT_KEY_PREFIX
*/
constructor(readonly storage: Storage, readonly prefix: string = DEFAULT_KEY_PREFIX) {}
get = async (key: string): Promise<StorageValue> => {
const prefixedKey = this.prefixKey(key);
const json = this.storage.getItem(prefixedKey);
if (!json) {
return { state: 'empty' };
}
const parsed = JSON.parse(json);
if (isCacheValid(parsed) === false) {
this.storage.removeItem(prefixedKey);
return { state: 'empty' };
}
return parsed;
};
set = async (key: string, value: StorageValue): Promise<void> => {
const json = JSON.stringify(value);
this.storage.setItem(this.prefixKey(key), json);
};
remove = async (key: string): Promise<void> => {
this.storage.removeItem(this.prefixKey(key));
};
private prefixKey = (key: string): string => `${this.prefix}:${key}`;
}
export class LocalCacheStorage extends WindowStorageWrapper {
constructor(prefix?: string) {
super(window.localStorage, prefix);
}
}
export class SessionCacheStorage extends WindowStorageWrapper {
constructor(prefix?: string) {
super(window.sessionStorage, prefix);
}
}

View File

@ -1,8 +1,5 @@
import type {
CachedStorageValue,
CacheStorage,
EmptyStorageValue
} from '../storage/types';
import type { AxiosStorage } from '../storage/storage';
import type { CachedStorageValue, EmptyStorageValue } from '../storage/types';
export type CacheUpdater =
| 'delete'
@ -12,7 +9,7 @@ export type CacheUpdater =
) => CachedStorageValue | void);
export async function updateCache<T = any>(
storage: CacheStorage,
storage: AxiosStorage,
data: T,
entries: Record<string, CacheUpdater>
): Promise<void> {

View File

@ -1,6 +1,6 @@
import { MemoryStorage } from '../../src/storage/memory';
import { MemoryAxiosStorage } from '../../src/storage/memory';
import { testStorage } from './storages';
describe('tests common storages', () => {
testStorage('memory', () => new MemoryStorage());
testStorage('memory', () => new MemoryAxiosStorage());
});

View File

@ -1,7 +1,7 @@
import type { CacheStorage } from '../../src/storage/types';
import type { AxiosStorage } from '../../src/storage/storage';
import { EMPTY_RESPONSE } from '../constants';
export function testStorage(name: string, Storage: () => CacheStorage): void {
export function testStorage(name: string, Storage: () => AxiosStorage): void {
it(`tests ${name} storage methods`, async () => {
const storage = Storage();

View File

@ -1,20 +1,20 @@
import { isCacheValid } from '../../src/storage/util';
import { AxiosStorage } from '../../src/storage/storage';
describe('tests common storages', () => {
it('tests isCacheValid with empty state', () => {
const invalid = isCacheValid({ state: 'empty' });
const invalid = AxiosStorage.isValid({ state: 'empty' });
expect(invalid).toBe('unknown');
expect(invalid).toBe(true);
});
it('tests isCacheValid with loading state', () => {
const invalid = isCacheValid({ state: 'loading' });
const invalid = AxiosStorage.isValid({ state: 'loading' });
expect(invalid).toBe('unknown');
expect(invalid).toBe(true);
});
it('tests isCacheValid with overdue cached state', () => {
const isValid = isCacheValid({
const isValid = AxiosStorage.isValid({
state: 'cached',
data: {} as any, // doesn't matter
createdAt: Date.now() - 2000, // 2 seconds in the past
@ -24,8 +24,8 @@ describe('tests common storages', () => {
expect(isValid).toBe(false);
});
it('tests isCacheValid with overdue cached state', () => {
const isValid = isCacheValid({
it('tests isCacheValid with cached state', () => {
const isValid = AxiosStorage.isValid({
state: 'cached',
data: {} as any, // doesn't matter
createdAt: Date.now(),

View File

@ -2,10 +2,10 @@
* @jest-environment jsdom
*/
import { LocalCacheStorage, SessionCacheStorage } from '../../src/storage/web';
import { BrowserAxiosStorage } from '../../src/storage/browser';
import { testStorage } from './storages';
describe('tests web storages', () => {
testStorage('local-storage', () => new LocalCacheStorage());
testStorage('session-storage', () => new SessionCacheStorage());
testStorage('local-storage', () => new BrowserAxiosStorage(localStorage));
testStorage('session-storage', () => new BrowserAxiosStorage(sessionStorage));
});