Race condition fix for on the fly requests, improve cache implementation and tests

Co-authored-by: Goffert van Gool <ruphin@ruphin.net>
Co-authored-by: Martin Pool <martin.pool@ing.com>
This commit is contained in:
Ahmet Yesil 2021-09-21 13:52:39 +02:00
parent dec9c7555a
commit 879598506a
22 changed files with 1768 additions and 959 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/ajax': patch
---
Fix cache session race condition for in-flight requests

View file

@ -0,0 +1,8 @@
---
'@lion/ajax': minor
---
**BREAKING** public API changes:
- Changed `timeToLive` to `maxAge`
- Renamed `requestIdentificationFn` to `requestIdFunction`

View file

@ -15,9 +15,11 @@ const getCacheIdentifier = () => {
return userId; return userId;
}; };
const TEN_MINUTES = 1000 * 60 * 10; // in milliseconds
const cacheOptions = { const cacheOptions = {
useCache: true, useCache: true,
timeToLive: 1000 * 60 * 10, // 10 minutes maxAge: TEN_MINUTES,
}; };
const [cacheRequestInterceptor, cacheResponseInterceptor] = createCacheInterceptors( const [cacheRequestInterceptor, cacheResponseInterceptor] = createCacheInterceptors(
@ -72,9 +74,13 @@ const newUser = await response.json();
### JSON requests ### JSON requests
We usually deal with JSON requests and responses. With `fetchJson` you don't need to specifically stringify the request body or parse the response body. We usually deal with JSON requests and responses. `ajax.fetchJson` supports JSON by:
The result will have the Response object on `.response` property, and the decoded json will be available on `.body`. - Serializing request body as JSON
- Deserializing response payload as JSON
- Adding the correct Content-Type and Accept headers
> Note that, the result will have the Response object on `.response` property, and the parsed JSON will be available on `.body`.
## GET JSON request ## GET JSON request
@ -133,7 +139,7 @@ export const errorHandling = () => {
} }
} else { } else {
// an error happened before receiving a response, // an error happened before receiving a response,
// ex. an incorrect request or network error // Example: an incorrect request or network error
actionLogger.log(error); actionLogger.log(error);
} }
} }
@ -157,32 +163,28 @@ For IE11 you will need a polyfill for fetch. You should add this on your top lev
[This is the polyfill we recommend](https://github.com/github/fetch). It also has a [section for polyfilling AbortController](https://github.com/github/fetch#aborting-requests) [This is the polyfill we recommend](https://github.com/github/fetch). It also has a [section for polyfilling AbortController](https://github.com/github/fetch#aborting-requests)
## Ajax Cache ## Ajax Caching Support
A caching library that uses `@lion/ajax` and adds cache interceptors to provide caching for use in Ajax package provides in-memory cache support through interceptors. And cache interceptors can be added manually or by configuring the Ajax instance.
frontend `services`.
The **request interceptor**'s main goal is to determine whether or not to The cache request interceptor and cache response interceptor are designed to work together to support caching of network requests/responses.
**return the cached object**. This is done based on the options that are being
passed.
The **response interceptor**'s goal is to determine **when to cache** the > The **request interceptor** checks whether the response for this particular request is cached, and if so returns the cached response.
requested response, based on the options that are being passed. > And the **response interceptor** caches the response for this particular request.
### Getting started ### Getting started
Consume the global `ajax` instance and add interceptors to it, using a cache configuration which is applied on application level. If a developer wants to add specifics to cache behaviour they have to provide a cache config per action (`get`, `post`, etc.) via `cacheOptions` field of local ajax config, Consume the global `ajax` instance and add interceptors to it, using a cache configuration which is applied on application level. If a developer wants to add specifics to cache behaviour they have to provide a cache config per action (`get`, `post`, etc.) via `cacheOptions` field of local ajax config,
see examples below. see examples below.
> **Note**: make sure to add the **interceptors** only **once**. This is usually > **Note**: make sure to add the **interceptors** only **once**. This is usually done on app-level
> done on app-level
```js ```js
import { ajax, createCacheInterceptors } from '@lion-web/ajax'; import { ajax, createCacheInterceptors } from '@lion-web/ajax';
const globalCacheOptions = { const globalCacheOptions = {
useCache: true, useCache: true,
timeToLive: 1000 * 60 * 5, // 5 minutes maxAge: 1000 * 60 * 5, // 5 minutes
}; };
// Cache is removed each time an identifier changes, // Cache is removed each time an identifier changes,
@ -208,7 +210,7 @@ import { Ajax } from '@lion/ajax';
export const ajax = new Ajax({ export const ajax = new Ajax({
cacheOptions: { cacheOptions: {
useCache: true, useCache: true,
timeToLive: 1000 * 60 * 5, // 5 minutes maxAge: 1000 * 60 * 5, // 5 minutes
getCacheIdentifier: () => getActiveProfile().profileId, getCacheIdentifier: () => getActiveProfile().profileId,
}, },
}); });
@ -218,8 +220,7 @@ export const ajax = new Ajax({
> Let's assume that we have a user session, for this demo purposes we already created an identifier function for this and set the cache interceptors. > Let's assume that we have a user session, for this demo purposes we already created an identifier function for this and set the cache interceptors.
We can see if a response is served from the cache by checking the `response.fromCache` property, We can see if a response is served from the cache by checking the `response.fromCache` property, which is either undefined for normal requests, or set to true for responses that were served from cache.
which is either undefined for normal requests, or set to true for responses that were served from cache.
```js preview-story ```js preview-story
export const cache = () => { export const cache = () => {
@ -284,28 +285,28 @@ export const cacheActionOptions = () => {
Invalidating the cache, or cache busting, can be done in multiple ways: Invalidating the cache, or cache busting, can be done in multiple ways:
- Going past the `timeToLive` of the cache object - Going past the `maxAge` of the cache object
- Changing cache identifier (e.g. user session or active profile changes) - Changing cache identifier (e.g. user session or active profile changes)
- Doing a non GET request to the cached endpoint - Doing a non GET request to the cached endpoint
- Invalidates the cache of that endpoint - Invalidates the cache of that endpoint
- Invalidates the cache of all other endpoints matching `invalidatesUrls` and `invalidateUrlsRegex` - Invalidates the cache of all other endpoints matching `invalidatesUrls` and `invalidateUrlsRegex`
## Time to live ## maxAge
In this demo we pass a timeToLive of three seconds. In this demo we pass a maxAge of three seconds.
Try clicking the fetch button and watch fromCache change whenever TTL expires. Try clicking the fetch button and watch fromCache change whenever maxAge expires.
After TTL expires, the next request will set the cache again, and for the next 3 seconds you will get cached responses for subsequent requests. After maxAge expires, the next request will set the cache again, and for the next 3 seconds you will get cached responses for subsequent requests.
```js preview-story ```js preview-story
export const cacheTimeToLive = () => { export const cacheMaxAge = () => {
const actionLogger = renderLitAsNode(html`<sb-action-logger></sb-action-logger>`); const actionLogger = renderLitAsNode(html`<sb-action-logger></sb-action-logger>`);
const fetchHandler = () => { const fetchHandler = () => {
ajax ajax
.fetchJson(`../assets/pabu.json`, { .fetchJson(`../assets/pabu.json`, {
cacheOptions: { cacheOptions: {
timeToLive: 1000 * 3, // 3 seconds maxAge: 1000 * 3, // 3 seconds
}, },
}) })
.then(result => { .then(result => {

View file

@ -1,10 +1,7 @@
# Tools >> Ajax >> Overview ||10 # Tools >> Ajax >> Overview ||10
```js script ```js script
import { html } from '@mdjs/mdjs-preview';
import { renderLitAsNode } from '@lion/helpers';
import { ajax, createCacheInterceptors } from '@lion/ajax'; import { ajax, createCacheInterceptors } from '@lion/ajax';
import '@lion/helpers/define';
const getCacheIdentifier = () => { const getCacheIdentifier = () => {
let userId = localStorage.getItem('lion-ajax-cache-demo-user-id'); let userId = localStorage.getItem('lion-ajax-cache-demo-user-id');
@ -15,9 +12,11 @@ const getCacheIdentifier = () => {
return userId; return userId;
}; };
const TEN_MINUTES = 1000 * 60 * 10; // in milliseconds
const cacheOptions = { const cacheOptions = {
useCache: true, useCache: true,
timeToLive: 1000 * 60 * 10, // 10 minutes maxAge: TEN_MINUTES,
}; };
const [cacheRequestInterceptor, cacheResponseInterceptor] = createCacheInterceptors( const [cacheRequestInterceptor, cacheResponseInterceptor] = createCacheInterceptors(
@ -33,8 +32,8 @@ ajax.addResponseInterceptor(cacheResponseInterceptor);
- Allows globally registering request and response interceptors - Allows globally registering request and response interceptors
- Throws on 4xx and 5xx status codes - Throws on 4xx and 5xx status codes
- Prevents network request if a request interceptor returns a response - Supports caching, so a request can be prevented from reaching to network, by returning the cached response.
- Supports a JSON request which automatically encodes/decodes body request and response payload as JSON - Supports JSON with `ajax.fetchJSON` by automatically serializing request body and deserializing response payload as JSON, and adding the correct Content-Type and Accept headers.
- Adds accept-language header to requests based on application language - Adds accept-language header to requests based on application language
- Adds XSRF header to request if the cookie is present - Adds XSRF header to request if the cookie is present

View file

@ -8,9 +8,14 @@ import { AjaxFetchError } from './AjaxFetchError.js';
import './typedef.js'; import './typedef.js';
/** /**
* HTTP Client which acts as a small wrapper around `fetch`. Allows registering hooks which * A small wrapper around `fetch`.
* intercept request and responses, for example to add authorization headers or logging. A - Allows globally registering request and response interceptors
* request can also be prevented from reaching the network at all by returning the Response directly. - Throws on 4xx and 5xx status codes
- Supports caching, so a request can be prevented from reaching to network, by returning the cached response.
- Supports JSON with `ajax.fetchJSON` by automatically serializing request body and
deserializing response payload as JSON, and adding the correct Content-Type and Accept headers.
- Adds accept-language header to requests based on application language
- Adds XSRF header to request if the cookie is present
*/ */
export class Ajax { export class Ajax {
/** /**
@ -49,18 +54,18 @@ export class Ajax {
const { cacheOptions } = this.__config; const { cacheOptions } = this.__config;
if (cacheOptions?.useCache) { if (cacheOptions?.useCache) {
const [cacheRequestInterceptor, cacheResponseInterceptor] = createCacheInterceptors( const { cacheRequestInterceptor, cacheResponseInterceptor } = createCacheInterceptors(
cacheOptions.getCacheIdentifier, cacheOptions.getCacheIdentifier,
cacheOptions, cacheOptions,
); );
this.addRequestInterceptor(/** @type {RequestInterceptor} */ (cacheRequestInterceptor)); this.addRequestInterceptor(cacheRequestInterceptor);
this.addResponseInterceptor(/** @type {ResponseInterceptor} */ (cacheResponseInterceptor)); this.addResponseInterceptor(cacheResponseInterceptor);
} }
} }
/** /**
* Sets the config for the instance * Configures the Ajax instance
* @param {Partial<AjaxConfig>} config configuration for the AjaxClass instance * @param {Partial<AjaxConfig>} config configuration for the Ajax instance
*/ */
set options(config) { set options(config) {
this.__config = config; this.__config = config;
@ -95,8 +100,7 @@ export class Ajax {
} }
/** /**
* Makes a fetch request, calling the registered fetch request and response * Fetch by calling the registered request and response interceptors.
* interceptors.
* *
* @param {RequestInfo} info * @param {RequestInfo} info
* @param {RequestInit & Partial<CacheRequestExtension>} [init] * @param {RequestInit & Partial<CacheRequestExtension>} [init]
@ -126,8 +130,11 @@ export class Ajax {
} }
/** /**
* Makes a fetch request, calling the registered fetch request and response * Fetch by calling the registered request and response
* interceptors. Encodes/decodes the request and response body as JSON. * interceptors. And supports JSON by:
* - Serializing request body as JSON
* - Deserializing response payload as JSON
* - Adding the correct Content-Type and Accept headers
* *
* @param {RequestInfo} info * @param {RequestInfo} info
* @param {LionRequestInit} [init] * @param {LionRequestInit} [init]
@ -149,7 +156,7 @@ export class Ajax {
lionInit.body = JSON.stringify(lionInit.body); lionInit.body = JSON.stringify(lionInit.body);
} }
// Now that we stringified lionInit.body, we can safely typecast LionRequestInit back to RequestInit // typecast LionRequestInit back to RequestInit
const jsonInit = /** @type {RequestInit} */ (lionInit); const jsonInit = /** @type {RequestInit} */ (lionInit);
const response = await this.fetch(info, jsonInit); const response = await this.fetch(info, jsonInit);
let responseText = await response.text(); let responseText = await response.text();

View file

@ -0,0 +1,65 @@
import './typedef.js';
export default class Cache {
constructor() {
/**
* @type {{ [requestId: string]: { createdAt: number, response: CacheResponse } }}
* @private
*/
this._cachedRequests = {};
}
/**
* Store an item in the cache
* @param {string} requestId key by which the request is stored
* @param {Response} response the cached response
*/
set(requestId, response) {
this._cachedRequests[requestId] = {
createdAt: Date.now(),
response,
};
}
/**
* Retrieve an item from the cache
* @param {string} requestId key by which the cache is stored
* @param {number} maxAge maximum age of a cached request to serve from cache, in milliseconds
* @returns {CacheResponse | undefined}
*/
get(requestId, maxAge = 0) {
const cachedRequest = this._cachedRequests[requestId];
if (!cachedRequest) {
return;
}
const cachedRequestAge = Date.now() - cachedRequest.createdAt;
if (Number.isFinite(maxAge) && cachedRequestAge < maxAge) {
// eslint-disable-next-line consistent-return
return cachedRequest.response;
}
}
/**
* Delete the item with the given requestId from the cache
* @param {string } requestId the request id to delete from the cache
*/
delete(requestId) {
delete this._cachedRequests[requestId];
}
/**
* Delete all items from the cache that match given regex
* @param {RegExp} regex a regular expression to match cache entries
*/
deleteMatching(regex) {
Object.keys(this._cachedRequests).forEach(requestId => {
if (new RegExp(regex).test(requestId)) {
this.delete(requestId);
}
});
}
reset() {
this._cachedRequests = {};
}
}

View file

@ -0,0 +1,62 @@
import './typedef.js';
export default class PendingRequestStore {
constructor() {
/**
* @type {{ [requestId: string]: { promise: Promise<void>, resolve: (value?: any) => void } }}
* @private
*/
this._pendingRequests = {};
}
/**
* Creates a promise for a pending request with given key
* @param {string} requestId
*/
set(requestId) {
if (this._pendingRequests[requestId]) {
return;
}
/** @type {(value?: any) => void } */
let resolve;
const promise = new Promise(_resolve => {
resolve = _resolve;
});
// @ts-ignore
this._pendingRequests[requestId] = { promise, resolve };
}
/**
* Gets the promise for a pending request with given key
* @param {string} requestId
* @returns {Promise<void> | undefined}
*/
get(requestId) {
return this._pendingRequests[requestId]?.promise;
}
/**
* Resolves the promise of a pending request that matches the given string
* @param { string } requestId the requestId to resolve
*/
resolve(requestId) {
this._pendingRequests[requestId]?.resolve();
delete this._pendingRequests[requestId];
}
/**
* Resolves the promise of pending requests that match the given regex
* @param { RegExp } regex an regular expression to match store entries
*/
resolveMatching(regex) {
Object.keys(this._pendingRequests).forEach(pendingRequestId => {
if (regex.test(pendingRequestId)) {
this.resolve(pendingRequestId);
}
});
}
reset() {
this._pendingRequests = {};
}
}

View file

@ -1,221 +0,0 @@
/* eslint-disable consistent-return */
/* eslint-disable no-param-reassign */
import './typedef.js';
const SECOND = 1000;
const MINUTE = SECOND * 60;
const HOUR = MINUTE * 60;
const DEFAULT_TIME_TO_LIVE = HOUR;
class Cache {
constructor() {
this.expiration = new Date().getTime() + DEFAULT_TIME_TO_LIVE;
/**
* @type {{[url: string]: {expires: number, response: CacheResponse} }}
* @private
*/
this._cacheObject = {};
/**
* @type {{ [url: string]: { promise: Promise<void>, resolve: (v?: any) => void } }}
* @private
*/
this._pendingRequests = {};
}
/** @param {string} url */
setPendingRequest(url) {
/** @type {(v: any) => void} */
let resolve = () => {};
const promise = new Promise(_resolve => {
resolve = _resolve;
});
this._pendingRequests[url] = { promise, resolve };
}
/**
* @param {string} url
* @returns {Promise<void> | undefined}
*/
getPendingRequest(url) {
if (this._pendingRequests[url]) {
return this._pendingRequests[url].promise;
}
}
/** @param {string} url */
resolvePendingRequest(url) {
if (this._pendingRequests[url]) {
this._pendingRequests[url].resolve();
delete this._pendingRequests[url];
}
}
/**
* Store an item in the cache
* @param {string} url key by which the cache is stored
* @param {Response} response the cached response
*/
set(url, response) {
this._validateCache();
this._cacheObject[url] = {
expires: new Date().getTime(),
response,
};
}
/**
* 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
* @returns {CacheResponse | false}
*/
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.response;
}
/**
* 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.includes(url)) {
delete this._cacheObject[key];
this.resolvePendingRequest(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;
delete this._cacheObject[key];
this.resolvePendingRequest(key);
});
}
/**
* Validate cache on each call to the Cache
* When the expiration date has passed, the _cacheObject will be replaced by an
* empty object
* @protected
*/
_validateCache() {
if (new Date().getTime() > this.expiration) {
this._cacheObject = {};
return false;
}
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 stringifySearchParams = (params = {}) =>
typeof params === 'object' && params !== null ? 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
*/
export const getCache = cacheIdentifier => {
if (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 validateCacheOptions = ({
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 = DEFAULT_TIME_TO_LIVE;
}
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`');
}
} else {
// eslint-disable-next-line no-shadow
requestIdentificationFn = /** @param {any} data */ ({ url, params }, stringifySearchParams) => {
const serializedParams = stringifySearchParams(params);
return serializedParams ? `${url}?${serializedParams}` : url;
};
}
return {
useCache,
methods,
timeToLive,
invalidateUrls,
invalidateUrlsRegex,
requestIdentificationFn,
};
};

View file

@ -0,0 +1,157 @@
import './typedef.js';
import Cache from './Cache.js';
import PendingRequestStore from './PendingRequestStore.js';
/**
* The id for the cache session
* @type {string | undefined}
*/
let cacheSessionId;
/**
* The ajax cache
* @type {Cache}
*/
export const ajaxCache = new Cache();
/**
* The pending request store
* @type {PendingRequestStore}
*/
export const pendingRequestStore = new PendingRequestStore();
/**
* Checks whether the given cacheSessionId matches the currently active id.
*
* @param {string|undefined} cacheId The cache id to check
*/
export const isCurrentSessionId = cacheId => cacheId === cacheSessionId;
/**
* Resets the cache session when the cacheId changes.
*
* There can be only 1 active session at all times.
* @param {string} cacheId The cache id that is tied to the current session
*/
export const resetCacheSession = cacheId => {
if (!cacheId) {
throw new Error('Invalid cache identifier');
}
if (!isCurrentSessionId(cacheId)) {
cacheSessionId = cacheId;
ajaxCache.reset();
pendingRequestStore.reset();
}
};
/**
* Stringify URL search params
* @param {Params} params query string parameters object
* @returns {string} of querystring parameters WITHOUT `?` or empty string ''
*/
const stringifySearchParams = (params = {}) =>
typeof params === 'object' && params !== null ? new URLSearchParams(params).toString() : '';
/**
* Returns request key string, which uniquely identifies a Request
* @param {Partial<CacheRequest>} request Request object
* @param {function} serializeSearchParams Function to serialize URL search params
* @returns {string} requestId to uniquely identify a request
*/
const DEFAULT_GET_REQUEST_ID = (
{ url = '', params },
serializeSearchParams = stringifySearchParams,
) => {
const serializedParams = serializeSearchParams(params);
return serializedParams ? `${url}?${serializedParams}` : url;
};
/**
* Defaults to 1 hour
*/
const DEFAULT_MAX_AGE = 1000 * 60 * 60;
/**
* @param {CacheOptions} options Cache options
* @returns {ValidatedCacheOptions}
*/
export const extendCacheOptions = ({
useCache = false,
methods = ['get'],
maxAge = DEFAULT_MAX_AGE,
requestIdFunction = DEFAULT_GET_REQUEST_ID,
invalidateUrls,
invalidateUrlsRegex,
}) => ({
useCache,
methods,
maxAge,
requestIdFunction,
invalidateUrls,
invalidateUrlsRegex,
});
/**
* @param {CacheOptions} options Cache options
*/
export const validateCacheOptions = ({
useCache,
methods,
maxAge,
requestIdFunction,
invalidateUrls,
invalidateUrlsRegex,
} = {}) => {
if (useCache !== undefined && typeof useCache !== 'boolean') {
throw new Error('Property `useCache` must be a `boolean`');
}
if (methods !== undefined && JSON.stringify(methods) !== JSON.stringify(['get'])) {
throw new Error('Cache can only be utilized with `GET` method');
}
if (maxAge !== undefined && !Number.isFinite(maxAge)) {
throw new Error('Property `maxAge` must be a finite `number`');
}
if (invalidateUrls !== undefined && !Array.isArray(invalidateUrls)) {
throw new Error('Property `invalidateUrls` must be an `Array` or `falsy`');
}
if (invalidateUrlsRegex !== undefined && !(invalidateUrlsRegex instanceof RegExp)) {
throw new Error('Property `invalidateUrlsRegex` must be a `RegExp` or `falsy`');
}
if (requestIdFunction !== undefined && typeof requestIdFunction !== 'function') {
throw new Error('Property `requestIdFunction` must be a `function`');
}
};
/**
* Invalidates matching requestIds in the cache and pendingRequestStore
*
* There are two kinds of invalidate rules:
* invalidateUrls (array of URL like strings)
* invalidateUrlsRegex (RegExp)
* If a non-GET method is fired, by default it only invalidates its own endpoint.
* Invalidating /api/users cache by doing a PATCH, will not invalidate /api/accounts cache.
* However, in the case of users and accounts, they may be very interconnected,
* so perhaps you do want to invalidate /api/accounts when invalidating /api/users.
* If it's NOT one of the config.methods, invalidate caches
*
* @param requestId { string }
* @param cacheOptions { CacheOptions }
*/
export const invalidateMatchingCache = (requestId, { invalidateUrls, invalidateUrlsRegex }) => {
// invalidate this request
ajaxCache.delete(requestId);
pendingRequestStore.resolve(requestId);
// also invalidate caches matching to invalidateUrls
if (Array.isArray(invalidateUrls)) {
invalidateUrls.forEach(url => {
ajaxCache.delete(url);
pendingRequestStore.resolve(url);
});
}
// also invalidate caches matching to invalidateUrlsRegex
if (invalidateUrlsRegex) {
ajaxCache.deleteMatching(invalidateUrlsRegex);
pendingRequestStore.resolveMatching(invalidateUrlsRegex);
}
};

View file

@ -1,137 +1,112 @@
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
import '../typedef.js'; import '../typedef.js';
import { validateCacheOptions, stringifySearchParams, getCache } from '../cache.js'; import {
ajaxCache,
resetCacheSession,
extendCacheOptions,
validateCacheOptions,
invalidateMatchingCache,
pendingRequestStore,
isCurrentSessionId,
} from '../cacheManager.js';
/** /**
* Request interceptor to return relevant cached requests * Request interceptor to return relevant cached requests
* @param {function(): string} getCacheIdentifier used to invalidate cache if identifier is changed * @param {function(): string} getCacheId used to invalidate cache if identifier is changed
* @param {CacheOptions} globalCacheOptions * @param {CacheOptions} globalCacheOptions
* @returns {RequestInterceptor} * @returns {RequestInterceptor}
*/ */
const createCacheRequestInterceptor = (getCacheIdentifier, globalCacheOptions) => { const createCacheRequestInterceptor =
const validatedInitialCacheOptions = validateCacheOptions(globalCacheOptions); (getCacheId, globalCacheOptions) => /** @param {CacheRequest} request */ async request => {
validateCacheOptions(request.cacheOptions);
const cacheSessionId = getCacheId();
resetCacheSession(cacheSessionId); // cacheSessionId is used to bind the cache to the current session
return /** @param {CacheRequest} cacheRequest */ async cacheRequest => { const cacheOptions = extendCacheOptions({
const cacheOptions = validateCacheOptions({ ...globalCacheOptions,
...validatedInitialCacheOptions, ...request.cacheOptions,
...cacheRequest.cacheOptions,
}); });
cacheRequest.cacheOptions = cacheOptions; // store cacheOptions and cacheSessionId in the request, to use it in the response interceptor.
request.cacheOptions = cacheOptions;
request.cacheSessionId = cacheSessionId;
// don't use cache if 'useCache' === false
if (!cacheOptions.useCache) { if (!cacheOptions.useCache) {
return cacheRequest; return request;
} }
const cacheId = cacheOptions.requestIdentificationFn(cacheRequest, stringifySearchParams); const requestId = cacheOptions.requestIdFunction(request);
// cacheIdentifier is used to bind the cache to the current session const isMethodSupported = cacheOptions.methods.includes(request.method.toLowerCase());
const currentCache = getCache(getCacheIdentifier());
const { method } = cacheRequest;
// don't use cache if the request method is not part of the configs methods if (!isMethodSupported) {
if (!cacheOptions.methods.includes(method.toLowerCase())) { invalidateMatchingCache(requestId, cacheOptions);
// If it's NOT one of the config.methods, invalidate caches return request;
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;
} }
const pendingRequest = currentCache.getPendingRequest(cacheId); const pendingRequest = pendingRequestStore.get(requestId);
if (pendingRequest) { if (pendingRequest) {
// there is another concurrent request, wait for it to finish // there is another concurrent request, wait for it to finish
await pendingRequest; await pendingRequest;
} }
const cacheResponse = currentCache.get(cacheId, cacheOptions.timeToLive); const cachedResponse = ajaxCache.get(requestId, cacheOptions.maxAge);
if (cacheResponse) { if (cachedResponse) {
cacheRequest.cacheOptions = cacheRequest.cacheOptions ?? { useCache: false }; // Return the response from cache
const response = /** @type {CacheResponse} */ cacheResponse.clone(); request.cacheOptions = request.cacheOptions ?? { useCache: false };
response.request = cacheRequest; /** @type {CacheResponse} */
const response = cachedResponse.clone();
response.request = request;
response.fromCache = true; response.fromCache = true;
return response; return response;
} }
// we do want to use caching for this requesting, but it's not already cached // Mark this as a pending request, so that concurrent requests can use the response from this request
// mark this as a pending request, so that concurrent requests can reuse it from the cache pendingRequestStore.set(requestId);
currentCache.setPendingRequest(cacheId); return request;
return cacheRequest;
}; };
};
/** /**
* Response interceptor to cache relevant requests * Response interceptor to cache relevant requests
* @param {function(): string} getCacheIdentifier used to invalidate cache if identifier is changed
* @param {CacheOptions} globalCacheOptions * @param {CacheOptions} globalCacheOptions
* @returns {ResponseInterceptor} * @returns {ResponseInterceptor}
*/ */
const createCacheResponseInterceptor = (getCacheIdentifier, globalCacheOptions) => { const createCacheResponseInterceptor =
const validatedInitialCacheOptions = validateCacheOptions(globalCacheOptions); globalCacheOptions => /** @param {CacheResponse} response */ async response => {
if (!response.request) {
/** throw new Error('Missing request in response');
* Axios response https://github.com/axios/axios#response-schema
*/
return /** @param {CacheResponse} cacheResponse */ async cacheResponse => {
if (!getCacheIdentifier()) {
throw new Error(`getCacheIdentifier returns falsy`);
} }
if (!cacheResponse.request) { const cacheOptions = extendCacheOptions({
throw new Error('Missing request in response.'); ...globalCacheOptions,
} ...response.request.cacheOptions,
const cacheOptions = validateCacheOptions({
...validatedInitialCacheOptions,
...cacheResponse.request?.cacheOptions,
}); });
// string that identifies cache entry const requestId = cacheOptions.requestIdFunction(response.request);
const cacheId = cacheOptions.requestIdentificationFn( const isAlreadyFromCache = !!response.fromCache;
cacheResponse.request, const isCacheActive = cacheOptions.useCache;
stringifySearchParams, const isMethodSupported = cacheOptions.methods.includes(response.request?.method.toLowerCase());
);
const currentCache = getCache(getCacheIdentifier());
const isAlreadyFromCache = !!cacheResponse.fromCache;
// caching all responses with not default `timeToLive`
const isCacheActive = cacheOptions.timeToLive > 0;
const isMethodSupported = cacheOptions.methods.includes(
cacheResponse.request.method.toLowerCase(),
);
// if the request is one of the options.methods; store response in cache
if (!isAlreadyFromCache && isCacheActive && isMethodSupported) {
// store the response data in the cache and mark request as resolved
currentCache.set(cacheId, cacheResponse.clone());
}
currentCache.resolvePendingRequest(cacheId); if (!isAlreadyFromCache && isCacheActive && isMethodSupported) {
return cacheResponse; if (isCurrentSessionId(response.request.cacheSessionId)) {
// Cache the response
ajaxCache.set(requestId, response.clone());
}
// Mark the pending request as resolved
pendingRequestStore.resolve(requestId);
}
return response;
}; };
};
/** /**
* Response interceptor to cache relevant requests * Response interceptor to cache relevant requests
* @param {function(): string} getCacheIdentifier used to invalidate cache if identifier is changed * @param {function(): string} getCacheId used to invalidate cache if identifier is changed
* @param {CacheOptions} globalCacheOptions * @param {CacheOptions} globalCacheOptions
* @returns [{RequestInterceptor}, {ResponseInterceptor}] * @returns {{cacheRequestInterceptor: RequestInterceptor, cacheResponseInterceptor: ResponseInterceptor}}
*/ */
export const createCacheInterceptors = (getCacheIdentifier, globalCacheOptions) => { export const createCacheInterceptors = (getCacheId, globalCacheOptions) => {
const requestInterceptor = createCacheRequestInterceptor(getCacheIdentifier, globalCacheOptions); validateCacheOptions(globalCacheOptions);
const responseInterceptor = createCacheResponseInterceptor( const cacheRequestInterceptor = createCacheRequestInterceptor(getCacheId, globalCacheOptions);
getCacheIdentifier, const cacheResponseInterceptor = createCacheResponseInterceptor(globalCacheOptions);
globalCacheOptions, return { cacheRequestInterceptor, cacheResponseInterceptor };
);
return [requestInterceptor, responseInterceptor];
}; };

View file

@ -5,7 +5,7 @@
* @typedef {import('../types/types').ResponseInterceptor} ResponseInterceptor * @typedef {import('../types/types').ResponseInterceptor} ResponseInterceptor
* @typedef {import('../types/types').CacheConfig} CacheConfig * @typedef {import('../types/types').CacheConfig} CacheConfig
* @typedef {import('../types/types').Params} Params * @typedef {import('../types/types').Params} Params
* @typedef {import('../types/types').RequestIdentificationFn} RequestIdentificationFn * @typedef {import('../types/types').RequestIdFunction} RequestIdFunction
* @typedef {import('../types/types').CacheOptions} CacheOptions * @typedef {import('../types/types').CacheOptions} CacheOptions
* @typedef {import('../types/types').ValidatedCacheOptions} ValidatedCacheOptions * @typedef {import('../types/types').ValidatedCacheOptions} ValidatedCacheOptions
* @typedef {import('../types/types').CacheRequestExtension} CacheRequestExtension * @typedef {import('../types/types').CacheRequestExtension} CacheRequestExtension

View file

@ -26,7 +26,7 @@ describe('Ajax', () => {
jsonPrefix: ")]}',", jsonPrefix: ")]}',",
cacheOptions: { cacheOptions: {
useCache: true, useCache: true,
timeToLive: 1000 * 60 * 5, // 5 minutes maxAge: 1000 * 60 * 5, // 5 minutes
getCacheIdentifier, getCacheIdentifier,
}, },
}; };
@ -37,7 +37,7 @@ describe('Ajax', () => {
jsonPrefix: ")]}',", jsonPrefix: ")]}',",
cacheOptions: { cacheOptions: {
useCache: true, useCache: true,
timeToLive: 300000, maxAge: 300000,
getCacheIdentifier, getCacheIdentifier,
}, },
}; };
@ -53,7 +53,7 @@ describe('Ajax', () => {
const config = { const config = {
cacheOptions: { cacheOptions: {
useCache: true, useCache: true,
timeToLive: 1000 * 60 * 5, // 5 minutes maxAge: 1000 * 60 * 5, // 5 minutes
}, },
}; };
// When // When
@ -288,7 +288,7 @@ describe('Ajax', () => {
const customAjax = new Ajax({ const customAjax = new Ajax({
cacheOptions: { cacheOptions: {
useCache: true, useCache: true,
timeToLive: 100, maxAge: 100,
getCacheIdentifier, getCacheIdentifier,
}, },
}); });

View file

@ -0,0 +1,196 @@
// @ts-nocheck
import { expect } from '@open-wc/testing';
import Cache from '../src/Cache.js';
const A_MINUTE_IN_MS = 60 * 1000;
const TWO_MINUTES_IN_MS = 2 * A_MINUTE_IN_MS;
const TEN_MINUTES_IN_MS = 10 * A_MINUTE_IN_MS;
describe('Cache', () => {
describe('public interface', () => {
const cache = new Cache();
it('Cache has `set` method', () => {
expect(cache.set).to.exist;
});
it('Cache has `get` method', () => {
expect(cache.get).to.exist;
});
it('Cache has `delete` method', () => {
expect(cache.delete).to.exist;
});
it('Cache has `reset` method', () => {
expect(cache.reset).to.exist;
});
});
describe('cache.get', () => {
// Mock cache data
const cache = new Cache();
cache._cachedRequests = {
requestId1: { createdAt: Date.now() - TWO_MINUTES_IN_MS, response: 'cached data 1' },
requestId2: { createdAt: Date.now(), response: 'cached data 2' },
};
it('returns undefined if no cached request found for requestId', () => {
// Given
const maxAge = TEN_MINUTES_IN_MS;
const expected = undefined;
// When
const result = cache.get('nonCachedRequestId', maxAge);
// Then
expect(result).to.equal(expected);
});
it('returns undefined if maxAge is not a number', () => {
// Given
const maxAge = 'some string';
const expected = undefined;
// When
const result = cache.get('requestId1', maxAge);
// Then
expect(result).to.equal(expected);
});
it('returns undefined if maxAge is not finite', () => {
// Given
const maxAge = 1 / 0;
const expected = undefined;
// When
const result = cache.get('requestId1', maxAge);
// Then
expect(result).to.equal(expected);
});
it('returns undefined if maxAge is negative', () => {
// Given
const maxAge = -10;
const expected = undefined;
// When
const result = cache.get('requestId1', maxAge);
// Then
expect(result).to.equal(expected);
});
it('returns undefined if cached request age is not less than maxAge', () => {
// Given
const maxAge = A_MINUTE_IN_MS;
const expected = undefined;
// When
const result = cache.get('requestId1', maxAge);
// Then
expect(result).to.equal(expected);
});
it('gets the cached request by requestId if cached request age is less than maxAge', () => {
// Given
const maxAge = TEN_MINUTES_IN_MS;
const expected = cache._cachedRequests?.requestId1?.response;
// When
const result = cache.get('requestId1', maxAge);
// Then
expect(result).to.deep.equal(expected);
});
});
describe('cache.set', () => {
it('stores the `response` for the given `requestId`', () => {
// Given
const cache = new Cache();
const maxAge = TEN_MINUTES_IN_MS;
const response1 = 'response of request1';
const response2 = 'response of request2';
// When
cache.set('requestId1', response1);
cache.set('requestId2', response2);
// Then
expect(cache.get('requestId1', maxAge)).to.equal(response1);
expect(cache.get('requestId2', maxAge)).to.equal(response2);
});
it('updates the `response` for the given `requestId`, if already cached', () => {
// Given
const cache = new Cache();
const maxAge = TEN_MINUTES_IN_MS;
const response = 'response of request1';
const updatedResponse = 'updated response of request1';
// When
cache.set('requestId1', response);
// Then
expect(cache.get('requestId1', maxAge)).to.equal(response);
// When
cache.set('requestId1', updatedResponse);
// Then
expect(cache.get('requestId1', maxAge)).to.equal(updatedResponse);
});
});
describe('cache.delete', () => {
it('deletes cache by `requestId`', () => {
// Given
const cache = new Cache();
const maxAge = TEN_MINUTES_IN_MS;
const response1 = 'response of request1';
const response2 = 'response of request2';
// When
cache.set('requestId1', response1);
cache.set('requestId2', response2);
// Then
expect(cache.get('requestId1', maxAge)).to.equal(response1);
expect(cache.get('requestId2', maxAge)).to.equal(response2);
// When
cache.delete('requestId1');
// Then
expect(cache.get('requestId1', maxAge)).to.be.undefined;
expect(cache.get('requestId2', maxAge)).to.equal(response2);
});
it('deletes cache by regex', () => {
// Given
const cache = new Cache();
const maxAge = TEN_MINUTES_IN_MS;
const response1 = 'response of request1';
const response2 = 'response of request2';
const response3 = 'response of request3';
// When
cache.set('requestId1', response1);
cache.set('requestId2', response2);
cache.set('anotherRequestId', response3);
// Then
expect(cache.get('requestId1', maxAge)).to.equal(response1);
expect(cache.get('requestId2', maxAge)).to.equal(response2);
expect(cache.get('anotherRequestId', maxAge)).to.equal(response3);
// When
cache.deleteMatching(/^requestId/);
// Then
expect(cache.get('requestId1', maxAge)).to.be.undefined;
expect(cache.get('requestId2', maxAge)).to.be.undefined;
expect(cache.get('anotherRequestId', maxAge)).to.equal(response3);
});
});
describe('cache.reset', () => {
it('resets the cache', () => {
// Given
const cache = new Cache();
const maxAge = TEN_MINUTES_IN_MS;
const response1 = 'response of request1';
const response2 = 'response of request2';
// When
cache.set('requestId1', response1);
cache.set('requestId2', response2);
// Then
expect(cache.get('requestId1', maxAge)).to.equal(response1);
expect(cache.get('requestId2', maxAge)).to.equal(response2);
// When
cache.reset();
// Then
expect(cache.get('requestId1', maxAge)).to.be.undefined;
expect(cache.get('requestId2', maxAge)).to.be.undefined;
});
});
});

View file

@ -0,0 +1,159 @@
// @ts-nocheck
import { expect } from '@open-wc/testing';
import PendingRequestStore from '../src/PendingRequestStore.js';
describe('PendingRequestStore', () => {
let pendingRequestStore;
beforeEach(() => {
pendingRequestStore = new PendingRequestStore();
});
describe('public interface', () => {
it('PendingRequestStore has `set` method', () => {
expect(pendingRequestStore.set).to.exist;
});
it('PendingRequestStore has `get` method', () => {
expect(pendingRequestStore.get).to.exist;
});
it('PendingRequestStore has `resolve` method', () => {
expect(pendingRequestStore.resolve).to.exist;
});
it('PendingRequestStore has `reset` method', () => {
expect(pendingRequestStore.reset).to.exist;
});
});
describe('getting and setting', () => {
it('will return undefined for an unknown key', () => {
expect(pendingRequestStore.get('unknown-key')).to.be.undefined;
});
it('will return a promise for a key that has been added earlier', () => {
// Given
pendingRequestStore.set('a-key');
// Then
expect(pendingRequestStore.get('a-key')).to.be.a('Promise');
});
it('will not replace an already known entry', () => {
// Given
pendingRequestStore.set('the-original-key');
const theOriginalPromise = pendingRequestStore.get('the-original-key');
// When
pendingRequestStore.set('the-original-key');
// Then
expect(pendingRequestStore.get('the-original-key')).to.equal(theOriginalPromise);
});
it('will return the same promise when retrieved twice', () => {
// Given
pendingRequestStore.set('a-key');
// When
const a1 = pendingRequestStore.get('a-key');
const a2 = pendingRequestStore.get('a-key');
// Then
expect(a1).to.equal(a2);
});
it('will return undefined when the store is reset', () => {
// Given
pendingRequestStore.set('a-key');
// When
pendingRequestStore.reset();
// Then
expect(pendingRequestStore.get('a-key')).to.be.undefined;
});
});
describe('resolving', () => {
it('will resolve a named promise and delete it', async () => {
// Given
pendingRequestStore.set('do-groceries');
const backFromTheStore = pendingRequestStore
.get('do-groceries')
.catch(() => expect.fail('Promise was rejected before it could be resolved'));
// When
pendingRequestStore.resolve('do-groceries');
await backFromTheStore;
// Then
expect(pendingRequestStore.get('do-groceries')).to.be.undefined;
});
it('will use the same promise when retrieving or resolving the same key', async () => {
// Given
const fridge = [];
pendingRequestStore.set('do-groceries');
pendingRequestStore.get('do-groceries').then(() => fridge.push('milk'));
pendingRequestStore.get('do-groceries').then(() => fridge.push('eggs'));
const backFromTheStore = pendingRequestStore.get('do-groceries');
// When
pendingRequestStore.resolve('do-groceries');
await backFromTheStore;
// Then
expect(fridge).to.contain('milk');
expect(fridge).to.contain('eggs');
expect(pendingRequestStore.get('do-groceries')).to.be.undefined;
});
});
describe('resolving multiple requestIds by regular expression', () => {
it('will resolve multiple promises matching a regular expression and delete them', async () => {
// Given
let canIPlayNow = false;
pendingRequestStore.set('do-dishes');
pendingRequestStore.set('do-groceries');
const choresAllDone = Promise.all([
pendingRequestStore.get('do-dishes'),
pendingRequestStore.get('do-groceries'),
]);
// When
pendingRequestStore.resolveMatching(/^do-/);
await choresAllDone.then(() => {
canIPlayNow = true;
});
// Then
expect(canIPlayNow).to.be.ok;
expect(pendingRequestStore.get('do-groceries')).to.be.undefined;
expect(pendingRequestStore.get('do-dishes')).to.be.undefined;
});
});
it('will leave unmatched requests alone when resolving', () => {
// Given
pendingRequestStore.set('do-dishes');
pendingRequestStore.set('do-groceries');
pendingRequestStore.set('ponder-meaning-of-life');
// When
pendingRequestStore.resolveMatching(/^do-/);
// Then
expect(pendingRequestStore.get('do-groceries')).to.be.undefined;
expect(pendingRequestStore.get('do-dishes')).to.be.undefined;
expect(pendingRequestStore.get('ponder-meaning-of-life')).not.to.be.undefined;
});
});

View file

@ -0,0 +1,313 @@
// @ts-nocheck
import { expect } from '@open-wc/testing';
import * as sinon from 'sinon';
import {
ajaxCache,
pendingRequestStore,
resetCacheSession,
extendCacheOptions,
validateCacheOptions,
invalidateMatchingCache,
isCurrentSessionId,
} from '../src/cacheManager.js';
import Cache from '../src/Cache.js';
import PendingRequestStore from '../src/PendingRequestStore.js';
describe('cacheManager', () => {
describe('ajaxCache', () => {
it('is an instance of the Cache class', () => {
expect(ajaxCache).to.be.instanceOf(Cache);
});
});
describe('pendingRequestStore', () => {
it('is an instance of the PendingRequestStore class', () => {
expect(pendingRequestStore).to.be.instanceOf(PendingRequestStore);
});
});
describe('resetCacheSession', () => {
let ajaxCacheSpy;
let pendingRequestStoreSpy;
beforeEach(() => {
ajaxCacheSpy = sinon.spy(ajaxCache, 'reset');
pendingRequestStoreSpy = sinon.spy(pendingRequestStore, 'reset');
});
afterEach(() => {
ajaxCacheSpy.restore();
pendingRequestStoreSpy.restore();
});
it('throws an Error when no cacheId is passed', () => {
try {
resetCacheSession();
} catch (e) {
expect(e).to.be.an.instanceOf(Error);
}
});
it('assigns the passed cacheId to the cacheSessionId', () => {
// Arrange
const cacheId = 'a-new-cache-id';
// Act
resetCacheSession(cacheId);
// Assert
expect(ajaxCacheSpy.calledOnce).to.be.true;
expect(pendingRequestStoreSpy.calledOnce).to.be.true;
});
});
describe('extendCacheOptions', () => {
// Arrange
const DEFAULT_MAX_AGE = 1000 * 60 * 60;
const invalidateUrls = ['https://f00.bar/', 'https://share.ware/'];
const invalidateUrlsRegex = /f00/;
it('returns object with default values', () => {
// Act
const {
useCache,
methods,
maxAge,
requestIdFunction,
invalidateUrls: invalidateUrlsResult,
invalidateUrlsRegex: invalidateUrlsRegexResult,
} = extendCacheOptions({ invalidateUrls, invalidateUrlsRegex });
// Assert
expect(useCache).to.be.false;
expect(methods).to.eql(['get']);
expect(maxAge).to.equal(DEFAULT_MAX_AGE);
expect(typeof requestIdFunction).to.eql('function');
expect(invalidateUrlsResult).to.equal(invalidateUrls);
expect(invalidateUrlsRegexResult).to.equal(invalidateUrlsRegex);
});
it('the DEFAULT_GET_REQUEST_ID function throws when called with no arguments', () => {
// Arrange
const { requestIdFunction } = extendCacheOptions({
invalidateUrls,
invalidateUrlsRegex,
});
// Act
expect(requestIdFunction).to.throw(TypeError);
});
it('the DEFAULT_GET_REQUEST_ID function returns a url when URLSearchParams cannot be serialized', () => {
// Arrange
const { requestIdFunction } = extendCacheOptions({
invalidateUrls,
invalidateUrlsRegex,
});
// Act
const formattedUrl = requestIdFunction({
url: 'http://f00.bar/',
params: {},
});
// Assert
expect(formattedUrl).to.equal('http://f00.bar/');
});
it('the DEFAULT_GET_REQUEST_ID function returns a correctly formatted url with URLSearchParams', () => {
// Arrange
const { requestIdFunction } = extendCacheOptions({
invalidateUrls,
invalidateUrlsRegex,
});
// Act
const formattedUrl = requestIdFunction({
url: 'http://f00.bar/',
params: { f00: 'bar', bar: 'f00' },
});
// Assert
expect(formattedUrl).to.equal('http://f00.bar/?f00=bar&bar=f00');
});
});
describe('validateCacheOptions', () => {
it('does not accept null as argument', () => {
expect(() => validateCacheOptions(null)).to.throw(TypeError);
});
it('accepts an empty object', () => {
expect(() => validateCacheOptions({})).not.to.throw(
'Property `useCache` must be a `boolean`',
);
});
describe('the useCache property', () => {
it('accepts a boolean', () => {
expect(() => validateCacheOptions({ useCache: false })).not.to.throw;
});
it('accepts undefined', () => {
expect(() => validateCacheOptions({ useCache: undefined })).not.to.throw;
});
it('does not accept anything else', () => {
// @ts-ignore
expect(() => validateCacheOptions({ useCache: '' })).to.throw(
'Property `useCache` must be a `boolean`',
);
});
});
describe('the methods property', () => {
it('accepts an array with the value `get`', () => {
expect(() => validateCacheOptions({ methods: ['get'] })).not.to.throw;
});
it('accepts undefined', () => {
expect(() => validateCacheOptions({ methods: undefined })).not.to.throw;
});
it('does not accept anything else', () => {
expect(() => validateCacheOptions({ methods: [] })).to.throw(
'Cache can only be utilized with `GET` method',
);
expect(() => validateCacheOptions({ methods: ['post'] })).to.throw(
'Cache can only be utilized with `GET` method',
);
expect(() => validateCacheOptions({ methods: ['get', 'post'] })).to.throw(
'Cache can only be utilized with `GET` method',
);
});
});
describe('the maxAge property', () => {
it('accepts a finite number', () => {
expect(() => validateCacheOptions({ maxAge: 42 })).not.to.throw;
});
it('accepts undefined', () => {
expect(() => validateCacheOptions({ maxAge: undefined })).not.to.throw;
});
it('does not accept anything else', () => {
// @ts-ignore
expect(() => validateCacheOptions({ maxAge: 'string' })).to.throw(
'Property `maxAge` must be a finite `number`',
);
expect(() => validateCacheOptions({ maxAge: Infinity })).to.throw(
'Property `maxAge` must be a finite `number`',
);
});
});
describe('the invalidateUrls property', () => {
it('accepts an array', () => {
// @ts-ignore Typescript requires this to be an array of string, but this is not checked by validateCacheOptions
expect(() =>
validateCacheOptions({ invalidateUrls: [6, 'elements', 'in', 1, true, Array] }),
).not.to.throw;
});
it('accepts undefined', () => {
expect(() => validateCacheOptions({ invalidateUrls: undefined })).not.to.throw;
});
it('does not accept anything else', () => {
// @ts-ignore
expect(() => validateCacheOptions({ invalidateUrls: 'not-an-array' })).to.throw(
'Property `invalidateUrls` must be an `Array` or `falsy`',
);
});
});
describe('the invalidateUrlsRegex property', () => {
it('accepts a regular expression', () => {
expect(() => validateCacheOptions({ invalidateUrlsRegex: /this is a very picky regex/ }))
.not.to.throw;
});
it('accepts undefined', () => {
expect(() => validateCacheOptions({ invalidateUrlsRegex: undefined })).not.to.throw;
});
it('does not accept anything else', () => {
// @ts-ignore
expect(() =>
validateCacheOptions({ invalidateUrlsRegex: 'a string is not a regex' }),
).to.throw('Property `invalidateUrlsRegex` must be a `RegExp` or `falsy`');
});
});
describe('the requestIdFunction property', () => {
it('accepts a function', () => {
// @ts-ignore Typescript requires the requestIdFunction to return a string, but this is not checked by validateCacheOptions
expect(() =>
validateCacheOptions({ requestIdFunction: () => ['this-is-ok-outside-typescript'] }),
).not.to.throw;
});
it('accepts undefined', () => {
expect(() => validateCacheOptions({ requestIdFunction: undefined })).not.to.throw;
});
it('does not accept anything else', () => {
// @ts-ignore
expect(() => validateCacheOptions({ requestIdFunction: 'not a function' })).to.throw(
'Property `requestIdFunction` must be a `function`',
);
});
});
});
describe('invalidateMatchingCache', () => {
beforeEach(() => {
sinon.spy(ajaxCache, 'delete');
sinon.spy(ajaxCache, 'deleteMatching');
sinon.spy(pendingRequestStore, 'resolve');
sinon.spy(pendingRequestStore, 'resolveMatching');
});
afterEach(() => {
sinon.restore();
});
it('calls delete on the ajaxCache and calls resolve on the pendingRequestStore', () => {
// Arrange
const requestId = 'request-id';
// Act
invalidateMatchingCache(requestId, {});
// Assert
expect(ajaxCache.delete).to.have.been.calledOnce;
expect(pendingRequestStore.resolve.calledOnce).to.be.true;
expect(ajaxCache.delete.calledWith(requestId)).to.be.true;
expect(pendingRequestStore.resolve.calledWith(requestId)).to.be.true;
});
it('calls invalidateMatching for all URL items in the invalidateUrls argument', () => {
// Arrange
const requestId = 'request-id';
const invalidateUrls = ['https://f00.bar/'];
// Act
invalidateMatchingCache(requestId, { invalidateUrls });
// Assert
expect(ajaxCache.delete.calledTwice).to.be.true;
expect(pendingRequestStore.resolve.calledTwice).to.be.true;
expect(ajaxCache.delete.calledWith(requestId)).to.be.true;
expect(pendingRequestStore.resolve.calledWith(requestId)).to.be.true;
expect(ajaxCache.delete.calledWith('https://f00.bar/')).to.be.true;
expect(pendingRequestStore.resolve.calledWith('https://f00.bar/')).to.be.true;
});
it('calls invalidateMatching when the invalidateUrlsRegex argument is passed', () => {
// Arrange
const requestId = 'request-id';
const invalidateUrlsRegex = 'f00';
// Act
invalidateMatchingCache(requestId, { invalidateUrlsRegex });
// Assert
expect(ajaxCache.delete.calledOnce).to.be.true;
expect(ajaxCache.deleteMatching.calledOnce).to.be.true;
expect(pendingRequestStore.resolve.calledOnce).to.be.true;
expect(pendingRequestStore.resolveMatching.calledOnce).to.be.true;
expect(ajaxCache.delete.calledWith(requestId)).to.be.true;
expect(pendingRequestStore.resolve.calledWith(requestId)).to.be.true;
expect(ajaxCache.deleteMatching.calledWith('f00')).to.be.true;
expect(pendingRequestStore.resolveMatching.calledWith('f00')).to.be.true;
});
});
describe('isCurrentSessionId', () => {
it('returns true for the current session id', () => {
resetCacheSession('the-id');
expect(isCurrentSessionId('the-id')).to.equal(true);
});
it('returns true for the current session id', () => {
resetCacheSession('the-id');
expect(isCurrentSessionId('a-different-id')).to.equal(false);
});
});
});

View file

@ -1,586 +0,0 @@
import { aTimeout, expect } from '@open-wc/testing';
import { spy, stub, useFakeTimers } from 'sinon';
import '../src/typedef.js';
import { acceptLanguageRequestInterceptor } from '../src/interceptors/acceptLanguageHeader.js';
import { createXsrfRequestInterceptor, getCookie } from '../src/interceptors/xsrfHeader.js';
import { createCacheInterceptors } from '../src/interceptors/cacheInterceptors.js';
import { Ajax } from '../index.js';
const ajax = new Ajax();
describe('interceptors', () => {
describe('getCookie()', () => {
it('returns the cookie value', () => {
expect(getCookie('foo', { cookie: 'foo=bar' })).to.equal('bar');
});
it('returns the cookie value when there are multiple cookies', () => {
expect(getCookie('foo', { cookie: 'foo=bar; bar=foo;lorem=ipsum' })).to.equal('bar');
});
it('returns null when the cookie cannot be found', () => {
expect(getCookie('foo', { cookie: 'bar=foo;lorem=ipsum' })).to.equal(null);
});
it('decodes the cookie vaue', () => {
expect(getCookie('foo', { cookie: `foo=${decodeURIComponent('/foo/ bar "')}` })).to.equal(
'/foo/ bar "',
);
});
});
describe('acceptLanguageRequestInterceptor()', () => {
it('adds the locale as accept-language header', () => {
const request = new Request('/foo/');
acceptLanguageRequestInterceptor(request);
expect(request.headers.get('accept-language')).to.equal('en');
});
it('does not change an existing accept-language header', () => {
const request = new Request('/foo/', { headers: { 'accept-language': 'my-accept' } });
acceptLanguageRequestInterceptor(request);
expect(request.headers.get('accept-language')).to.equal('my-accept');
});
});
describe('createXsrfRequestInterceptor()', () => {
it('adds the xsrf token header to the request', () => {
const interceptor = createXsrfRequestInterceptor('XSRF-TOKEN', 'X-XSRF-TOKEN', {
cookie: 'XSRF-TOKEN=foo',
});
const request = new Request('/foo/');
interceptor(request);
expect(request.headers.get('X-XSRF-TOKEN')).to.equal('foo');
});
it('does not set anything if the cookie is not there', () => {
const interceptor = createXsrfRequestInterceptor('XSRF-TOKEN', 'X-XSRF-TOKEN', {
cookie: 'XXSRF-TOKEN=foo',
});
const request = new Request('/foo/');
interceptor(request);
expect(request.headers.get('X-XSRF-TOKEN')).to.equal(null);
});
});
describe('cache interceptors', () => {
/** @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 [cacheRequestInterceptor, cacheResponseInterceptor] = createCacheInterceptors(
getCacheIdentifier,
options,
);
const requestInterceptorIndex =
ajaxInstance._requestInterceptors.push(
/** @type {RequestInterceptor} */ (cacheRequestInterceptor),
) - 1;
const responseInterceptorIndex =
ajaxInstance._responseInterceptors.push(
/** @type {ResponseInterceptor} */ (cacheResponseInterceptor),
) - 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', async () => {
await ajax.fetch('/test');
expect(fetchStub.callCount).to.equal(1);
await ajax.fetch('/test');
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.fetch('/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`/);
});
});
describe('Cached responses', () => {
it('returns the cached object on second call with `useCache: true`', async () => {
newCacheId();
const indexes = addCacheInterceptors(ajax, {
useCache: true,
timeToLive: 100,
});
const ajaxRequestSpy = spy(ajax, 'fetch');
await ajax.fetch('/test');
expect(ajaxRequestSpy.calledOnce).to.be.true;
expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
await ajax.fetch('/test');
expect(fetchStub.callCount).to.equal(1);
ajaxRequestSpy.restore();
removeCacheInterceptors(ajax, indexes);
});
it('all calls with non-default `timeToLive` are cached proactively', async () => {
newCacheId();
const indexes = addCacheInterceptors(ajax, {
useCache: false,
timeToLive: 100,
});
const ajaxRequestSpy = spy(ajax, 'fetch');
await ajax.fetch('/test');
expect(ajaxRequestSpy.calledOnce).to.be.true;
expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
expect(fetchStub.callCount).to.equal(1);
await ajax.fetch('/test');
expect(fetchStub.callCount).to.equal(2);
await ajax.fetch('/test', {
cacheOptions: {
useCache: true,
},
});
expect(fetchStub.callCount).to.equal(2);
ajaxRequestSpy.restore();
removeCacheInterceptors(ajax, indexes);
});
it('returns the cached object on second call with `useCache: true`, with querystring parameters', async () => {
newCacheId();
const indexes = addCacheInterceptors(ajax, {
useCache: true,
timeToLive: 100,
});
const ajaxRequestSpy = spy(ajax, 'fetch');
await ajax.fetch('/test', {
params: {
q: 'test',
page: 1,
},
});
expect(ajaxRequestSpy.calledOnce).to.be.true;
expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
await ajax.fetch('/test', {
params: {
q: 'test',
page: 1,
},
});
expect(fetchStub.callCount).to.equal(1);
// a request with different param should not be cached
await ajax.fetch('/test', {
params: {
q: 'test',
page: 2,
},
});
expect(fetchStub.callCount).to.equal(2);
ajaxRequestSpy.restore();
removeCacheInterceptors(ajax, indexes);
});
it('uses cache when inside `timeToLive: 5000` window', async () => {
newCacheId();
const clock = useFakeTimers({
shouldAdvanceTime: true,
});
const indexes = addCacheInterceptors(ajax, {
useCache: true,
timeToLive: 5000,
});
const ajaxRequestSpy = spy(ajax, 'fetch');
await ajax.fetch('/test');
expect(ajaxRequestSpy.calledOnce).to.be.true;
expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
expect(fetchStub.callCount).to.equal(1);
clock.tick(4900);
await ajax.fetch('/test');
expect(fetchStub.callCount).to.equal(1);
clock.tick(5100);
await ajax.fetch('/test');
expect(fetchStub.callCount).to.equal(2);
ajaxRequestSpy.restore();
clock.restore();
removeCacheInterceptors(ajax, indexes);
});
it('uses custom requestIdentificationFn when passed', async () => {
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,
});
await ajax.fetch('/test', { headers: { 'x-id': '1' } });
expect(reqIdSpy.calledOnce);
expect(reqIdSpy.returnValues[0]).to.equal(`/test-1`);
removeCacheInterceptors(ajax, indexes);
});
});
describe('Cache invalidation', () => {
it('previously cached data has to be invalidated when regex invalidation rule triggered', async () => {
newCacheId();
const indexes = addCacheInterceptors(ajax, {
useCache: true,
timeToLive: 1000,
invalidateUrlsRegex: /foo/gi,
});
await ajax.fetch('/test'); // new url
expect(fetchStub.callCount).to.equal(1);
await ajax.fetch('/test'); // cached
expect(fetchStub.callCount).to.equal(1);
await ajax.fetch('/foo-request-1'); // new url
expect(fetchStub.callCount).to.equal(2);
await ajax.fetch('/foo-request-1'); // cached
expect(fetchStub.callCount).to.equal(2);
await ajax.fetch('/foo-request-3'); // new url
expect(fetchStub.callCount).to.equal(3);
await ajax.fetch('/test', { method: 'POST' }); // clear cache
expect(fetchStub.callCount).to.equal(4);
await ajax.fetch('/foo-request-1'); // not cached anymore
expect(fetchStub.callCount).to.equal(5);
await ajax.fetch('/foo-request-2'); // not cached anymore
expect(fetchStub.callCount).to.equal(6);
removeCacheInterceptors(ajax, indexes);
});
it('previously cached data has to be invalidated when regex invalidation rule triggered and urls are nested', async () => {
newCacheId();
const indexes = addCacheInterceptors(ajax, {
useCache: true,
timeToLive: 1000,
invalidateUrlsRegex: /posts/gi,
});
await ajax.fetch('/test');
await ajax.fetch('/test'); // cached
expect(fetchStub.callCount).to.equal(1);
await ajax.fetch('/posts');
expect(fetchStub.callCount).to.equal(2);
await ajax.fetch('/posts'); // cached
expect(fetchStub.callCount).to.equal(2);
await ajax.fetch('/posts/1');
expect(fetchStub.callCount).to.equal(3);
await ajax.fetch('/posts/1'); // cached
expect(fetchStub.callCount).to.equal(3);
// cleans cache for defined urls
await ajax.fetch('/test', { method: 'POST' });
expect(fetchStub.callCount).to.equal(4);
await ajax.fetch('/posts'); // no longer cached => new request
expect(fetchStub.callCount).to.equal(5);
await ajax.fetch('/posts/1'); // no longer cached => new request
expect(fetchStub.callCount).to.equal(6);
removeCacheInterceptors(ajax, indexes);
});
it('deletes cache after one hour', async () => {
newCacheId();
const clock = useFakeTimers({
shouldAdvanceTime: true,
});
const ajaxRequestSpy = spy(ajax, 'fetch');
const indexes = addCacheInterceptors(ajax, {
useCache: true,
timeToLive: 1000 * 60 * 60,
});
await ajax.fetch('/test-hour');
expect(ajaxRequestSpy.calledOnce).to.be.true;
expect(ajaxRequestSpy.calledWith('/test-hour')).to.be.true;
expect(fetchStub.callCount).to.equal(1);
clock.tick(1000 * 60 * 59); // 0:59 hour
await ajax.fetch('/test-hour');
expect(fetchStub.callCount).to.equal(1);
clock.tick(1000 * 60 * 2); // +2 minutes => 1:01 hour
await ajax.fetch('/test-hour');
expect(fetchStub.callCount).to.equal(2);
ajaxRequestSpy.restore();
clock.restore();
removeCacheInterceptors(ajax, indexes);
});
it('invalidates invalidateUrls endpoints', async () => {
newCacheId();
const indexes = addCacheInterceptors(ajax, {
useCache: true,
timeToLive: 500,
});
const actionConfig = {
cacheOptions: {
invalidateUrls: ['/test-invalid-url'],
},
};
await ajax.fetch('/test-valid-url', { ...actionConfig });
expect(fetchStub.callCount).to.equal(1);
await ajax.fetch('/test-invalid-url');
expect(fetchStub.callCount).to.equal(2);
// 'post' will invalidate 'own' cache and the one mentioned in config
await ajax.fetch('/test-valid-url', { ...actionConfig, method: 'POST' });
expect(fetchStub.callCount).to.equal(3);
await ajax.fetch('/test-invalid-url');
// indicates that 'test-invalid-url' cache was removed
// because the server registered new request
expect(fetchStub.callCount).to.equal(4);
removeCacheInterceptors(ajax, indexes);
});
it('invalidates cache on a post', async () => {
newCacheId();
const indexes = addCacheInterceptors(ajax, {
useCache: true,
timeToLive: 100,
});
const ajaxRequestSpy = spy(ajax, 'fetch');
await ajax.fetch('/test-post');
expect(ajaxRequestSpy.calledOnce).to.be.true;
expect(ajaxRequestSpy.calledWith('/test-post')).to.be.true;
expect(fetchStub.callCount).to.equal(1);
await ajax.fetch('/test-post', { method: 'POST', body: 'data-post' });
expect(ajaxRequestSpy.calledTwice).to.be.true;
expect(ajaxRequestSpy.calledWith('/test-post')).to.be.true;
expect(fetchStub.callCount).to.equal(2);
await ajax.fetch('/test-post');
expect(fetchStub.callCount).to.equal(3);
ajaxRequestSpy.restore();
removeCacheInterceptors(ajax, indexes);
});
it('caches response but does not return it when expiration time is 0', async () => {
newCacheId();
const indexes = addCacheInterceptors(ajax, {
useCache: true,
timeToLive: 0,
});
const ajaxRequestSpy = spy(ajax, 'fetch');
await ajax.fetch('/test');
const clock = useFakeTimers();
expect(ajaxRequestSpy.calledOnce).to.be.true;
expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
clock.tick(1);
clock.restore();
await ajax.fetch('/test');
expect(fetchStub.callCount).to.equal(2);
ajaxRequestSpy.restore();
removeCacheInterceptors(ajax, indexes);
});
it('does not use cache when `useCache: false` in the action', async () => {
newCacheId();
getCacheIdentifier = () => 'cacheIdentifier2';
const ajaxAlwaysRequestSpy = spy(ajax, 'fetch');
const indexes = addCacheInterceptors(ajax, { useCache: true });
await ajax.fetch('/test');
expect(ajaxAlwaysRequestSpy.calledOnce, 'calledOnce').to.be.true;
expect(ajaxAlwaysRequestSpy.calledWith('/test'));
await ajax.fetch('/test', { cacheOptions: { useCache: false } });
expect(fetchStub.callCount).to.equal(2);
ajaxAlwaysRequestSpy.restore();
removeCacheInterceptors(ajax, indexes);
});
it('caches concurrent requests', async () => {
newCacheId();
let i = 0;
fetchStub.returns(
new Promise(resolve => {
i += 1;
setTimeout(() => {
resolve(new Response(`mock response ${i}`));
}, 5);
}),
);
const indexes = addCacheInterceptors(ajax, {
useCache: true,
timeToLive: 100,
});
const ajaxRequestSpy = spy(ajax, 'fetch');
const request1 = ajax.fetch('/test');
const request2 = ajax.fetch('/test');
await aTimeout(1);
const request3 = ajax.fetch('/test');
await aTimeout(3);
const request4 = ajax.fetch('/test');
const responses = await Promise.all([request1, request2, request3, request4]);
expect(fetchStub.callCount).to.equal(1);
const responseTexts = await Promise.all(responses.map(r => r.text()));
expect(responseTexts).to.eql([
'mock response 1',
'mock response 1',
'mock response 1',
'mock response 1',
]);
ajaxRequestSpy.restore();
removeCacheInterceptors(ajax, indexes);
});
it('preserves status and headers when returning cached response', async () => {
newCacheId();
fetchStub.returns(
Promise.resolve(
new Response('mock response', { status: 206, headers: { 'x-foo': 'x-bar' } }),
),
);
const indexes = addCacheInterceptors(ajax, {
useCache: true,
timeToLive: 100,
});
const ajaxRequestSpy = spy(ajax, 'fetch');
const response1 = await ajax.fetch('/test');
const response2 = await ajax.fetch('/test');
expect(fetchStub.callCount).to.equal(1);
expect(response1.status).to.equal(206);
expect(response1.headers.get('x-foo')).to.equal('x-bar');
expect(response2.status).to.equal(206);
expect(response2.headers.get('x-foo')).to.equal('x-bar');
ajaxRequestSpy.restore();
removeCacheInterceptors(ajax, indexes);
});
});
});
});

View file

@ -0,0 +1,16 @@
import { expect } from '@open-wc/testing';
import { acceptLanguageRequestInterceptor } from '../../src/interceptors/acceptLanguageHeader.js';
describe('acceptLanguageRequestInterceptor()', () => {
it('adds the locale as accept-language header', () => {
const request = new Request('/foo/');
acceptLanguageRequestInterceptor(request);
expect(request.headers.get('accept-language')).to.equal('en');
});
it('does not change an existing accept-language header', () => {
const request = new Request('/foo/', { headers: { 'accept-language': 'my-accept' } });
acceptLanguageRequestInterceptor(request);
expect(request.headers.get('accept-language')).to.equal('my-accept');
});
});

View file

@ -0,0 +1,595 @@
import { expect } from '@open-wc/testing';
import * as sinon from 'sinon';
import '../../src/typedef.js';
import { Ajax } from '../../index.js';
import { extendCacheOptions, resetCacheSession, ajaxCache } from '../../src/cacheManager.js';
import { createCacheInterceptors } from '../../src/interceptors/cacheInterceptors.js';
/** @type {Ajax} */
let ajax;
describe('cache interceptors', () => {
/**
* @param {number | undefined} timeout
* @param {number} i
*/
const returnResponseOnTick = (timeout, i) =>
new Promise(resolve =>
window.setTimeout(() => resolve(new Response(`mock response ${i}`)), timeout),
);
/** @type {number | undefined} */
let cacheId;
/** @type {sinon.SinonStub} */
let fetchStub;
const getCacheIdentifier = () => String(cacheId);
/** @type {sinon.SinonSpy} */
let ajaxRequestSpy;
const newCacheId = () => {
if (!cacheId) {
cacheId = 1;
} else {
cacheId += 1;
}
return cacheId;
};
/**
* @param {Ajax} ajaxInstance
* @param {CacheOptions} options
*/
const addCacheInterceptors = (ajaxInstance, options) => {
const { cacheRequestInterceptor, cacheResponseInterceptor } = createCacheInterceptors(
getCacheIdentifier,
options,
);
ajaxInstance._requestInterceptors.push(cacheRequestInterceptor);
ajaxInstance._responseInterceptors.push(cacheResponseInterceptor);
};
beforeEach(() => {
ajax = new Ajax();
fetchStub = sinon.stub(window, 'fetch');
fetchStub.returns(Promise.resolve(new Response('mock response')));
ajaxRequestSpy = sinon.spy(ajax, 'fetch');
});
afterEach(() => {
sinon.restore();
});
describe('Original ajax instance', () => {
it('allows direct ajax calls without cache interceptors configured', async () => {
await ajax.fetch('/test');
expect(fetchStub.callCount).to.equal(1);
await ajax.fetch('/test');
expect(fetchStub.callCount).to.equal(2);
});
});
describe('Cache config validation', () => {
it('validates `useCache`', () => {
newCacheId();
const test = () => {
addCacheInterceptors(ajax, {
// @ts-ignore needed for test
useCache: 'fakeUseCacheType',
});
};
expect(test).to.throw();
});
it('validates property `maxAge` throws if not type `number`', () => {
newCacheId();
expect(() => {
addCacheInterceptors(ajax, {
useCache: true,
// @ts-ignore needed for test
maxAge: '',
});
}).to.throw();
});
it('validates cache identifier function', async () => {
const cacheSessionId = cacheId;
// @ts-ignore needed for test
cacheId = '';
addCacheInterceptors(ajax, { useCache: true });
await ajax
.fetch('/test')
.then(() => expect.fail('fetch should not resolve here'))
.catch(
/** @param {Error} err */ err => {
expect(err.message).to.equal('Invalid cache identifier');
},
)
.finally(() => {});
cacheId = cacheSessionId;
});
it("throws when using methods other than `['get']`", () => {
newCacheId();
expect(() => {
addCacheInterceptors(ajax, {
useCache: true,
methods: ['get', 'post'],
});
}).to.throw(/Cache can only be utilized with `GET` method/);
});
it('throws error when requestIdFunction is not a function', () => {
newCacheId();
expect(() => {
addCacheInterceptors(ajax, {
useCache: true,
// @ts-ignore needed for test
requestIdFunction: 'not a function',
});
}).to.throw(/Property `requestIdFunction` must be a `function`/);
});
});
describe('Cached responses', () => {
it('returns the cached object on second call with `useCache: true`', async () => {
newCacheId();
addCacheInterceptors(ajax, {
useCache: true,
maxAge: 100,
});
await ajax.fetch('/test');
expect(ajaxRequestSpy.calledOnce).to.be.true;
expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
await ajax.fetch('/test');
expect(fetchStub.callCount).to.equal(1);
});
// TODO: Check if this is the behaviour we want
it('all calls with non-default `maxAge` are cached proactively', async () => {
// Given
newCacheId();
addCacheInterceptors(ajax, {
useCache: false,
maxAge: 100,
});
// When
await ajax.fetch('/test');
// Then
expect(ajaxRequestSpy.calledOnce).to.be.true;
expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
expect(fetchStub.callCount).to.equal(1);
// When
await ajax.fetch('/test', {
cacheOptions: {
useCache: true,
},
});
// Then
expect(fetchStub.callCount).to.equal(2);
// When
await ajax.fetch('/test', {
cacheOptions: {
useCache: true,
},
});
// Then
expect(fetchStub.callCount).to.equal(2);
});
it('returns the cached object on second call with `useCache: true`, with querystring parameters', async () => {
// Given
newCacheId();
addCacheInterceptors(ajax, {
useCache: true,
maxAge: 100,
});
// When
await ajax.fetch('/test', {
params: {
q: 'test',
page: 1,
},
});
// Then
expect(ajaxRequestSpy.calledOnce).to.be.true;
expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
expect(fetchStub.callCount).to.equal(1);
// When
await ajax.fetch('/test', {
params: {
q: 'test',
page: 1,
},
});
// Then
expect(fetchStub.callCount).to.equal(1);
// a request with different param should not be cached
// When
await ajax.fetch('/test', {
params: {
q: 'test',
page: 2,
},
});
// Then
expect(fetchStub.callCount).to.equal(2);
});
it('uses cache when inside `maxAge: 5000` window', async () => {
newCacheId();
const clock = sinon.useFakeTimers({
shouldAdvanceTime: true,
});
addCacheInterceptors(ajax, {
useCache: true,
maxAge: 5000,
});
await ajax.fetch('/test');
expect(ajaxRequestSpy.calledOnce).to.be.true;
expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
expect(fetchStub.callCount).to.equal(1);
clock.tick(4900);
await ajax.fetch('/test');
expect(fetchStub.callCount).to.equal(1);
clock.tick(5100);
await ajax.fetch('/test');
expect(fetchStub.callCount).to.equal(2);
clock.restore();
});
it('uses custom requestIdFunction when passed', async () => {
newCacheId();
const customRequestIdFn = /** @type {RequestIdFunction} */ (request, serializer) => {
let serializedRequestParams = '';
if (request.params) {
// @ts-ignore assume serializer is defined
serializedRequestParams = `?${serializer(request.params)}`;
}
return `${new URL(/** @type {string} */ (request.url)).pathname}-${request.headers?.get(
'x-id',
)}${serializedRequestParams}`;
};
const reqIdSpy = sinon.spy(customRequestIdFn);
addCacheInterceptors(ajax, {
useCache: true,
requestIdFunction: reqIdSpy,
});
await ajax.fetch('/test', { headers: { 'x-id': '1' } });
expect(reqIdSpy.calledOnce);
expect(reqIdSpy.returnValues[0]).to.equal(`/test-1`);
});
it('throws when the request object is missing from the response', async () => {
const { cacheResponseInterceptor } = createCacheInterceptors(() => 'cache-id', {});
// @ts-ignore not an actual valid CacheResponse object
await cacheResponseInterceptor({})
.then(() => expect.fail('cacheResponseInterceptor should not resolve here'))
.catch(
/** @param {Error} err */ err => {
expect(err.message).to.equal('Missing request in response');
},
);
// @ts-ignore not an actual valid CacheResponse object
await cacheResponseInterceptor({ request: { method: 'get' } })
.then(() => expect('everything').to.be.ok)
.catch(err =>
expect.fail(
`cacheResponseInterceptor should resolve here, but threw an error: ${err.message}`,
),
);
});
});
describe('Cache invalidation', () => {
it('previously cached data has to be invalidated when regex invalidation rule triggered', async () => {
newCacheId();
addCacheInterceptors(ajax, {
useCache: true,
maxAge: 1000,
invalidateUrlsRegex: /foo/gi,
});
await ajax.fetch('/test'); // new url
expect(fetchStub.callCount).to.equal(1);
await ajax.fetch('/test'); // cached
expect(fetchStub.callCount).to.equal(1);
await ajax.fetch('/foo-request-1'); // new url
expect(fetchStub.callCount).to.equal(2);
await ajax.fetch('/foo-request-1'); // cached
expect(fetchStub.callCount).to.equal(2);
await ajax.fetch('/foo-request-3'); // new url
expect(fetchStub.callCount).to.equal(3);
await ajax.fetch('/test', { method: 'POST' }); // clear cache
expect(fetchStub.callCount).to.equal(4);
await ajax.fetch('/foo-request-1'); // not cached anymore
expect(fetchStub.callCount).to.equal(5);
await ajax.fetch('/foo-request-2'); // not cached anymore
expect(fetchStub.callCount).to.equal(6);
});
it('previously cached data has to be invalidated when regex invalidation rule triggered and urls are nested', async () => {
newCacheId();
addCacheInterceptors(ajax, {
useCache: true,
maxAge: 1000,
invalidateUrlsRegex: /posts/gi,
});
await ajax.fetch('/test');
await ajax.fetch('/test'); // cached
expect(fetchStub.callCount).to.equal(1);
await ajax.fetch('/posts');
expect(fetchStub.callCount).to.equal(2);
await ajax.fetch('/posts'); // cached
expect(fetchStub.callCount).to.equal(2);
await ajax.fetch('/posts/1');
expect(fetchStub.callCount).to.equal(3);
await ajax.fetch('/posts/1'); // cached
expect(fetchStub.callCount).to.equal(3);
// cleans cache for defined urls
await ajax.fetch('/test', { method: 'POST' });
expect(fetchStub.callCount).to.equal(4);
await ajax.fetch('/posts'); // no longer cached => new request
expect(fetchStub.callCount).to.equal(5);
await ajax.fetch('/posts/1'); // no longer cached => new request
expect(fetchStub.callCount).to.equal(6);
});
it('deletes cache after one hour', async () => {
newCacheId();
const clock = sinon.useFakeTimers({
shouldAdvanceTime: true,
});
addCacheInterceptors(ajax, {
useCache: true,
maxAge: 1000 * 60 * 60,
});
await ajax.fetch('/test-hour');
expect(ajaxRequestSpy.calledOnce).to.be.true;
expect(ajaxRequestSpy.calledWith('/test-hour')).to.be.true;
expect(fetchStub.callCount).to.equal(1);
clock.tick(1000 * 60 * 59); // 0:59 hour
await ajax.fetch('/test-hour');
expect(fetchStub.callCount).to.equal(1);
clock.tick(1000 * 60 * 2); // +2 minutes => 1:01 hour
await ajax.fetch('/test-hour');
expect(fetchStub.callCount).to.equal(2);
clock.restore();
});
it('invalidates invalidateUrls endpoints', async () => {
const { requestIdFunction } = extendCacheOptions({});
newCacheId();
addCacheInterceptors(ajax, {
useCache: true,
maxAge: 500,
});
const cacheOptions = {
invalidateUrls: [
requestIdFunction({
url: new URL('/test-invalid-url', window.location.href).toString(),
params: { foo: 1, bar: 2 },
}),
],
};
await ajax.fetch('/test-valid-url', { cacheOptions });
expect(fetchStub.callCount).to.equal(1);
await ajax.fetch('/test-invalid-url?foo=1&bar=2');
expect(fetchStub.callCount).to.equal(2);
await ajax.fetch('/test-invalid-url?foo=1&bar=2');
expect(fetchStub.callCount).to.equal(2);
// 'post' will invalidate 'own' cache and the one mentioned in config
await ajax.fetch('/test-valid-url', { cacheOptions, method: 'POST' });
expect(fetchStub.callCount).to.equal(3);
await ajax.fetch('/test-invalid-url?foo=1&bar=2');
// indicates that 'test-invalid-url' cache was removed
// because the server registered new request
expect(fetchStub.callCount).to.equal(4);
});
it('invalidates cache on a post', async () => {
newCacheId();
addCacheInterceptors(ajax, {
useCache: true,
maxAge: 100,
});
await ajax.fetch('/test-post');
expect(ajaxRequestSpy.calledOnce).to.be.true;
expect(ajaxRequestSpy.calledWith('/test-post')).to.be.true;
expect(fetchStub.callCount).to.equal(1);
await ajax.fetch('/test-post', { method: 'POST', body: 'data-post' });
expect(ajaxRequestSpy.calledTwice).to.be.true;
expect(ajaxRequestSpy.calledWith('/test-post')).to.be.true;
expect(fetchStub.callCount).to.equal(2);
await ajax.fetch('/test-post');
expect(fetchStub.callCount).to.equal(3);
});
it('caches response but does not return it when expiration time is 0', async () => {
newCacheId();
addCacheInterceptors(ajax, {
useCache: true,
maxAge: 0,
});
const clock = sinon.useFakeTimers();
await ajax.fetch('/test');
expect(ajaxRequestSpy.calledOnce).to.be.true;
expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
clock.tick(1);
await ajax.fetch('/test');
clock.restore();
expect(fetchStub.callCount).to.equal(2);
});
it('does not use cache when cacheOption `useCache: false` is passed to fetch method', async () => {
// Given
addCacheInterceptors(ajax, { useCache: true });
// When
await ajax.fetch('/test');
await ajax.fetch('/test');
// Then
expect(fetchStub.callCount).to.equal(1);
// When
await ajax.fetch('/test', { cacheOptions: { useCache: false } });
// Then
expect(fetchStub.callCount).to.equal(2);
});
it('caches concurrent requests', async () => {
newCacheId();
const clock = sinon.useFakeTimers();
fetchStub.onFirstCall().returns(returnResponseOnTick(900, 1));
fetchStub.onSecondCall().returns(returnResponseOnTick(1900, 2));
addCacheInterceptors(ajax, {
useCache: true,
maxAge: 750,
});
const firstRequest = ajax.fetch('/test').then(r => r.text());
const concurrentFirstRequest1 = ajax.fetch('/test').then(r => r.text());
const concurrentFirstRequest2 = ajax.fetch('/test').then(r => r.text());
clock.tick(1000);
// firstRequest is cached at tick 1000 in the next line!
const firstResponses = await Promise.all([
firstRequest,
concurrentFirstRequest1,
concurrentFirstRequest2,
]);
expect(fetchStub.callCount).to.equal(1);
const cachedFirstRequest = ajax.fetch('/test').then(r => r.text());
clock.tick(500);
const cachedFirstResponse = await cachedFirstRequest;
expect(fetchStub.callCount).to.equal(1);
const secondRequest = ajax.fetch('/test').then(r => r.text());
const secondConcurrentRequest = ajax.fetch('/test').then(r => r.text());
clock.tick(1000);
const secondResponses = await Promise.all([secondRequest, secondConcurrentRequest]);
expect(fetchStub.callCount).to.equal(2);
expect(firstResponses).to.eql(['mock response 1', 'mock response 1', 'mock response 1']);
expect(cachedFirstResponse).to.equal('mock response 1');
expect(secondResponses).to.eql(['mock response 2', 'mock response 2']);
});
it('discards responses that are requested in a different cache session', async () => {
newCacheId();
addCacheInterceptors(ajax, {
useCache: true,
maxAge: 10000,
});
// Switch the cache after the cache request interceptor, but before the fetch
// @ts-ignore
ajax._requestInterceptors.push(async request => {
newCacheId();
resetCacheSession(getCacheIdentifier());
return request;
});
const firstRequest = ajax.fetch('/test').then(r => r.text());
const firstResponse = await firstRequest;
expect(firstResponse).to.equal('mock response');
// @ts-ignore
expect(ajaxCache._cachedRequests).to.deep.equal({});
expect(fetchStub.callCount).to.equal(1);
});
it('preserves status and headers when returning cached response', async () => {
newCacheId();
fetchStub.returns(
Promise.resolve(
new Response('mock response', { status: 206, headers: { 'x-foo': 'x-bar' } }),
),
);
addCacheInterceptors(ajax, {
useCache: true,
maxAge: 100,
});
const response1 = await ajax.fetch('/test');
const response2 = await ajax.fetch('/test');
expect(fetchStub.callCount).to.equal(1);
expect(response1.status).to.equal(206);
expect(response1.headers.get('x-foo')).to.equal('x-bar');
expect(response2.status).to.equal(206);
expect(response2.headers.get('x-foo')).to.equal('x-bar');
});
});
});

View file

@ -0,0 +1,16 @@
import { expect } from '@open-wc/testing';
import * as interceptors from '../../src/interceptors/index.js';
describe('interceptors interface', () => {
it('exposes the acceptLanguageRequestInterceptor function', () => {
expect(interceptors.acceptLanguageRequestInterceptor).to.be.a('Function');
});
it('exposes the createXsrfRequestInterceptor function', () => {
expect(interceptors.createXsrfRequestInterceptor).to.be.a('Function');
});
it('exposes the createCacheInterceptors function', () => {
expect(interceptors.createCacheInterceptors).to.be.a('Function');
});
});

View file

@ -0,0 +1,42 @@
import { expect } from '@open-wc/testing';
import { createXsrfRequestInterceptor, getCookie } from '../../src/interceptors/xsrfHeader.js';
describe('getCookie()', () => {
it('returns the cookie value', () => {
expect(getCookie('foo', { cookie: 'foo=bar' })).to.equal('bar');
});
it('returns the cookie value when there are multiple cookies', () => {
expect(getCookie('foo', { cookie: 'foo=bar; bar=foo;lorem=ipsum' })).to.equal('bar');
});
it('returns null when the cookie cannot be found', () => {
expect(getCookie('foo', { cookie: 'bar=foo;lorem=ipsum' })).to.equal(null);
});
it('decodes the cookie vaue', () => {
expect(getCookie('foo', { cookie: `foo=${decodeURIComponent('/foo/ bar "')}` })).to.equal(
'/foo/ bar "',
);
});
});
describe('createXsrfRequestInterceptor()', () => {
it('adds the xsrf token header to the request', () => {
const interceptor = createXsrfRequestInterceptor('XSRF-TOKEN', 'X-XSRF-TOKEN', {
cookie: 'XSRF-TOKEN=foo',
});
const request = new Request('/foo/');
interceptor(request);
expect(request.headers.get('X-XSRF-TOKEN')).to.equal('foo');
});
it('does not set anything if the cookie is not there', () => {
const interceptor = createXsrfRequestInterceptor('XSRF-TOKEN', 'X-XSRF-TOKEN', {
cookie: 'XXSRF-TOKEN=foo',
});
const request = new Request('/foo/');
interceptor(request);
expect(request.headers.get('X-XSRF-TOKEN')).to.equal(null);
});
});

View file

@ -27,18 +27,18 @@ export interface CacheConfig {
export type Params = { [key: string]: any }; export type Params = { [key: string]: any };
export type RequestIdentificationFn = ( export type RequestIdFunction = (
request: Partial<CacheRequest>, request: Partial<CacheRequest>,
stringifySearchParams: (params: Params) => string, serializeSearchParams?: (params: Params) => string,
) => string; ) => string;
export interface CacheOptions { export interface CacheOptions {
useCache?: boolean; useCache?: boolean;
methods?: string[]; methods?: string[];
timeToLive?: number; maxAge?: number;
invalidateUrls?: string[]; invalidateUrls?: string[];
invalidateUrlsRegex?: RegExp; invalidateUrlsRegex?: RegExp;
requestIdentificationFn?: RequestIdentificationFn; requestIdFunction?: RequestIdFunction;
} }
export interface CacheOptionsWithIdentifier extends CacheOptions { export interface CacheOptionsWithIdentifier extends CacheOptions {
@ -48,11 +48,12 @@ export interface CacheOptionsWithIdentifier extends CacheOptions {
export interface ValidatedCacheOptions extends CacheOptions { export interface ValidatedCacheOptions extends CacheOptions {
useCache: boolean; useCache: boolean;
methods: string[]; methods: string[];
timeToLive: number; maxAge: number;
requestIdentificationFn: RequestIdentificationFn; requestIdFunction: RequestIdFunction;
} }
export interface CacheRequestExtension { export interface CacheRequestExtension {
cacheSessionId?: string;
cacheOptions?: CacheOptions; cacheOptions?: CacheOptions;
adapter: any; adapter: any;
status: number; status: number;
@ -61,6 +62,7 @@ export interface CacheRequestExtension {
} }
export interface CacheResponseRequest { export interface CacheResponseRequest {
cacheSessionId?: string;
cacheOptions?: CacheOptions; cacheOptions?: CacheOptions;
method: string; method: string;
} }

View file

@ -39,10 +39,8 @@ export default {
playwrightLauncher({ product: 'chromium' }), playwrightLauncher({ product: 'chromium' }),
playwrightLauncher({ product: 'webkit' }), playwrightLauncher({ product: 'webkit' }),
], ],
groups: packages.map(pkg => { groups: packages.map(pkg => ({
return { name: pkg,
name: pkg, files: `packages/${pkg}/test/**/*.test.js`,
files: `packages/${pkg}/test/**/*.test.js`, })),
};
}),
}; };