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:
parent
dec9c7555a
commit
879598506a
22 changed files with 1768 additions and 959 deletions
5
.changeset/silly-lamps-flash.md
Normal file
5
.changeset/silly-lamps-flash.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@lion/ajax': patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Fix cache session race condition for in-flight requests
|
||||||
8
.changeset/tall-adults-act.md
Normal file
8
.changeset/tall-adults-act.md
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
---
|
||||||
|
'@lion/ajax': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
**BREAKING** public API changes:
|
||||||
|
|
||||||
|
- Changed `timeToLive` to `maxAge`
|
||||||
|
- Renamed `requestIdentificationFn` to `requestIdFunction`
|
||||||
|
|
@ -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 => {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
65
packages/ajax/src/Cache.js
Normal file
65
packages/ajax/src/Cache.js
Normal 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 = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
62
packages/ajax/src/PendingRequestStore.js
Normal file
62
packages/ajax/src/PendingRequestStore.js
Normal 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 = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
157
packages/ajax/src/cacheManager.js
Normal file
157
packages/ajax/src/cacheManager.js
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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 = pendingRequestStore.get(requestId);
|
||||||
}
|
|
||||||
|
|
||||||
const pendingRequest = currentCache.getPendingRequest(cacheId);
|
|
||||||
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) {
|
if (!isAlreadyFromCache && isCacheActive && isMethodSupported) {
|
||||||
// store the response data in the cache and mark request as resolved
|
if (isCurrentSessionId(response.request.cacheSessionId)) {
|
||||||
currentCache.set(cacheId, cacheResponse.clone());
|
// Cache the response
|
||||||
|
ajaxCache.set(requestId, response.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
currentCache.resolvePendingRequest(cacheId);
|
// Mark the pending request as resolved
|
||||||
return cacheResponse;
|
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];
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
196
packages/ajax/test/Cache.test.js
Normal file
196
packages/ajax/test/Cache.test.js
Normal 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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
159
packages/ajax/test/PendingRequestStore.test.js
Normal file
159
packages/ajax/test/PendingRequestStore.test.js
Normal 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;
|
||||||
|
});
|
||||||
|
});
|
||||||
313
packages/ajax/test/cacheManager.test.js
Normal file
313
packages/ajax/test/cacheManager.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
16
packages/ajax/test/interceptors/acceptLanguageHeader.test.js
Normal file
16
packages/ajax/test/interceptors/acceptLanguageHeader.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
595
packages/ajax/test/interceptors/cacheInterceptors.test.js
Normal file
595
packages/ajax/test/interceptors/cacheInterceptors.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
16
packages/ajax/test/interceptors/interceptors.test.js
Normal file
16
packages/ajax/test/interceptors/interceptors.test.js
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
42
packages/ajax/test/interceptors/xsrfHeader.test.js
Normal file
42
packages/ajax/test/interceptors/xsrfHeader.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
14
packages/ajax/types/types.d.ts
vendored
14
packages/ajax/types/types.d.ts
vendored
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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`,
|
||||||
};
|
})),
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue