feat: port caching feature to fetch proposal
Co-authored-by: Yevgeniy Valeyev <yevgeniy.valeyev@ing.com>
This commit is contained in:
parent
c3e5a6596d
commit
bbffd7105f
12 changed files with 1298 additions and 66 deletions
5
.changeset/twelve-apes-reflect.md
Normal file
5
.changeset/twelve-apes-reflect.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@lion/ajax': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Added Ajax cache interceptors.
|
||||||
|
|
@ -27,6 +27,9 @@ npm i --save @lion/ajax
|
||||||
|
|
||||||
#### GET request
|
#### GET request
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { ajax } from '@lion/ajax';
|
||||||
|
|
||||||
const response = await ajax.request('/api/users');
|
const response = await ajax.request('/api/users');
|
||||||
const users = await response.json();
|
const users = await response.json();
|
||||||
```
|
```
|
||||||
|
|
@ -89,6 +92,152 @@ try {
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Ajax Cache
|
||||||
|
|
||||||
|
A caching library that uses `lion-web/ajax` and adds cache interceptors to provide caching for use in
|
||||||
|
frontend `services`.
|
||||||
|
|
||||||
|
> Technical documentation and decisions can be found in
|
||||||
|
> [./docs/technical-docs.md](./docs/technical-docs.md)
|
||||||
|
|
||||||
|
### Getting started
|
||||||
|
|
||||||
|
Consume the global `ajax` instance and add the interceptors to it, using a cache configuration
|
||||||
|
which is applied on application level. If a developer wants to add specifics to cache behavior
|
||||||
|
they have to provide a cache config per action (`get`, `post`, etc.) via `cacheOptions` field of local ajax config,
|
||||||
|
see examples below.
|
||||||
|
|
||||||
|
> **Note**: make sure to add the **interceptors** only **once**. This is usually
|
||||||
|
> done on app-level
|
||||||
|
|
||||||
|
```js
|
||||||
|
import {
|
||||||
|
ajax,
|
||||||
|
cacheRequestInterceptorFactory,
|
||||||
|
cacheResponseInterceptorFactory,
|
||||||
|
} from '@lion-web/ajax.js';
|
||||||
|
|
||||||
|
const globalCacheOptions = {
|
||||||
|
useCache: true,
|
||||||
|
timeToLive: 1000 * 60 * 5, // 5 minutes
|
||||||
|
};
|
||||||
|
// Cache is removed each time an identifier changes,
|
||||||
|
// for instance when a current user is logged out
|
||||||
|
const getCacheIdentifier = () => getActiveProfile().profileId;
|
||||||
|
|
||||||
|
ajax.addRequestInterceptor(cacheRequestInterceptorFactory(getCacheIdentifier, globalCacheOptions));
|
||||||
|
ajax.addResponseInterceptor(
|
||||||
|
cacheResponseInterceptorFactory(getCacheIdentifier, globalCacheOptions),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { response, body } = await ajax.requestJson('/my-url');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ajax cache example
|
||||||
|
|
||||||
|
```js
|
||||||
|
import {
|
||||||
|
ajax,
|
||||||
|
cacheRequestInterceptorFactory,
|
||||||
|
cacheResponseInterceptorFactory,
|
||||||
|
} from '@lion-web/ajax';
|
||||||
|
|
||||||
|
const getCacheIdentifier = () => getActiveProfile().profileId;
|
||||||
|
|
||||||
|
const globalCacheOptions = {
|
||||||
|
useCache: false,
|
||||||
|
timeToLive: 50, // default: one hour (the cache instance will be replaced in 1 hour, regardless of this setting)
|
||||||
|
methods: ['get'], // default: ['get'] NOTE for now only 'get' is supported
|
||||||
|
// requestIdentificationFn: (requestConfig) => { }, // see docs below for more info
|
||||||
|
// invalidateUrls: [], see docs below for more info
|
||||||
|
// invalidateUrlsRegex: RegExp, // see docs below for more info
|
||||||
|
};
|
||||||
|
|
||||||
|
// pass a function to the interceptorFactory that retrieves a cache identifier
|
||||||
|
// ajax.interceptors.request.use(cacheRequestInterceptorFactory(getCacheIdentifier, cacheOptions));
|
||||||
|
// ajax.interceptors.response.use(
|
||||||
|
// cacheResponseInterceptorFactory(getCacheIdentifier, cacheOptions),
|
||||||
|
// );
|
||||||
|
|
||||||
|
class TodoService {
|
||||||
|
constructor() {
|
||||||
|
this.localAjaxConfig = {
|
||||||
|
cacheOptions: {
|
||||||
|
invalidateUrls: ['/api/todosbykeyword'], // default: []
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all todos from cache if not older than 5 minutes
|
||||||
|
*/
|
||||||
|
getTodos() {
|
||||||
|
return ajax.requestJson(`/api/todos`, this.localAjaxConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
getTodosByKeyword(keyword) {
|
||||||
|
return ajax.requestJson(`/api/todosbykeyword/${keyword}`, this.localAjaxConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates new todo and invalidates cache.
|
||||||
|
* `getTodos` will NOT take the response from cache
|
||||||
|
*/
|
||||||
|
saveTodo(todo) {
|
||||||
|
return ajax.requestJson(`/api/todos`, { method: 'POST', body: todo, ...this.localAjaxConfig });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If a value returned by `cacheIdentifier` changes the cache is reset. We avoid situation of accessing old cache and proactively clean it, for instance when a user session is ended.
|
||||||
|
|
||||||
|
### Ajax cache Options
|
||||||
|
|
||||||
|
```js
|
||||||
|
const cacheOptions = {
|
||||||
|
// `useCache`: determines wether or not to use the cache
|
||||||
|
// can be boolean
|
||||||
|
// default: false
|
||||||
|
useCache: true,
|
||||||
|
|
||||||
|
// `timeToLive`: is the time the cache should be kept in ms
|
||||||
|
// default: 0
|
||||||
|
// Note: regardless of this setting, the cache instance holding all the caches
|
||||||
|
// will be invalidated after one hour
|
||||||
|
timeToLive: 1000 * 60 * 5,
|
||||||
|
|
||||||
|
// `methods`: an array of methods on which this configuration is applied
|
||||||
|
// Note: when `useCache` is `false` this will not be used
|
||||||
|
// NOTE: ONLY GET IS SUPPORTED
|
||||||
|
// default: ['get']
|
||||||
|
methods: ['get'],
|
||||||
|
|
||||||
|
// `invalidateUrls`: an array of strings that for each string that partially
|
||||||
|
// occurs as key in the cache, will be removed
|
||||||
|
// default: []
|
||||||
|
// Note: can be invalidated only by non-get request to the same url
|
||||||
|
invalidateUrls: ['/api/todosbykeyword'],
|
||||||
|
|
||||||
|
// `invalidateUrlsRegex`: a RegExp object to match and delete
|
||||||
|
// each matched key in the cache
|
||||||
|
// Note: can be invalidated only by non-get request to the same url
|
||||||
|
invalidateUrlsRegex: /posts/
|
||||||
|
|
||||||
|
// `requestIdentificationFn`: a function to provide a string that should be
|
||||||
|
// taken as a key in the cache.
|
||||||
|
// This can be used to cache post-requests.
|
||||||
|
// default: (requestConfig, searchParamsSerializer) => url + params
|
||||||
|
requestIdentificationFn: (request, serializer) => {
|
||||||
|
return `${request.url}?${serializer(request.params)}`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Considerations
|
||||||
|
|
||||||
## Fetch Polyfill
|
## Fetch Polyfill
|
||||||
|
|
||||||
For IE11 you will need a polyfill for fetch. You should add this on your top level layer, e.g. your application.
|
For IE11 you will need a polyfill for fetch. You should add this on your top level layer, e.g. your application.
|
||||||
|
|
|
||||||
45
packages/ajax/docs/cache-technical-docs.md
Normal file
45
packages/ajax/docs/cache-technical-docs.md
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
# Ajax Cache
|
||||||
|
|
||||||
|
## Technical documentation
|
||||||
|
|
||||||
|
The library consists of 2 major parts:
|
||||||
|
|
||||||
|
1. A cache class
|
||||||
|
2. Request and Response Interceptors
|
||||||
|
|
||||||
|
### Cache class
|
||||||
|
|
||||||
|
The cache class is responsible for keeping cached data and keeping it valid.
|
||||||
|
This class isn't exposed outside, and remains private. Together with this class
|
||||||
|
we provide a `getCache(cacheIdentifier)` method that enforces a clean cache when
|
||||||
|
the `cacheIdentifier` changes.
|
||||||
|
|
||||||
|
> **Note**: the `cacheIdentifier` should be bound to the users session.
|
||||||
|
> Advice: Use the sessionToken as cacheIdentifier
|
||||||
|
|
||||||
|
Core invalidation rules are:
|
||||||
|
|
||||||
|
1. The `LionCache` instance is bound to a `cacheIdentifier`. When the `getCache`
|
||||||
|
receives another token, all instances of `LionCache` will be invalidated.
|
||||||
|
2. The `LionCache` instance is created with an expiration date **one hour** in
|
||||||
|
the future. Each method on the `LionCache` validates that this time hasn't
|
||||||
|
passed, and if it does, the cache object in the `LionCache` is cleared.
|
||||||
|
|
||||||
|
### Request and Response Interceptors
|
||||||
|
|
||||||
|
The interceptors are the core of the logic of when to cache.
|
||||||
|
|
||||||
|
To make the cache mechanism work, these interceptors have to be added to an ajax instance (for caching needs).
|
||||||
|
|
||||||
|
The **request interceptor**'s main goal is to determine whether or not to
|
||||||
|
**return the cached object**. This is done based on the options that are being
|
||||||
|
passed to the factory function.
|
||||||
|
|
||||||
|
The **response interceptor**'s goal is to determine **when to cache** the
|
||||||
|
requested response, based on the options that are being passed in the factory
|
||||||
|
function.
|
||||||
|
|
||||||
|
Interceptors require `cacheIdentifier` function and `cacheOptions` config.
|
||||||
|
The configuration is used by the interceptors to determine what to put in the cache and when to use the cached data.
|
||||||
|
|
||||||
|
A cache configuration per action (pre `get` etc) can be placed in ajax configuration in `lionCacheOptions` field, it needed for situations when you want your, for instance, `get` request to have specific cache parameters, like `timeToLive`.
|
||||||
|
|
@ -7,3 +7,9 @@ export {
|
||||||
createXSRFRequestInterceptor,
|
createXSRFRequestInterceptor,
|
||||||
getCookie,
|
getCookie,
|
||||||
} from './src/interceptors.js';
|
} from './src/interceptors.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
cacheRequestInterceptorFactory,
|
||||||
|
cacheResponseInterceptorFactory,
|
||||||
|
validateOptions,
|
||||||
|
} from './src/interceptors-cache.js';
|
||||||
|
|
|
||||||
|
|
@ -2,31 +2,7 @@
|
||||||
import { acceptLanguageRequestInterceptor, createXSRFRequestInterceptor } from './interceptors.js';
|
import { acceptLanguageRequestInterceptor, createXSRFRequestInterceptor } from './interceptors.js';
|
||||||
import { AjaxClientFetchError } from './AjaxClientFetchError.js';
|
import { AjaxClientFetchError } from './AjaxClientFetchError.js';
|
||||||
|
|
||||||
/**
|
import './typedef.js';
|
||||||
* @typedef {Object} AjaxClientConfig configuration for the AjaxClient instance
|
|
||||||
* @property {boolean} [addAcceptLanguage] the Accept-Language request HTTP header advertises
|
|
||||||
* which languages the client is able to understand, and which locale variant is preferred.
|
|
||||||
* @property {string|null} [xsrfCookieName] name of the XSRF cookie to read from
|
|
||||||
* @property {string|null} [xsrfHeaderName] name of the XSRF header to set
|
|
||||||
* @property {string} [jsonPrefix] the json prefix to use when fetching json (if any)
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Intercepts a Request before fetching. Must return an instance of Request or Response.
|
|
||||||
* If a Respone is returned, the network call is skipped and it is returned as is.
|
|
||||||
* @typedef {(request: Request) => Promise<Request | Response>} RequestInterceptor
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Intercepts a Response before returning. Must return an instance of Response.
|
|
||||||
* @typedef {(response: Response) => Promise<Response>} ResponseInterceptor
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Overrides the body property to also allow javascript objects
|
|
||||||
* as they get string encoded automatically
|
|
||||||
* @typedef {import('../types/ajaxClientTypes').LionRequestInit} LionRequestInit
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP Client which acts as a small wrapper around `fetch`. Allows registering hooks which
|
* HTTP Client which acts as a small wrapper around `fetch`. Allows registering hooks which
|
||||||
|
|
@ -35,32 +11,45 @@ import { AjaxClientFetchError } from './AjaxClientFetchError.js';
|
||||||
*/
|
*/
|
||||||
export class AjaxClient {
|
export class AjaxClient {
|
||||||
/**
|
/**
|
||||||
* @param {AjaxClientConfig} config
|
* @param {Partial<AjaxClientConfig>} config
|
||||||
*/
|
*/
|
||||||
constructor(config = {}) {
|
constructor(config = {}) {
|
||||||
const {
|
this.__config = {
|
||||||
addAcceptLanguage = true,
|
addAcceptLanguage: true,
|
||||||
xsrfCookieName = 'XSRF-TOKEN',
|
xsrfCookieName: 'XSRF-TOKEN',
|
||||||
xsrfHeaderName = 'X-XSRF-TOKEN',
|
xsrfHeaderName: 'X-XSRF-TOKEN',
|
||||||
jsonPrefix,
|
jsonPrefix: '',
|
||||||
} = config;
|
...config,
|
||||||
|
};
|
||||||
|
|
||||||
/** @type {string | undefined} */
|
/** @type {Array.<RequestInterceptor|CachedRequestInterceptor>} */
|
||||||
this._jsonPrefix = jsonPrefix;
|
|
||||||
/** @type {RequestInterceptor[]} */
|
|
||||||
this._requestInterceptors = [];
|
this._requestInterceptors = [];
|
||||||
/** @type {ResponseInterceptor[]} */
|
/** @type {Array.<ResponseInterceptor|CachedResponseInterceptor>} */
|
||||||
this._responseInterceptors = [];
|
this._responseInterceptors = [];
|
||||||
|
|
||||||
if (addAcceptLanguage) {
|
if (this.__config.addAcceptLanguage) {
|
||||||
this.addRequestInterceptor(acceptLanguageRequestInterceptor);
|
this.addRequestInterceptor(acceptLanguageRequestInterceptor);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (xsrfCookieName && xsrfHeaderName) {
|
if (this.__config.xsrfCookieName && this.__config.xsrfHeaderName) {
|
||||||
this.addRequestInterceptor(createXSRFRequestInterceptor(xsrfCookieName, xsrfHeaderName));
|
this.addRequestInterceptor(
|
||||||
|
createXSRFRequestInterceptor(this.__config.xsrfCookieName, this.__config.xsrfHeaderName),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the config for the instance
|
||||||
|
* @param {AjaxClientConfig} config configuration for the AjaxClass instance
|
||||||
|
*/
|
||||||
|
set options(config) {
|
||||||
|
this.__config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
get options() {
|
||||||
|
return this.__config;
|
||||||
|
}
|
||||||
|
|
||||||
/** @param {RequestInterceptor} requestInterceptor */
|
/** @param {RequestInterceptor} requestInterceptor */
|
||||||
addRequestInterceptor(requestInterceptor) {
|
addRequestInterceptor(requestInterceptor) {
|
||||||
this._requestInterceptors.push(requestInterceptor);
|
this._requestInterceptors.push(requestInterceptor);
|
||||||
|
|
@ -92,11 +81,13 @@ export class AjaxClient {
|
||||||
* interceptors.
|
* interceptors.
|
||||||
*
|
*
|
||||||
* @param {RequestInfo} info
|
* @param {RequestInfo} info
|
||||||
* @param {RequestInit} [init]
|
* @param {RequestInit & Partial<CacheRequestExtension>} [init]
|
||||||
* @returns {Promise<Response>}
|
* @returns {Promise<Response>}
|
||||||
*/
|
*/
|
||||||
async request(info, init) {
|
async request(info, init) {
|
||||||
const request = new Request(info, init);
|
const request = /** @type {CacheRequest} */ (new Request(info, { ...init }));
|
||||||
|
request.cacheOptions = init?.cacheOptions;
|
||||||
|
request.params = init?.params;
|
||||||
|
|
||||||
// run request interceptors, returning directly and skipping the network
|
// run request interceptors, returning directly and skipping the network
|
||||||
// if a interceptor returns a Response
|
// if a interceptor returns a Response
|
||||||
|
|
@ -112,7 +103,8 @@ export class AjaxClient {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(interceptedRequest);
|
const response = /** @type {CacheResponse} */ (await fetch(interceptedRequest));
|
||||||
|
response.request = interceptedRequest;
|
||||||
|
|
||||||
let interceptedResponse = response;
|
let interceptedResponse = response;
|
||||||
for (const intercept of this._responseInterceptors) {
|
for (const intercept of this._responseInterceptors) {
|
||||||
|
|
@ -156,9 +148,9 @@ export class AjaxClient {
|
||||||
const response = await this.request(info, jsonInit);
|
const response = await this.request(info, jsonInit);
|
||||||
let responseText = await response.text();
|
let responseText = await response.text();
|
||||||
|
|
||||||
if (typeof this._jsonPrefix === 'string') {
|
if (typeof this.__config.jsonPrefix === 'string') {
|
||||||
if (responseText.startsWith(this._jsonPrefix)) {
|
if (responseText.startsWith(this.__config.jsonPrefix)) {
|
||||||
responseText = responseText.substring(this._jsonPrefix.length);
|
responseText = responseText.substring(this.__config.jsonPrefix.length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
346
packages/ajax/src/interceptors-cache.js
Normal file
346
packages/ajax/src/interceptors-cache.js
Normal file
|
|
@ -0,0 +1,346 @@
|
||||||
|
/* eslint-disable consistent-return */
|
||||||
|
/* eslint-disable no-param-reassign */
|
||||||
|
|
||||||
|
import './typedef.js';
|
||||||
|
|
||||||
|
const SECOND = 1000;
|
||||||
|
const MINUTE = SECOND * 60;
|
||||||
|
const HOUR = MINUTE * 60;
|
||||||
|
|
||||||
|
class Cache {
|
||||||
|
constructor() {
|
||||||
|
this.expiration = new Date().getTime() + HOUR;
|
||||||
|
/**
|
||||||
|
* @type {{[url: string]: CacheConfig }}
|
||||||
|
*/
|
||||||
|
this.cacheConfig = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {{[url: string]: {expires: number, data: object} }}
|
||||||
|
*/
|
||||||
|
this._cacheObject = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store an item in the cache
|
||||||
|
* @param {string} url key by which the cache is stored
|
||||||
|
* @param {object} data the cached object
|
||||||
|
*/
|
||||||
|
set(url, data) {
|
||||||
|
this._validateCache();
|
||||||
|
this._cacheObject[url] = {
|
||||||
|
expires: new Date().getTime(),
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve an item from the cache
|
||||||
|
* @param {string} url key by which the cache is stored
|
||||||
|
* @param {number} timeToLive maximum time to allow cache to live
|
||||||
|
*/
|
||||||
|
get(url, timeToLive) {
|
||||||
|
this._validateCache();
|
||||||
|
|
||||||
|
const cacheResult = this._cacheObject[url];
|
||||||
|
if (!cacheResult) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const cacheAge = new Date().getTime() - cacheResult.expires;
|
||||||
|
|
||||||
|
if (timeToLive !== null && cacheAge > timeToLive) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return cacheResult.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all items from the cache that contain the given url
|
||||||
|
* @param {string} url key by which the cache is stored
|
||||||
|
*/
|
||||||
|
delete(url) {
|
||||||
|
this._validateCache();
|
||||||
|
|
||||||
|
Object.keys(this._cacheObject).forEach(key => {
|
||||||
|
if (key.indexOf(url) > -1) {
|
||||||
|
delete this._cacheObject[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete all items from the cache that match given regex
|
||||||
|
* @param {RegExp} regex an regular expression to match cache entries
|
||||||
|
*/
|
||||||
|
deleteMatched(regex) {
|
||||||
|
this._validateCache();
|
||||||
|
|
||||||
|
Object.keys(this._cacheObject).forEach(key => {
|
||||||
|
const notMatch = !new RegExp(regex).test(key);
|
||||||
|
|
||||||
|
if (notMatch) return;
|
||||||
|
|
||||||
|
const isDataDeleted = delete this._cacheObject[key];
|
||||||
|
|
||||||
|
if (!isDataDeleted) {
|
||||||
|
throw new Error(`Failed to delete cache for a request '${key}'`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate cache on each call to the Cache
|
||||||
|
* When the expiration date has passed, the _cacheObject will be replaced by an
|
||||||
|
* empty object
|
||||||
|
*/
|
||||||
|
_validateCache() {
|
||||||
|
if (new Date().getTime() > this.expiration) {
|
||||||
|
// @ts-ignore
|
||||||
|
this._cacheObject = {};
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let caches = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize search parameters into url query string parameters.
|
||||||
|
* If params === null, returns ''
|
||||||
|
* @param {Params} params query string parameters object
|
||||||
|
* @returns {string} of querystring parameters WITHOUT `?` or empty string ''
|
||||||
|
*/
|
||||||
|
export const searchParamSerializer = (params = {}) =>
|
||||||
|
// @ts-ignore
|
||||||
|
typeof params === 'object' ? new URLSearchParams(params).toString() : '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the active cache instance for the current session
|
||||||
|
* If 'cacheIdentifier' changes the cache is reset, we avoid situation of accessing old cache
|
||||||
|
* and proactively clean it
|
||||||
|
* @param {string} cacheIdentifier usually the refreshToken of the owner of the cache
|
||||||
|
*/
|
||||||
|
const getCache = cacheIdentifier => {
|
||||||
|
if (caches[cacheIdentifier] && caches[cacheIdentifier]._validateCache()) {
|
||||||
|
return caches[cacheIdentifier];
|
||||||
|
}
|
||||||
|
|
||||||
|
// invalidate old caches
|
||||||
|
caches = {};
|
||||||
|
// create new cache
|
||||||
|
caches[cacheIdentifier] = new Cache();
|
||||||
|
return caches[cacheIdentifier];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {CacheOptions} options Options to match cache
|
||||||
|
* @returns {ValidatedCacheOptions}
|
||||||
|
*/
|
||||||
|
export const validateOptions = ({
|
||||||
|
useCache = false,
|
||||||
|
methods = ['get'],
|
||||||
|
timeToLive,
|
||||||
|
invalidateUrls,
|
||||||
|
invalidateUrlsRegex,
|
||||||
|
requestIdentificationFn,
|
||||||
|
}) => {
|
||||||
|
// validate 'cache'
|
||||||
|
if (typeof useCache !== 'boolean') {
|
||||||
|
throw new Error('Property `useCache` should be `true` or `false`');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (methods[0] !== 'get' || methods.length !== 1) {
|
||||||
|
throw new Error('Functionality to use cache on anything except "get" is not yet supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
// validate 'timeToLive', default 1 hour
|
||||||
|
if (timeToLive === undefined) {
|
||||||
|
timeToLive = 0;
|
||||||
|
}
|
||||||
|
if (Number.isNaN(parseInt(String(timeToLive), 10))) {
|
||||||
|
throw new Error('Property `timeToLive` must be of type `number`');
|
||||||
|
}
|
||||||
|
// validate 'invalidateUrls', must be an `Array` or `falsy`
|
||||||
|
if (invalidateUrls) {
|
||||||
|
if (!Array.isArray(invalidateUrls)) {
|
||||||
|
throw new Error('Property `invalidateUrls` must be of type `Array` or `falsy`');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// validate 'invalidateUrlsRegex', must be an regex expression or `falsy`
|
||||||
|
if (invalidateUrlsRegex) {
|
||||||
|
if (!(invalidateUrlsRegex instanceof RegExp)) {
|
||||||
|
throw new Error('Property `invalidateUrlsRegex` must be of type `RegExp` or `falsy`');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// validate 'requestIdentificationFn', default is url + searchParams
|
||||||
|
if (requestIdentificationFn) {
|
||||||
|
if (typeof requestIdentificationFn !== 'function') {
|
||||||
|
throw new Error('Property `requestIdentificationFn` must be of type `function` or `falsy`');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
requestIdentificationFn = /** @param {any} data */ (
|
||||||
|
{ url, params },
|
||||||
|
searchParamsSerializer,
|
||||||
|
) => {
|
||||||
|
const serializedParams = searchParamsSerializer(params);
|
||||||
|
return serializedParams ? `${url}?${serializedParams}` : url;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
useCache,
|
||||||
|
methods,
|
||||||
|
timeToLive,
|
||||||
|
invalidateUrls,
|
||||||
|
invalidateUrlsRegex,
|
||||||
|
requestIdentificationFn,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request interceptor to return relevant cached requests
|
||||||
|
* @param {ValidatedCacheOptions} validatedInitialCacheOptions
|
||||||
|
* @param {CacheOptions=} configCacheOptions
|
||||||
|
* @returns {ValidatedCacheOptions}
|
||||||
|
*/
|
||||||
|
function composeCacheOptions(validatedInitialCacheOptions, configCacheOptions) {
|
||||||
|
/** @type {any} */
|
||||||
|
let actionCacheOptions = {};
|
||||||
|
|
||||||
|
actionCacheOptions =
|
||||||
|
configCacheOptions &&
|
||||||
|
validateOptions({
|
||||||
|
...validatedInitialCacheOptions,
|
||||||
|
...configCacheOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cacheOptions = {
|
||||||
|
...validatedInitialCacheOptions,
|
||||||
|
...actionCacheOptions,
|
||||||
|
};
|
||||||
|
|
||||||
|
return cacheOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request interceptor to return relevant cached requests
|
||||||
|
* @param {function(): string} getCacheIdentifier used to invalidate cache if identifier is changed
|
||||||
|
* @param {CacheOptions} globalCacheOptions
|
||||||
|
* @returns {CachedRequestInterceptor}
|
||||||
|
*/
|
||||||
|
export const cacheRequestInterceptorFactory = (getCacheIdentifier, globalCacheOptions) => {
|
||||||
|
const validatedInitialCacheOptions = validateOptions(globalCacheOptions);
|
||||||
|
|
||||||
|
return /** @param {CacheRequest} cacheRequest */ async cacheRequest => {
|
||||||
|
const { method, status, statusText, headers } = cacheRequest;
|
||||||
|
|
||||||
|
const cacheOptions = composeCacheOptions(
|
||||||
|
validatedInitialCacheOptions,
|
||||||
|
cacheRequest.cacheOptions,
|
||||||
|
);
|
||||||
|
cacheRequest.cacheOptions = cacheOptions;
|
||||||
|
|
||||||
|
// don't use cache if 'useCache' === false
|
||||||
|
if (!cacheOptions.useCache) {
|
||||||
|
return cacheRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheId = cacheOptions.requestIdentificationFn(cacheRequest, searchParamSerializer);
|
||||||
|
|
||||||
|
// cacheIdentifier is used to bind the cache to the current session
|
||||||
|
const currentCache = getCache(getCacheIdentifier());
|
||||||
|
|
||||||
|
const cacheResponse = currentCache.get(cacheId, cacheOptions.timeToLive);
|
||||||
|
|
||||||
|
// don't use cache if the request method is not part of the configs methods
|
||||||
|
if (cacheOptions.methods.indexOf(method.toLowerCase()) === -1) {
|
||||||
|
// If it's NOT one of the config.methods, invalidate caches
|
||||||
|
currentCache.delete(cacheId);
|
||||||
|
// also invalidate caches matching to cacheOptions
|
||||||
|
if (cacheOptions.invalidateUrls) {
|
||||||
|
cacheOptions.invalidateUrls.forEach(
|
||||||
|
/** @type {string} */ invalidateUrl => {
|
||||||
|
currentCache.delete(invalidateUrl);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// also invalidate caches matching to invalidateUrlsRegex
|
||||||
|
if (cacheOptions.invalidateUrlsRegex) {
|
||||||
|
currentCache.deleteMatched(cacheOptions.invalidateUrlsRegex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cacheRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cacheResponse) {
|
||||||
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
if (!cacheRequest.cacheOptions) {
|
||||||
|
cacheRequest.cacheOptions = { useCache: false };
|
||||||
|
}
|
||||||
|
cacheRequest.cacheOptions.fromCache = true;
|
||||||
|
|
||||||
|
const init = /** @type {LionRequestInit} */ ({
|
||||||
|
status,
|
||||||
|
statusText,
|
||||||
|
headers,
|
||||||
|
request: cacheRequest,
|
||||||
|
});
|
||||||
|
|
||||||
|
return /** @type {CacheResponse} */ (new Response(cacheResponse, init));
|
||||||
|
}
|
||||||
|
|
||||||
|
return cacheRequest;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response interceptor to cache relevant requests
|
||||||
|
* @param {function(): string} getCacheIdentifier used to invalidate cache if identifier is changed
|
||||||
|
* @param {CacheOptions} globalCacheOptions
|
||||||
|
* @returns {CachedResponseInterceptor}
|
||||||
|
*/
|
||||||
|
export const cacheResponseInterceptorFactory = (getCacheIdentifier, globalCacheOptions) => {
|
||||||
|
const validatedInitialCacheOptions = validateOptions(globalCacheOptions);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Axios response https://github.com/axios/axios#response-schema
|
||||||
|
*/
|
||||||
|
return /** @param {CacheResponse} cacheResponse */ async cacheResponse => {
|
||||||
|
if (!getCacheIdentifier()) {
|
||||||
|
throw new Error(`getCacheIdentifier returns falsy`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheOptions = composeCacheOptions(
|
||||||
|
validatedInitialCacheOptions,
|
||||||
|
cacheResponse.request?.cacheOptions,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isAlreadyFromCache = !!cacheOptions.fromCache;
|
||||||
|
// caching all responses with not default `timeToLive`
|
||||||
|
const isCacheActive = cacheOptions.timeToLive > 0;
|
||||||
|
|
||||||
|
if (isAlreadyFromCache || !isCacheActive) {
|
||||||
|
return cacheResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the request is one of the options.methods; store response in cache
|
||||||
|
if (
|
||||||
|
cacheResponse.request &&
|
||||||
|
cacheOptions.methods.indexOf(cacheResponse.request.method.toLowerCase()) > -1
|
||||||
|
) {
|
||||||
|
// string that identifies cache entry
|
||||||
|
const cacheId = cacheOptions.requestIdentificationFn(
|
||||||
|
cacheResponse.request,
|
||||||
|
searchParamSerializer,
|
||||||
|
);
|
||||||
|
|
||||||
|
// store the response data in the cache
|
||||||
|
getCache(getCacheIdentifier()).set(cacheId, cacheResponse.body);
|
||||||
|
} else {
|
||||||
|
// don't store in cache if the request method is not part of the configs methods
|
||||||
|
return cacheResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cacheResponse;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
/**
|
import './typedef.js';
|
||||||
* @typedef {import('./AjaxClient').RequestInterceptor} RequestInterceptor
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} name the cookie name
|
* @param {string} name the cookie name
|
||||||
|
|
|
||||||
18
packages/ajax/src/typedef.js
Normal file
18
packages/ajax/src/typedef.js
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
/**
|
||||||
|
* @typedef {import('../types/types').LionRequestInit} LionRequestInit
|
||||||
|
* @typedef {import('../types/types').AjaxClientConfig} AjaxClientConfig
|
||||||
|
* @typedef {import('../types/types').RequestInterceptor} RequestInterceptor
|
||||||
|
* @typedef {import('../types/types').ResponseInterceptor} ResponseInterceptor
|
||||||
|
* @typedef {import('../types/types').CacheConfig} CacheConfig
|
||||||
|
* @typedef {import('../types/types').Params} Params
|
||||||
|
* @typedef {import('../types/types').RequestIdentificationFn} RequestIdentificationFn
|
||||||
|
* @typedef {import('../types/types').CacheOptions} CacheOptions
|
||||||
|
* @typedef {import('../types/types').ValidatedCacheOptions} ValidatedCacheOptions
|
||||||
|
* @typedef {import('../types/types').CacheRequestExtension} CacheRequestExtension
|
||||||
|
* @typedef {import('../types/types').CacheResponseExtension} CacheResponseExtension
|
||||||
|
* @typedef {import('../types/types').CacheResponseRequest} CacheResponseRequest
|
||||||
|
* @typedef {import('../types/types').CacheRequest} CacheRequest
|
||||||
|
* @typedef {import('../types/types').CacheResponse} CacheResponse
|
||||||
|
* @typedef {import('../types/types').CachedRequestInterceptor} CachedRequestInterceptor
|
||||||
|
* @typedef {import('../types/types').CachedResponseInterceptor} CachedResponseInterceptor
|
||||||
|
*/
|
||||||
|
|
@ -10,7 +10,7 @@ describe('AjaxClient', () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fetchStub = stub(window, 'fetch');
|
fetchStub = stub(window, 'fetch');
|
||||||
fetchStub.returns(Promise.resolve('mock response'));
|
fetchStub.returns(Promise.resolve(new Response('mock response')));
|
||||||
ajax = new AjaxClient();
|
ajax = new AjaxClient();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -20,7 +20,7 @@ describe('AjaxClient', () => {
|
||||||
|
|
||||||
describe('request()', () => {
|
describe('request()', () => {
|
||||||
it('calls fetch with the given args, returning the result', async () => {
|
it('calls fetch with the given args, returning the result', async () => {
|
||||||
const response = await ajax.request('/foo', { method: 'POST' });
|
const response = await (await ajax.request('/foo', { method: 'POST' })).text();
|
||||||
|
|
||||||
expect(fetchStub).to.have.been.calledOnce;
|
expect(fetchStub).to.have.been.calledOnce;
|
||||||
const request = fetchStub.getCall(0).args[0];
|
const request = fetchStub.getCall(0).args[0];
|
||||||
|
|
@ -115,12 +115,19 @@ describe('AjaxClient', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('addResponseInterceptor() adds a function which intercepts the response', async () => {
|
it('addResponseInterceptor() adds a function which intercepts the response', async () => {
|
||||||
// @ts-expect-error we're mocking the response as a simple promise which returns a string
|
ajax.addResponseInterceptor(async r => {
|
||||||
ajax.addResponseInterceptor(r => `${r} intercepted-1`);
|
const { status, statusText, headers } = r;
|
||||||
// @ts-expect-error we're mocking the response as a simple promise which returns a string
|
const body = await r.text();
|
||||||
ajax.addResponseInterceptor(r => `${r} intercepted-2`);
|
return new Response(`${body} intercepted-1`, { status, statusText, headers });
|
||||||
|
});
|
||||||
|
|
||||||
const response = await ajax.request('/foo', { method: 'POST' });
|
ajax.addResponseInterceptor(async r => {
|
||||||
|
const { status, statusText, headers } = r;
|
||||||
|
const body = await r.text();
|
||||||
|
return new Response(`${body} intercepted-2`, { status, statusText, headers });
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await (await ajax.request('/foo', { method: 'POST' })).text();
|
||||||
expect(response).to.equal('mock response intercepted-1 intercepted-2');
|
expect(response).to.equal('mock response intercepted-1 intercepted-2');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -143,7 +150,7 @@ describe('AjaxClient', () => {
|
||||||
// @ts-expect-error we're mocking the response as a simple promise which returns a string
|
// @ts-expect-error we're mocking the response as a simple promise which returns a string
|
||||||
ajax.removeResponseInterceptor(interceptor);
|
ajax.removeResponseInterceptor(interceptor);
|
||||||
|
|
||||||
const response = await ajax.request('/foo', { method: 'POST' });
|
const response = await (await ajax.request('/foo', { method: 'POST' })).text();
|
||||||
expect(response).to.equal('mock response');
|
expect(response).to.equal('mock response');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
596
packages/ajax/test/interceptors-cache.test.js
Normal file
596
packages/ajax/test/interceptors-cache.test.js
Normal file
|
|
@ -0,0 +1,596 @@
|
||||||
|
import { expect } from '@open-wc/testing';
|
||||||
|
import { spy, stub, useFakeTimers } from 'sinon';
|
||||||
|
import '../src/typedef.js';
|
||||||
|
|
||||||
|
import { cacheRequestInterceptorFactory, cacheResponseInterceptorFactory, ajax } from '../index.js';
|
||||||
|
|
||||||
|
describe('ajax cache', function describeLibCache() {
|
||||||
|
/** @type {number | undefined} */
|
||||||
|
let cacheId;
|
||||||
|
/** @type {import('sinon').SinonStub} */
|
||||||
|
let fetchStub;
|
||||||
|
/** @type {() => string} */
|
||||||
|
let getCacheIdentifier;
|
||||||
|
|
||||||
|
const newCacheId = () => {
|
||||||
|
if (!cacheId) {
|
||||||
|
cacheId = 1;
|
||||||
|
} else {
|
||||||
|
cacheId += 1;
|
||||||
|
}
|
||||||
|
return cacheId;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ajax} ajaxInstance
|
||||||
|
* @param {CacheOptions} options
|
||||||
|
*/
|
||||||
|
const addCacheInterceptors = (ajaxInstance, options) => {
|
||||||
|
const requestInterceptorIndex =
|
||||||
|
ajaxInstance._requestInterceptors.push(
|
||||||
|
cacheRequestInterceptorFactory(getCacheIdentifier, options),
|
||||||
|
) - 1;
|
||||||
|
|
||||||
|
const responseInterceptorIndex =
|
||||||
|
ajaxInstance._responseInterceptors.push(
|
||||||
|
cacheResponseInterceptorFactory(getCacheIdentifier, options),
|
||||||
|
) - 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
requestInterceptorIndex,
|
||||||
|
responseInterceptorIndex,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ajax} ajaxInstance
|
||||||
|
* @param {{requestInterceptorIndex: number, responseInterceptorIndex: number}} indexes
|
||||||
|
*/
|
||||||
|
const removeCacheInterceptors = (
|
||||||
|
ajaxInstance,
|
||||||
|
{ requestInterceptorIndex, responseInterceptorIndex },
|
||||||
|
) => {
|
||||||
|
ajaxInstance._requestInterceptors.splice(requestInterceptorIndex, 1);
|
||||||
|
ajaxInstance._responseInterceptors.splice(responseInterceptorIndex, 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
getCacheIdentifier = () => String(cacheId);
|
||||||
|
fetchStub = stub(window, 'fetch');
|
||||||
|
fetchStub.returns(Promise.resolve(new Response('mock response')));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fetchStub.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Original ajax instance', () => {
|
||||||
|
it('allows direct ajax calls without cache interceptors configured', () => {
|
||||||
|
return ajax
|
||||||
|
.request('/test')
|
||||||
|
.then(() => {
|
||||||
|
expect(fetchStub.callCount).to.equal(1);
|
||||||
|
})
|
||||||
|
.then(() => ajax.request('/test'))
|
||||||
|
.then(() => {
|
||||||
|
expect(fetchStub.callCount).to.equal(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Cache config validation', () => {
|
||||||
|
it('validates `useCache`', () => {
|
||||||
|
newCacheId();
|
||||||
|
const test = () => {
|
||||||
|
const indexes = addCacheInterceptors(ajax, {
|
||||||
|
// @ts-ignore needed for test
|
||||||
|
useCache: 'fakeUseCacheType',
|
||||||
|
});
|
||||||
|
removeCacheInterceptors(ajax, indexes);
|
||||||
|
};
|
||||||
|
expect(test).to.throw();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates property `timeToLive` throws if not type `number`', () => {
|
||||||
|
newCacheId();
|
||||||
|
expect(() => {
|
||||||
|
const indexes = addCacheInterceptors(ajax, {
|
||||||
|
useCache: true,
|
||||||
|
// @ts-ignore needed for test
|
||||||
|
timeToLive: '',
|
||||||
|
});
|
||||||
|
removeCacheInterceptors(ajax, indexes);
|
||||||
|
}).to.throw();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates cache identifier function', () => {
|
||||||
|
// @ts-ignore needed for test
|
||||||
|
cacheId = '';
|
||||||
|
|
||||||
|
const indexes = addCacheInterceptors(ajax, { useCache: true });
|
||||||
|
|
||||||
|
return ajax.request('/test').catch(
|
||||||
|
/** @param {Error} err */ err => {
|
||||||
|
expect(err.message).to.equal('getCacheIdentifier returns falsy');
|
||||||
|
|
||||||
|
removeCacheInterceptors(ajax, indexes);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("throws when using methods other than `['get']`", () => {
|
||||||
|
newCacheId();
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
const indexes = addCacheInterceptors(ajax, {
|
||||||
|
useCache: true,
|
||||||
|
methods: ['get', 'post'],
|
||||||
|
});
|
||||||
|
removeCacheInterceptors(ajax, indexes);
|
||||||
|
}).to.throw(/not yet supported/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws error when requestIdentificationFn is not a function', () => {
|
||||||
|
newCacheId();
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
const indexes = addCacheInterceptors(ajax, {
|
||||||
|
useCache: true,
|
||||||
|
// @ts-ignore needed for test
|
||||||
|
requestIdentificationFn: 'not a function',
|
||||||
|
});
|
||||||
|
removeCacheInterceptors(ajax, indexes);
|
||||||
|
}).to.throw(/Property `requestIdentificationFn` must be of type `function` or `falsy`/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Cached responses', () => {
|
||||||
|
it('returns the cached object on second call with `useCache: true`', () => {
|
||||||
|
newCacheId();
|
||||||
|
|
||||||
|
const indexes = addCacheInterceptors(ajax, {
|
||||||
|
useCache: true,
|
||||||
|
timeToLive: 100,
|
||||||
|
});
|
||||||
|
const ajaxRequestSpy = spy(ajax, 'request');
|
||||||
|
|
||||||
|
return ajax
|
||||||
|
.request('/test')
|
||||||
|
.then(() => {
|
||||||
|
expect(ajaxRequestSpy.calledOnce).to.be.true;
|
||||||
|
expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
|
||||||
|
})
|
||||||
|
.then(() => ajax.request('/test'))
|
||||||
|
.then(() => {
|
||||||
|
expect(fetchStub.callCount).to.equal(1);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
ajaxRequestSpy.restore();
|
||||||
|
removeCacheInterceptors(ajax, indexes);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('all calls with non-default `timeToLive` are cached proactively', () => {
|
||||||
|
newCacheId();
|
||||||
|
|
||||||
|
const indexes = addCacheInterceptors(ajax, {
|
||||||
|
useCache: false,
|
||||||
|
timeToLive: 100,
|
||||||
|
});
|
||||||
|
const ajaxRequestSpy = spy(ajax, 'request');
|
||||||
|
|
||||||
|
return ajax
|
||||||
|
.request('/test')
|
||||||
|
.then(() => {
|
||||||
|
expect(ajaxRequestSpy.calledOnce).to.be.true;
|
||||||
|
expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
expect(fetchStub.callCount).to.equal(1);
|
||||||
|
})
|
||||||
|
.then(() => ajax.request('/test'))
|
||||||
|
.then(() => {
|
||||||
|
expect(fetchStub.callCount).to.equal(2);
|
||||||
|
})
|
||||||
|
.then(() =>
|
||||||
|
ajax.request('/test', {
|
||||||
|
cacheOptions: {
|
||||||
|
useCache: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
expect(fetchStub.callCount).to.equal(2);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
ajaxRequestSpy.restore();
|
||||||
|
removeCacheInterceptors(ajax, indexes);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the cached object on second call with `useCache: true`, with querystring parameters', () => {
|
||||||
|
newCacheId();
|
||||||
|
|
||||||
|
const indexes = addCacheInterceptors(ajax, {
|
||||||
|
useCache: true,
|
||||||
|
timeToLive: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ajaxRequestSpy = spy(ajax, 'request');
|
||||||
|
|
||||||
|
return ajax
|
||||||
|
.request('/test', {
|
||||||
|
params: {
|
||||||
|
q: 'test',
|
||||||
|
page: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
expect(ajaxRequestSpy.calledOnce).to.be.true;
|
||||||
|
expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
|
||||||
|
})
|
||||||
|
.then(() =>
|
||||||
|
ajax.request('/test', {
|
||||||
|
params: {
|
||||||
|
q: 'test',
|
||||||
|
page: 1,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
expect(fetchStub.callCount).to.equal(1);
|
||||||
|
})
|
||||||
|
.then(() =>
|
||||||
|
// a request with different param should not be cached
|
||||||
|
ajax.request('/test', {
|
||||||
|
params: {
|
||||||
|
q: 'test',
|
||||||
|
page: 2,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
expect(fetchStub.callCount).to.equal(2);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
ajaxRequestSpy.restore();
|
||||||
|
removeCacheInterceptors(ajax, indexes);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses cache when inside `timeToLive: 5000` window', () => {
|
||||||
|
newCacheId();
|
||||||
|
const clock = useFakeTimers({
|
||||||
|
shouldAdvanceTime: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const indexes = addCacheInterceptors(ajax, {
|
||||||
|
useCache: true,
|
||||||
|
timeToLive: 5000,
|
||||||
|
});
|
||||||
|
const ajaxRequestSpy = spy(ajax, 'request');
|
||||||
|
|
||||||
|
return ajax
|
||||||
|
.request('/test')
|
||||||
|
.then(() => {
|
||||||
|
expect(ajaxRequestSpy.calledOnce).to.be.true;
|
||||||
|
expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
|
||||||
|
expect(fetchStub.callCount).to.equal(1);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
clock.tick(4900);
|
||||||
|
})
|
||||||
|
.then(() => ajax.request('/test'))
|
||||||
|
.then(() => {
|
||||||
|
expect(fetchStub.callCount).to.equal(1);
|
||||||
|
clock.tick(5100);
|
||||||
|
})
|
||||||
|
.then(() => ajax.request('/test'))
|
||||||
|
.then(() => {
|
||||||
|
expect(fetchStub.callCount).to.equal(2);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
ajaxRequestSpy.restore();
|
||||||
|
clock.restore();
|
||||||
|
removeCacheInterceptors(ajax, indexes);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses custom requestIdentificationFn when passed', () => {
|
||||||
|
newCacheId();
|
||||||
|
|
||||||
|
const customRequestIdFn = /** @type {RequestIdentificationFn} */ (request, serializer) => {
|
||||||
|
let serializedRequestParams = '';
|
||||||
|
if (request.params) {
|
||||||
|
serializedRequestParams = `?${serializer(request.params)}`;
|
||||||
|
}
|
||||||
|
return `${new URL(/** @type {string} */ (request.url)).pathname}-${request.headers?.get(
|
||||||
|
'x-id',
|
||||||
|
)}${serializedRequestParams}`;
|
||||||
|
};
|
||||||
|
const reqIdSpy = spy(customRequestIdFn);
|
||||||
|
const indexes = addCacheInterceptors(ajax, {
|
||||||
|
useCache: true,
|
||||||
|
requestIdentificationFn: reqIdSpy,
|
||||||
|
});
|
||||||
|
|
||||||
|
return ajax
|
||||||
|
.request('/test', { headers: { 'x-id': '1' } })
|
||||||
|
.then(() => {
|
||||||
|
expect(reqIdSpy.calledOnce);
|
||||||
|
expect(reqIdSpy.returnValues[0]).to.equal(`/test-1`);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
removeCacheInterceptors(ajax, indexes);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Cache invalidation', () => {
|
||||||
|
it('previously cached data has to be invalidated when regex invalidation rule triggered', () => {
|
||||||
|
newCacheId();
|
||||||
|
|
||||||
|
const indexes = addCacheInterceptors(ajax, {
|
||||||
|
useCache: true,
|
||||||
|
timeToLive: 1000,
|
||||||
|
invalidateUrlsRegex: /foo/gi,
|
||||||
|
});
|
||||||
|
|
||||||
|
return ajax
|
||||||
|
.request('/test')
|
||||||
|
.then(() => ajax.request('/test'))
|
||||||
|
.then(() => {
|
||||||
|
expect(fetchStub.callCount).to.equal(1);
|
||||||
|
})
|
||||||
|
.then(() => ajax.request('/foo-request-1'))
|
||||||
|
.then(() => {
|
||||||
|
expect(fetchStub.callCount).to.equal(2);
|
||||||
|
})
|
||||||
|
.then(() => ajax.request('/foo-request-1'))
|
||||||
|
.then(() => {
|
||||||
|
expect(fetchStub.callCount).to.equal(2);
|
||||||
|
})
|
||||||
|
.then(() => ajax.request('/foo-request-2'))
|
||||||
|
.then(() => {
|
||||||
|
expect(fetchStub.callCount).to.equal(3);
|
||||||
|
})
|
||||||
|
.then(() => ajax.request('/foo-request-2'))
|
||||||
|
.then(() => {
|
||||||
|
expect(fetchStub.callCount).to.equal(3);
|
||||||
|
})
|
||||||
|
.then(() => ajax.request('/test', { method: 'POST' }))
|
||||||
|
.then(() => {
|
||||||
|
expect(fetchStub.callCount).to.equal(4);
|
||||||
|
})
|
||||||
|
.then(() => ajax.request('/foo-request-1'))
|
||||||
|
.then(() => {
|
||||||
|
expect(fetchStub.callCount).to.equal(5);
|
||||||
|
})
|
||||||
|
.then(() => ajax.request('/foo-request-2'))
|
||||||
|
.then(() => {
|
||||||
|
expect(fetchStub.callCount).to.equal(6);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
removeCacheInterceptors(ajax, indexes);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('previously cached data has to be invalidated when regex invalidation rule triggered and urls are nested', () => {
|
||||||
|
newCacheId();
|
||||||
|
|
||||||
|
const indexes = addCacheInterceptors(ajax, {
|
||||||
|
useCache: true,
|
||||||
|
timeToLive: 1000,
|
||||||
|
invalidateUrlsRegex: /posts/gi,
|
||||||
|
});
|
||||||
|
|
||||||
|
return ajax
|
||||||
|
.request('/test')
|
||||||
|
.then(() => ajax.request('/test'))
|
||||||
|
.then(() => {
|
||||||
|
expect(fetchStub.callCount).to.equal(1);
|
||||||
|
})
|
||||||
|
.then(() => ajax.request('/posts'))
|
||||||
|
.then(() => {
|
||||||
|
expect(fetchStub.callCount).to.equal(2);
|
||||||
|
})
|
||||||
|
.then(() => ajax.request('/posts'))
|
||||||
|
.then(() => {
|
||||||
|
// no new requests, cached
|
||||||
|
expect(fetchStub.callCount).to.equal(2);
|
||||||
|
})
|
||||||
|
.then(() => ajax.request('/posts/1'))
|
||||||
|
.then(() => {
|
||||||
|
expect(fetchStub.callCount).to.equal(3);
|
||||||
|
})
|
||||||
|
.then(() => ajax.request('/posts/1'))
|
||||||
|
.then(() => {
|
||||||
|
// no new requests, cached
|
||||||
|
expect(fetchStub.callCount).to.equal(3);
|
||||||
|
})
|
||||||
|
.then(() =>
|
||||||
|
// cleans cache for defined urls
|
||||||
|
ajax.request('/test', { method: 'POST' }),
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
expect(fetchStub.callCount).to.equal(4);
|
||||||
|
})
|
||||||
|
.then(() => ajax.request('/posts'))
|
||||||
|
.then(() => {
|
||||||
|
// new requests, cache is cleaned
|
||||||
|
expect(fetchStub.callCount).to.equal(5);
|
||||||
|
})
|
||||||
|
.then(() => ajax.request('/posts/1'))
|
||||||
|
.then(() => {
|
||||||
|
// new requests, cache is cleaned
|
||||||
|
expect(fetchStub.callCount).to.equal(6);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
removeCacheInterceptors(ajax, indexes);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes cache after one hour', () => {
|
||||||
|
newCacheId();
|
||||||
|
const clock = useFakeTimers({
|
||||||
|
shouldAdvanceTime: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ajaxRequestSpy = spy(ajax, 'request');
|
||||||
|
const indexes = addCacheInterceptors(ajax, {
|
||||||
|
useCache: true,
|
||||||
|
timeToLive: 1000 * 60 * 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
return ajax
|
||||||
|
.request('/test-hour')
|
||||||
|
.then(() => {
|
||||||
|
expect(ajaxRequestSpy.calledOnce).to.be.true;
|
||||||
|
expect(ajaxRequestSpy.calledWith('/test-hour')).to.be.true;
|
||||||
|
expect(fetchStub.callCount).to.equal(1);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
clock.tick(1000 * 60 * 59); // 0:59 hour
|
||||||
|
})
|
||||||
|
.then(() => ajax.request('/test-hour'))
|
||||||
|
.then(() => {
|
||||||
|
expect(fetchStub.callCount).to.equal(1);
|
||||||
|
clock.tick(1000 * 60 * 61); // 1:01 hour
|
||||||
|
})
|
||||||
|
.then(() => ajax.request('/test-hour'))
|
||||||
|
.then(() => {
|
||||||
|
expect(fetchStub.callCount).to.equal(2);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
ajaxRequestSpy.restore();
|
||||||
|
clock.restore();
|
||||||
|
removeCacheInterceptors(ajax, indexes);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidates invalidateUrls endpoints', () => {
|
||||||
|
newCacheId();
|
||||||
|
|
||||||
|
const indexes = addCacheInterceptors(ajax, {
|
||||||
|
useCache: true,
|
||||||
|
timeToLive: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionConfig = {
|
||||||
|
cacheOptions: {
|
||||||
|
invalidateUrls: ['/test-invalid-url'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return ajax
|
||||||
|
.request('/test-valid-url', { ...actionConfig })
|
||||||
|
.then(() => {
|
||||||
|
expect(fetchStub.callCount).to.equal(1);
|
||||||
|
})
|
||||||
|
.then(() => ajax.request('/test-invalid-url'))
|
||||||
|
.then(() => {
|
||||||
|
expect(fetchStub.callCount).to.equal(2);
|
||||||
|
})
|
||||||
|
.then(() =>
|
||||||
|
// 'post' will invalidate 'own' cache and the one mentioned in config
|
||||||
|
ajax.request('/test-valid-url', { ...actionConfig, method: 'POST' }),
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
expect(fetchStub.callCount).to.equal(3);
|
||||||
|
})
|
||||||
|
.then(() => ajax.request('/test-invalid-url'))
|
||||||
|
.then(() => {
|
||||||
|
// indicates that 'test-invalid-url' cache was removed
|
||||||
|
// because the server registered new request
|
||||||
|
expect(fetchStub.callCount).to.equal(4);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
removeCacheInterceptors(ajax, indexes);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidates cache on a post', () => {
|
||||||
|
newCacheId();
|
||||||
|
|
||||||
|
const indexes = addCacheInterceptors(ajax, {
|
||||||
|
useCache: true,
|
||||||
|
timeToLive: 100,
|
||||||
|
});
|
||||||
|
const ajaxRequestSpy = spy(ajax, 'request');
|
||||||
|
|
||||||
|
return ajax
|
||||||
|
.request('/test-post')
|
||||||
|
.then(() => {
|
||||||
|
expect(ajaxRequestSpy.calledOnce).to.be.true;
|
||||||
|
expect(ajaxRequestSpy.calledWith('/test-post')).to.be.true;
|
||||||
|
expect(fetchStub.callCount).to.equal(1);
|
||||||
|
})
|
||||||
|
.then(() => ajax.request('/test-post', { method: 'POST', body: 'data-post' }))
|
||||||
|
.then(() => {
|
||||||
|
expect(ajaxRequestSpy.calledTwice).to.be.true;
|
||||||
|
expect(ajaxRequestSpy.calledWith('/test-post')).to.be.true;
|
||||||
|
expect(fetchStub.callCount).to.equal(2);
|
||||||
|
})
|
||||||
|
.then(() => ajax.request('/test-post'))
|
||||||
|
.then(() => {
|
||||||
|
expect(fetchStub.callCount).to.equal(3);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
ajaxRequestSpy.restore();
|
||||||
|
removeCacheInterceptors(ajax, indexes);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caches response but does not return it when expiration time is 0', () => {
|
||||||
|
newCacheId();
|
||||||
|
|
||||||
|
const indexes = addCacheInterceptors(ajax, {
|
||||||
|
useCache: true,
|
||||||
|
timeToLive: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const ajaxRequestSpy = spy(ajax, 'request');
|
||||||
|
|
||||||
|
return ajax
|
||||||
|
.request('/test')
|
||||||
|
.then(() => {
|
||||||
|
const clock = useFakeTimers();
|
||||||
|
expect(ajaxRequestSpy.calledOnce).to.be.true;
|
||||||
|
expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
|
||||||
|
clock.tick(1);
|
||||||
|
clock.restore();
|
||||||
|
})
|
||||||
|
.then(() => ajax.request('/test'))
|
||||||
|
.then(() => {
|
||||||
|
expect(fetchStub.callCount).to.equal(2);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
ajaxRequestSpy.restore();
|
||||||
|
removeCacheInterceptors(ajax, indexes);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not use cache when `useCache: false` in the action', () => {
|
||||||
|
newCacheId();
|
||||||
|
getCacheIdentifier = () => 'cacheIdentifier2';
|
||||||
|
|
||||||
|
const ajaxAlwaysRequestSpy = spy(ajax, 'request');
|
||||||
|
const indexes = addCacheInterceptors(ajax, { useCache: true });
|
||||||
|
|
||||||
|
return ajax
|
||||||
|
.request('/test')
|
||||||
|
.then(() => {
|
||||||
|
expect(ajaxAlwaysRequestSpy.calledOnce, 'calledOnce').to.be.true;
|
||||||
|
expect(ajaxAlwaysRequestSpy.calledWith('/test'));
|
||||||
|
})
|
||||||
|
.then(() => ajax.request('/test', { cacheOptions: { useCache: false } }))
|
||||||
|
.then(() => {
|
||||||
|
expect(fetchStub.callCount).to.equal(2);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
ajaxAlwaysRequestSpy.restore();
|
||||||
|
removeCacheInterceptors(ajax, indexes);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
10
packages/ajax/types/ajaxClientTypes.d.ts
vendored
10
packages/ajax/types/ajaxClientTypes.d.ts
vendored
|
|
@ -1,10 +0,0 @@
|
||||||
/**
|
|
||||||
* We have a method requestJson that encodes JS Object to
|
|
||||||
* a string automatically for `body` property.
|
|
||||||
* Sadly, Typescript doesn't allow us to extend RequestInit
|
|
||||||
* and override body prop because it is incompatible, so we
|
|
||||||
* omit it first from the base RequestInit.
|
|
||||||
*/
|
|
||||||
export interface LionRequestInit extends Omit<RequestInit, 'body'> {
|
|
||||||
body?: BodyInit | null | Object;
|
|
||||||
}
|
|
||||||
80
packages/ajax/types/types.d.ts
vendored
Normal file
80
packages/ajax/types/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
/**
|
||||||
|
* We have a method requestJson that encodes JS Object to
|
||||||
|
* a string automatically for `body` property.
|
||||||
|
* Sadly, Typescript doesn't allow us to extend RequestInit
|
||||||
|
* and override body prop because it is incompatible, so we
|
||||||
|
* omit it first from the base RequestInit.
|
||||||
|
*/
|
||||||
|
export interface LionRequestInit extends Omit<RequestInit, 'body'> {
|
||||||
|
body?: BodyInit | null | Object;
|
||||||
|
request?: CacheRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AjaxClientConfig {
|
||||||
|
addAcceptLanguage: boolean;
|
||||||
|
xsrfCookieName: string | null;
|
||||||
|
xsrfHeaderName: string | null;
|
||||||
|
jsonPrefix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RequestInterceptor = (request: Request) => Promise<Request | Response>;
|
||||||
|
export type ResponseInterceptor = (response: Response) => Promise<Response>;
|
||||||
|
|
||||||
|
export interface CacheConfig {
|
||||||
|
expires: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Params = { [key: string]: any };
|
||||||
|
|
||||||
|
export type RequestIdentificationFn = (
|
||||||
|
request: Partial<CacheRequest>,
|
||||||
|
searchParamsSerializer: (params: Params) => string,
|
||||||
|
) => string;
|
||||||
|
|
||||||
|
export interface CacheOptions {
|
||||||
|
useCache?: boolean;
|
||||||
|
methods?: string[];
|
||||||
|
timeToLive?: number;
|
||||||
|
invalidateUrls?: string[];
|
||||||
|
invalidateUrlsRegex?: RegExp;
|
||||||
|
requestIdentificationFn?: RequestIdentificationFn;
|
||||||
|
fromCache?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidatedCacheOptions {
|
||||||
|
useCache: boolean;
|
||||||
|
methods: string[];
|
||||||
|
timeToLive: number;
|
||||||
|
invalidateUrls?: string[];
|
||||||
|
invalidateUrlsRegex?: RegExp;
|
||||||
|
requestIdentificationFn: RequestIdentificationFn;
|
||||||
|
fromCache?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CacheRequestExtension {
|
||||||
|
cacheOptions?: CacheOptions;
|
||||||
|
adapter: any;
|
||||||
|
status: number;
|
||||||
|
statusText: string;
|
||||||
|
params: Params;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CacheResponseRequest {
|
||||||
|
cacheOptions?: CacheOptions;
|
||||||
|
method: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CacheResponseExtension {
|
||||||
|
request: CacheResponseRequest;
|
||||||
|
data: object | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CacheRequest = Request & Partial<CacheRequestExtension>;
|
||||||
|
|
||||||
|
export type CacheResponse = Response & Partial<CacheResponseExtension>;
|
||||||
|
|
||||||
|
export type CachedRequestInterceptor = (
|
||||||
|
request: CacheRequest,
|
||||||
|
) => Promise<CacheRequest | CacheResponse>;
|
||||||
|
|
||||||
|
export type CachedResponseInterceptor = (request: CacheResponse) => Promise<CacheResponse>;
|
||||||
Loading…
Reference in a new issue