feat: port caching feature to fetch proposal

Co-authored-by: Yevgeniy Valeyev <yevgeniy.valeyev@ing.com>
This commit is contained in:
Yevgeniy Valeyev 2021-01-15 14:19:52 +01:00 committed by jorenbroekema
parent c3e5a6596d
commit bbffd7105f
12 changed files with 1298 additions and 66 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/ajax': minor
---
Added Ajax cache interceptors.

View file

@ -27,6 +27,9 @@ npm i --save @lion/ajax
#### GET request
```js
import { ajax } from '@lion/ajax';
const response = await ajax.request('/api/users');
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
For IE11 you will need a polyfill for fetch. You should add this on your top level layer, e.g. your application.

View 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`.

View file

@ -7,3 +7,9 @@ export {
createXSRFRequestInterceptor,
getCookie,
} from './src/interceptors.js';
export {
cacheRequestInterceptorFactory,
cacheResponseInterceptorFactory,
validateOptions,
} from './src/interceptors-cache.js';

View file

@ -2,31 +2,7 @@
import { acceptLanguageRequestInterceptor, createXSRFRequestInterceptor } from './interceptors.js';
import { AjaxClientFetchError } from './AjaxClientFetchError.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
*/
import './typedef.js';
/**
* 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 {
/**
* @param {AjaxClientConfig} config
* @param {Partial<AjaxClientConfig>} config
*/
constructor(config = {}) {
const {
addAcceptLanguage = true,
xsrfCookieName = 'XSRF-TOKEN',
xsrfHeaderName = 'X-XSRF-TOKEN',
jsonPrefix,
} = config;
this.__config = {
addAcceptLanguage: true,
xsrfCookieName: 'XSRF-TOKEN',
xsrfHeaderName: 'X-XSRF-TOKEN',
jsonPrefix: '',
...config,
};
/** @type {string | undefined} */
this._jsonPrefix = jsonPrefix;
/** @type {RequestInterceptor[]} */
/** @type {Array.<RequestInterceptor|CachedRequestInterceptor>} */
this._requestInterceptors = [];
/** @type {ResponseInterceptor[]} */
/** @type {Array.<ResponseInterceptor|CachedResponseInterceptor>} */
this._responseInterceptors = [];
if (addAcceptLanguage) {
if (this.__config.addAcceptLanguage) {
this.addRequestInterceptor(acceptLanguageRequestInterceptor);
}
if (xsrfCookieName && xsrfHeaderName) {
this.addRequestInterceptor(createXSRFRequestInterceptor(xsrfCookieName, xsrfHeaderName));
if (this.__config.xsrfCookieName && this.__config.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 */
addRequestInterceptor(requestInterceptor) {
this._requestInterceptors.push(requestInterceptor);
@ -92,11 +81,13 @@ export class AjaxClient {
* interceptors.
*
* @param {RequestInfo} info
* @param {RequestInit} [init]
* @param {RequestInit & Partial<CacheRequestExtension>} [init]
* @returns {Promise<Response>}
*/
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
// 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;
for (const intercept of this._responseInterceptors) {
@ -156,9 +148,9 @@ export class AjaxClient {
const response = await this.request(info, jsonInit);
let responseText = await response.text();
if (typeof this._jsonPrefix === 'string') {
if (responseText.startsWith(this._jsonPrefix)) {
responseText = responseText.substring(this._jsonPrefix.length);
if (typeof this.__config.jsonPrefix === 'string') {
if (responseText.startsWith(this.__config.jsonPrefix)) {
responseText = responseText.substring(this.__config.jsonPrefix.length);
}
}

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

View file

@ -1,6 +1,4 @@
/**
* @typedef {import('./AjaxClient').RequestInterceptor} RequestInterceptor
*/
import './typedef.js';
/**
* @param {string} name the cookie name

View 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
*/

View file

@ -10,7 +10,7 @@ describe('AjaxClient', () => {
beforeEach(() => {
fetchStub = stub(window, 'fetch');
fetchStub.returns(Promise.resolve('mock response'));
fetchStub.returns(Promise.resolve(new Response('mock response')));
ajax = new AjaxClient();
});
@ -20,7 +20,7 @@ describe('AjaxClient', () => {
describe('request()', () => {
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;
const request = fetchStub.getCall(0).args[0];
@ -115,12 +115,19 @@ describe('AjaxClient', () => {
});
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(r => `${r} intercepted-1`);
// @ts-expect-error we're mocking the response as a simple promise which returns a string
ajax.addResponseInterceptor(r => `${r} intercepted-2`);
ajax.addResponseInterceptor(async r => {
const { status, statusText, headers } = r;
const body = await r.text();
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');
});
@ -143,7 +150,7 @@ describe('AjaxClient', () => {
// @ts-expect-error we're mocking the response as a simple promise which returns a string
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');
});
});

View 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);
});
});
});
});

View file

@ -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
View 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>;