Skip to content

Commit

Permalink
feat: cache working
Browse files Browse the repository at this point in the history
  • Loading branch information
arthurfiorette committed Sep 1, 2021
1 parent 5d8b698 commit 9f6e1a4
Show file tree
Hide file tree
Showing 15 changed files with 326 additions and 65 deletions.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,14 @@
"homepage": "https://github.com/ArthurFiorette/axios-cache-interceptor#readme",
"devDependencies": {
"@types/node": "^16.7.10",
"add": "^2.0.6",
"axios": "^0.21.1",
"prettier": "^2.3.2",
"prettier-plugin-jsdoc": "^0.3.23",
"prettier-plugin-organize-imports": "^2.3.3",
"typescript": "^4.4.2",
"yarn": "^1.22.11"
},
"dependencies": {
"@tusbar/cache-control": "^0.6.0"
}
}
10 changes: 7 additions & 3 deletions src/axios/cache.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { applyRequestInterceptor } from '#/interceptors/request';
import { applyResponseInterceptor } from '#/interceptors/response';
import { MemoryStorage } from '#/storage/memory';
import { defaultKeyGenerator } from '#/utils/key-generator';
import { AxiosInstance } from 'axios';
import { applyRequestInterceptor } from '../interceptors/request';
import { applyResponseInterceptor } from '../interceptors/response';
import { MemoryStorage } from '../storage/memory';
import { AxiosCacheInstance, CacheInstance, CacheRequestConfig } from './types';

type Options = CacheRequestConfig['cache'] & Partial<CacheInstance>;
Expand All @@ -13,6 +14,7 @@ export function createCache(
const axiosCache = axios as AxiosCacheInstance;

axiosCache.storage = options.storage || new MemoryStorage();
axiosCache.generateKey = defaultKeyGenerator;

// CacheRequestConfig values
axiosCache.defaults = {
Expand All @@ -21,6 +23,8 @@ export function createCache(
maxAge: 1000 * 60 * 5,
interpretHeader: false,
methods: ['get'],
shouldCache: ({ status }) => status >= 200 && status < 300,
update: {},
...options
}
};
Expand Down
40 changes: 39 additions & 1 deletion src/axios/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CacheStorage } from '#/storage/types';
import type {
AxiosInstance,
AxiosInterceptorManager,
Expand All @@ -6,12 +7,19 @@ import type {
AxiosResponse,
Method
} from 'axios';
import { CacheStorage } from '../storage/types';

/**
* Options that can be overridden per request
*/
export type CacheRequestConfig = AxiosRequestConfig & {
/**
* An id for this request, if this request is used in cache, only the last request made with this id will be returned.
*
* @see cacheKey
* @default undefined
*/
id?: string | number | symbol;

/**
* All cache options for the request
*/
Expand All @@ -37,6 +45,29 @@ export type CacheRequestConfig = AxiosRequestConfig & {
* @default ['get']
*/
methods?: Lowercase<Method>[];

/**
* The function to check if the response code permit being cached.
*
* @default ({ status }) => status >= 200 && status < 300
*/
shouldCache?: (response: AxiosResponse) => boolean;

/**
* Once the request is resolved, this specifies what requests should we change the cache.
* Can be used to update the request or delete other caches.
*
* If the function returns void, the entry is deleted
*
* This is independent if the request made was cached or not.
*
* The id used is the same as the id on `CacheRequestConfig['id']`, auto-generated or not.
*
* @default {}
*/
update?: {
[id: string]: 'delete' | ((oldValue: any, atual: any) => any | void);
};
};
};

Expand All @@ -47,6 +78,13 @@ export interface CacheInstance {
* @default new MemoryStorage()
*/
storage: CacheStorage;

/**
* The function used to create different keys for each request.
* Defaults to a function that priorizes the id, and if not specified,
* a string is generated using the method, baseUrl, params, and url
*/
generateKey: (options: CacheRequestConfig) => string;
}

export interface AxiosCacheInstance extends AxiosInstance, CacheInstance {
Expand Down
2 changes: 2 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const CACHED_RESPONSE_STATUS = 304;
export const CACHED_RESPONSE_STATUS_TEXT = '304 Cached by axios-cache-adapter';
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { createCache } from './axios/cache';
export * from './constants';
export * from './storage';
49 changes: 48 additions & 1 deletion src/interceptors/request.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,54 @@
import { AxiosCacheInstance } from '../axios/types';
import { AxiosCacheInstance } from '#/axios/types';
import {
CACHED_RESPONSE_STATUS,
CACHED_RESPONSE_STATUS_TEXT
} from '#/constants';
import { Deferred } from '#/utils/deferred';

export function applyRequestInterceptor(axios: AxiosCacheInstance) {
axios.interceptors.request.use(async (config) => {
// Only cache specified methods
if (
config.cache?.methods?.some(
(method) => (config.method || 'get').toLowerCase() == method
)
) {
return config;
}

const key = axios.generateKey(config);
const cache = await axios.storage.get(key);

// Not cached, continue the request, and mark it as fetching
if (cache.state == 'empty') {
await axios.storage.set(key, {
state: 'loading',
data: new Deferred(),
// 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!
});
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;
}

const { body, headers } = await cache.data;

config.adapter = () =>
Promise.resolve({
data: body,
config,
headers,
status: CACHED_RESPONSE_STATUS,
statusText: CACHED_RESPONSE_STATUS_TEXT
});

return config;
});
}
76 changes: 73 additions & 3 deletions src/interceptors/response.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,77 @@
import { AxiosCacheInstance } from '../axios/types';
import { AxiosCacheInstance } from '#/axios/types';
import { parse } from '@tusbar/cache-control';

export function applyResponseInterceptor(axios: AxiosCacheInstance) {
axios.interceptors.response.use(async (config) => {
return config;
axios.interceptors.response.use(async (response) => {
// Update other entries before updating himself
for (const [cacheKey, value] of Object.entries(
response.config.cache?.update || {}
)) {
if (value == 'delete') {
await axios.storage.remove(cacheKey);
continue;
}

const oldValue = await axios.storage.get(cacheKey);
const newValue = value(oldValue, response.data);
if(newValue !== undefined) {
await axios.storage.set(cacheKey, newValue);
} else {
await axios.storage.remove(cacheKey);
}
}

// Config told that this response should be cached.
if (!response.config.cache?.shouldCache!(response)) {
return response;
}

const key = axios.generateKey(response.config);
const cache = await axios.storage.get(key);

if (
// Response already is in cache.
cache.state === 'cached' ||
// Received response without being intercepted in the response
cache.state === 'empty'
) {
return response;
}

if (response.config.cache?.interpretHeader) {
const cacheControl = response.headers['cache-control'] || '';
const { noCache, noStore, maxAge } = parse(cacheControl);

// Header told that this response should not be cached.
if (noCache || noStore) {
return response;
}

const expirationTime = maxAge
? // Header max age in seconds
Date.now() + maxAge * 1000
: response.config.cache?.maxAge || axios.defaults.cache?.maxAge!;

cache.expiration = expirationTime;
} else {
// If the cache expiration has not been set, use the default expiration.
cache.expiration =
cache.expiration ||
response.config.cache?.maxAge ||
axios.defaults.cache?.maxAge!;
}

const data = { body: response.data, headers: response.headers };

// Resolve this deferred to update the cache after it
cache.data.resolve(data);

await axios.storage.set(key, {
data,
expiration: cache.expiration,
state: 'cached'
});

return response;
});
}
1 change: 0 additions & 1 deletion src/storage/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
export * from './memory';
export * from './types';
export * from './web';
export * from './wrapper';
9 changes: 1 addition & 8 deletions src/storage/memory.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
import { CacheStorage, StorageValue } from './types';

const emptyValue: StorageValue = {
data: null,
expires: -1,
state: 'empty'
};

export class MemoryStorage implements CacheStorage {
readonly storage: Map<string, StorageValue> = new Map();

Expand All @@ -17,8 +11,7 @@ export class MemoryStorage implements CacheStorage {
}

// Fresh copy to prevent code duplication
const empty = { ...emptyValue };

const empty = { data: null, expiration: -1, state: 'empty' } as const;
this.storage.set(key, empty);
return empty;
};
Expand Down
47 changes: 30 additions & 17 deletions src/storage/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { Deferred } from '../utils/deferred';

export interface CacheStorage {
/**
* Returns the cached value for the given key or a new empty
* Returns the cached value for the given key. Should return a 'empty'
* state StorageValue if the key does not exist.
*/
get: (key: string) => Promise<StorageValue>;
/**
Expand All @@ -13,20 +16,30 @@ export interface CacheStorage {
remove: (key: string) => Promise<void>;
}

export interface StorageValue {
/**
* The value of the cached response
*/
data: any | null;
export type CachedResponse = {
headers: any;
body: any;
};

/**
* The time when the cached response expires
* -1 means not cached
*/
expires: number;

/**
* The status of this value.
*/
state: 'cached' | 'empty' | 'loading';
}
/**
* The value returned for a given key.
*/
export type StorageValue =
| {
data: CachedResponse;
expiration: number;
state: 'cached';
}
| {
data: Deferred<CachedResponse>;
/**
* If interpretHeader is used, this value will be `-1`until the response is received
*/
expiration: number;
state: 'loading';
}
| {
data: null;
expiration: -1;
state: 'empty';
};
27 changes: 26 additions & 1 deletion src/storage/web.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,29 @@
import { WindowStorageWrapper } from './wrapper';
import { CacheStorage, StorageValue } from './types';
/**
* A storage that uses any {@link Storage} as his storage.
*/
export abstract class WindowStorageWrapper implements CacheStorage {
constructor(
readonly storage: Storage,
readonly prefix: string = 'axios-cache:'
) {}

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

set = async (key: string, value: StorageValue): Promise<void> => {
const json = JSON.stringify(value);
this.storage.setItem(this.prefix + key, json);
};

remove = async (key: string): Promise<void> => {
this.storage.removeItem(this.prefix + key);
};
}

export class LocalCacheStorage extends WindowStorageWrapper {
constructor(prefix?: string) {
Expand Down
24 changes: 0 additions & 24 deletions src/storage/wrapper.ts

This file was deleted.

0 comments on commit 9f6e1a4

Please sign in to comment.