Update public API of ajax & refactor package (#1371)
feat(ajax): align / break public API Co-authored-by: Ahmet Yesil <yesil.ahmet@ing.com>
This commit is contained in:
parent
a0ea1c4c5e
commit
73d4e222f6
19 changed files with 885 additions and 832 deletions
13
.changeset/rude-frogs-run.md
Normal file
13
.changeset/rude-frogs-run.md
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
---
|
||||||
|
'@lion/ajax': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
**BREAKING** public API changes:
|
||||||
|
|
||||||
|
- `AjaxClient` is now `Ajax`
|
||||||
|
- `AjaxClientFetchError` is now `AjaxFetchError`
|
||||||
|
- `request` and `requestJson` methods of `Ajax` class are renamed as `fetch` and `fetchJson` respectively
|
||||||
|
- `getCookie` and `validateOptions` is not part of the public API any more
|
||||||
|
- Removed the `setAjax`
|
||||||
|
- `createXSRFRequestInterceptor` renamed as `createXsrfRequestInterceptor`
|
||||||
|
- Exporting `createCacheInterceptors` instead of `cacheRequestInterceptorFactory` and `cacheResponseInterceptorFactory`
|
||||||
|
|
@ -3,12 +3,7 @@
|
||||||
```js script
|
```js script
|
||||||
import { html } from '@lion/core';
|
import { html } from '@lion/core';
|
||||||
import { renderLitAsNode } from '@lion/helpers';
|
import { renderLitAsNode } from '@lion/helpers';
|
||||||
import {
|
import { ajax, createCacheInterceptors } from '@lion/ajax';
|
||||||
ajax,
|
|
||||||
AjaxClient,
|
|
||||||
cacheRequestInterceptorFactory,
|
|
||||||
cacheResponseInterceptorFactory,
|
|
||||||
} from '@lion/ajax';
|
|
||||||
import '@lion/helpers/define';
|
import '@lion/helpers/define';
|
||||||
|
|
||||||
const getCacheIdentifier = () => {
|
const getCacheIdentifier = () => {
|
||||||
|
|
@ -25,8 +20,13 @@ const cacheOptions = {
|
||||||
timeToLive: 1000 * 60 * 10, // 10 minutes
|
timeToLive: 1000 * 60 * 10, // 10 minutes
|
||||||
};
|
};
|
||||||
|
|
||||||
ajax.addRequestInterceptor(cacheRequestInterceptorFactory(getCacheIdentifier, cacheOptions));
|
const [cacheRequestInterceptor, cacheResponseInterceptor] = createCacheInterceptors(
|
||||||
ajax.addResponseInterceptor(cacheResponseInterceptorFactory(getCacheIdentifier, cacheOptions));
|
getCacheIdentifier,
|
||||||
|
cacheOptions,
|
||||||
|
);
|
||||||
|
|
||||||
|
ajax.addRequestInterceptor(cacheRequestInterceptor);
|
||||||
|
ajax.addResponseInterceptor(cacheResponseInterceptor);
|
||||||
```
|
```
|
||||||
|
|
||||||
## GET request
|
## GET request
|
||||||
|
|
@ -34,14 +34,16 @@ ajax.addResponseInterceptor(cacheResponseInterceptorFactory(getCacheIdentifier,
|
||||||
```js preview-story
|
```js preview-story
|
||||||
export const getRequest = () => {
|
export const getRequest = () => {
|
||||||
const actionLogger = renderLitAsNode(html`<sb-action-logger></sb-action-logger>`);
|
const actionLogger = renderLitAsNode(html`<sb-action-logger></sb-action-logger>`);
|
||||||
|
|
||||||
const fetchHandler = name => {
|
const fetchHandler = name => {
|
||||||
ajax
|
ajax
|
||||||
.request(`../assets/${name}.json`)
|
.fetch(`../assets/${name}.json`)
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(result => {
|
.then(result => {
|
||||||
actionLogger.log(JSON.stringify(result, null, 2));
|
actionLogger.log(JSON.stringify(result, null, 2));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<style>
|
<style>
|
||||||
sb-action-logger {
|
sb-action-logger {
|
||||||
|
|
@ -60,16 +62,17 @@ export const getRequest = () => {
|
||||||
```js
|
```js
|
||||||
import { ajax } from '@lion/ajax';
|
import { ajax } from '@lion/ajax';
|
||||||
|
|
||||||
const response = await ajax.request('/api/users', {
|
const response = await ajax.fetch('/api/users', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ username: 'steve' }),
|
body: JSON.stringify({ username: 'steve' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const newUser = await response.json();
|
const newUser = await response.json();
|
||||||
```
|
```
|
||||||
|
|
||||||
### JSON requests
|
### JSON requests
|
||||||
|
|
||||||
We usually deal with JSON requests and responses. With `requestJson` you don't need to specifically stringify the request body or parse the response body.
|
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.
|
||||||
|
|
||||||
The result will have the Response object on `.response` property, and the decoded json will be available on `.body`.
|
The result will have the Response object on `.response` property, and the decoded json will be available on `.body`.
|
||||||
|
|
||||||
|
|
@ -78,12 +81,14 @@ The result will have the Response object on `.response` property, and the decode
|
||||||
```js preview-story
|
```js preview-story
|
||||||
export const getJsonRequest = () => {
|
export const getJsonRequest = () => {
|
||||||
const actionLogger = renderLitAsNode(html`<sb-action-logger></sb-action-logger>`);
|
const actionLogger = renderLitAsNode(html`<sb-action-logger></sb-action-logger>`);
|
||||||
|
|
||||||
const fetchHandler = name => {
|
const fetchHandler = name => {
|
||||||
ajax.requestJson(`../assets/${name}.json`).then(result => {
|
ajax.fetchJson(`../assets/${name}.json`).then(result => {
|
||||||
console.log(result.response);
|
console.log(result.response);
|
||||||
actionLogger.log(JSON.stringify(result.body, null, 2));
|
actionLogger.log(JSON.stringify(result.body, null, 2));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<style>
|
<style>
|
||||||
sb-action-logger {
|
sb-action-logger {
|
||||||
|
|
@ -102,7 +107,7 @@ export const getJsonRequest = () => {
|
||||||
```js
|
```js
|
||||||
import { ajax } from '@lion/ajax';
|
import { ajax } from '@lion/ajax';
|
||||||
|
|
||||||
const { response, body } = await ajax.requestJson('/api/users', {
|
const { response, body } = await ajax.fetchJson('/api/users', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { username: 'steve' },
|
body: { username: 'steve' },
|
||||||
});
|
});
|
||||||
|
|
@ -110,14 +115,15 @@ const { response, body } = await ajax.requestJson('/api/users', {
|
||||||
|
|
||||||
### Error handling
|
### Error handling
|
||||||
|
|
||||||
Different from fetch, `ajax` throws when the server returns a 4xx or 5xx, returning the request and response:
|
Different from fetch, `Ajax` throws when the server returns a 4xx or 5xx, returning the request and response:
|
||||||
|
|
||||||
```js preview-story
|
```js preview-story
|
||||||
export const errorHandling = () => {
|
export const errorHandling = () => {
|
||||||
const actionLogger = renderLitAsNode(html`<sb-action-logger></sb-action-logger>`);
|
const actionLogger = renderLitAsNode(html`<sb-action-logger></sb-action-logger>`);
|
||||||
|
|
||||||
const fetchHandler = async () => {
|
const fetchHandler = async () => {
|
||||||
try {
|
try {
|
||||||
const users = await ajax.requestJson('/api/users');
|
const users = await ajax.fetchJson('/api/users');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
if (error.response.status === 400) {
|
if (error.response.status === 400) {
|
||||||
|
|
@ -126,11 +132,13 @@ export const errorHandling = () => {
|
||||||
actionLogger.log(error);
|
actionLogger.log(error);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// an error happened before receiving a response, ex. an incorrect request or network error
|
// an error happened before receiving a response,
|
||||||
|
// ex. an incorrect request or network error
|
||||||
actionLogger.log(error);
|
actionLogger.log(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<style>
|
<style>
|
||||||
sb-action-logger {
|
sb-action-logger {
|
||||||
|
|
@ -163,43 +171,41 @@ requested response, based on the options that are being passed.
|
||||||
|
|
||||||
### Getting started
|
### Getting started
|
||||||
|
|
||||||
Consume the global `ajax` instance and add the interceptors to it, using a cache configuration
|
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,
|
||||||
which is applied on application level. If a developer wants to add specifics to cache behavior
|
|
||||||
they have to provide a cache config per action (`get`, `post`, etc.) via `cacheOptions` field of local ajax config,
|
|
||||||
see examples below.
|
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 {
|
import { ajax, createCacheInterceptors } from '@lion-web/ajax';
|
||||||
ajax,
|
|
||||||
cacheRequestInterceptorFactory,
|
|
||||||
cacheResponseInterceptorFactory,
|
|
||||||
} from '@lion-web/ajax.js';
|
|
||||||
|
|
||||||
const globalCacheOptions = {
|
const globalCacheOptions = {
|
||||||
useCache: true,
|
useCache: true,
|
||||||
timeToLive: 1000 * 60 * 5, // 5 minutes
|
timeToLive: 1000 * 60 * 5, // 5 minutes
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cache is removed each time an identifier changes,
|
// Cache is removed each time an identifier changes,
|
||||||
// for instance when a current user is logged out
|
// for instance when a current user is logged out
|
||||||
const getCacheIdentifier = () => getActiveProfile().profileId;
|
const getCacheIdentifier = () => getActiveProfile().profileId;
|
||||||
|
|
||||||
ajax.addRequestInterceptor(cacheRequestInterceptorFactory(getCacheIdentifier, globalCacheOptions));
|
const [cacheRequestInterceptor, cacheResponseInterceptor] = createCacheInterceptors(
|
||||||
ajax.addResponseInterceptor(
|
getCacheIdentifier,
|
||||||
cacheResponseInterceptorFactory(getCacheIdentifier, globalCacheOptions),
|
globalCacheOptions,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { response, body } = await ajax.requestJson('/my-url');
|
ajax.addRequestInterceptor(cacheRequestInterceptor);
|
||||||
|
ajax.addResponseInterceptor(cacheResponseInterceptor);
|
||||||
|
|
||||||
|
const { response, body } = await ajax.fetchJson('/my-url');
|
||||||
```
|
```
|
||||||
|
|
||||||
Alternatively, most often for subclassers, you can extend or import `AjaxClient` yourself, and pass cacheOptions when instantiating the ajax singleton.
|
Alternatively, most often for sub-classers, you can extend or import `Ajax` yourself, and pass cacheOptions when instantiating the ajax.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
import { AjaxClient } from '@lion/ajax';
|
import { Ajax } from '@lion/ajax';
|
||||||
|
|
||||||
export const ajax = new AjaxClient({
|
export const ajax = new Ajax({
|
||||||
cacheOptions: {
|
cacheOptions: {
|
||||||
useCache: true,
|
useCache: true,
|
||||||
timeToLive: 1000 * 60 * 5, // 5 minutes
|
timeToLive: 1000 * 60 * 5, // 5 minutes
|
||||||
|
|
@ -218,12 +224,14 @@ which is either undefined for normal requests, or set to true for responses that
|
||||||
```js preview-story
|
```js preview-story
|
||||||
export const cache = () => {
|
export const cache = () => {
|
||||||
const actionLogger = renderLitAsNode(html`<sb-action-logger></sb-action-logger>`);
|
const actionLogger = renderLitAsNode(html`<sb-action-logger></sb-action-logger>`);
|
||||||
|
|
||||||
const fetchHandler = name => {
|
const fetchHandler = name => {
|
||||||
ajax.requestJson(`../assets/${name}.json`).then(result => {
|
ajax.fetchJson(`../assets/${name}.json`).then(result => {
|
||||||
actionLogger.log(`From cache: ${result.response.fromCache || false}`);
|
actionLogger.log(`From cache: ${result.response.fromCache || false}`);
|
||||||
actionLogger.log(JSON.stringify(result.body, null, 2));
|
actionLogger.log(JSON.stringify(result.body, null, 2));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<style>
|
<style>
|
||||||
sb-action-logger {
|
sb-action-logger {
|
||||||
|
|
@ -244,6 +252,7 @@ In this demo, when we fetch naga, we always pass `useCache: false` so the Respon
|
||||||
```js preview-story
|
```js preview-story
|
||||||
export const cacheActionOptions = () => {
|
export const cacheActionOptions = () => {
|
||||||
const actionLogger = renderLitAsNode(html`<sb-action-logger></sb-action-logger>`);
|
const actionLogger = renderLitAsNode(html`<sb-action-logger></sb-action-logger>`);
|
||||||
|
|
||||||
const fetchHandler = name => {
|
const fetchHandler = name => {
|
||||||
let actionCacheOptions;
|
let actionCacheOptions;
|
||||||
if (name === 'naga') {
|
if (name === 'naga') {
|
||||||
|
|
@ -252,13 +261,12 @@ export const cacheActionOptions = () => {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
ajax
|
ajax.fetchJson(`../assets/${name}.json`, { cacheOptions: actionCacheOptions }).then(result => {
|
||||||
.requestJson(`../assets/${name}.json`, { cacheOptions: actionCacheOptions })
|
|
||||||
.then(result => {
|
|
||||||
actionLogger.log(`From cache: ${result.response.fromCache || false}`);
|
actionLogger.log(`From cache: ${result.response.fromCache || false}`);
|
||||||
actionLogger.log(JSON.stringify(result.body, null, 2));
|
actionLogger.log(JSON.stringify(result.body, null, 2));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<style>
|
<style>
|
||||||
sb-action-logger {
|
sb-action-logger {
|
||||||
|
|
@ -292,9 +300,10 @@ After TTL expires, the next request will set the cache again, and for the next 3
|
||||||
```js preview-story
|
```js preview-story
|
||||||
export const cacheTimeToLive = () => {
|
export const cacheTimeToLive = () => {
|
||||||
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
|
||||||
.requestJson(`../assets/pabu.json`, {
|
.fetchJson(`../assets/pabu.json`, {
|
||||||
cacheOptions: {
|
cacheOptions: {
|
||||||
timeToLive: 1000 * 3, // 3 seconds
|
timeToLive: 1000 * 3, // 3 seconds
|
||||||
},
|
},
|
||||||
|
|
@ -304,6 +313,7 @@ export const cacheTimeToLive = () => {
|
||||||
actionLogger.log(JSON.stringify(result.body, null, 2));
|
actionLogger.log(JSON.stringify(result.body, null, 2));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<style>
|
<style>
|
||||||
sb-action-logger {
|
sb-action-logger {
|
||||||
|
|
@ -325,8 +335,9 @@ Now we will allow you to change this identifier to invalidate the cache.
|
||||||
```js preview-story
|
```js preview-story
|
||||||
export const changeCacheIdentifier = () => {
|
export const changeCacheIdentifier = () => {
|
||||||
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.requestJson(`../assets/pabu.json`).then(result => {
|
ajax.fetchJson(`../assets/pabu.json`).then(result => {
|
||||||
actionLogger.log(`From cache: ${result.response.fromCache || false}`);
|
actionLogger.log(`From cache: ${result.response.fromCache || false}`);
|
||||||
actionLogger.log(JSON.stringify(result.body, null, 2));
|
actionLogger.log(JSON.stringify(result.body, null, 2));
|
||||||
});
|
});
|
||||||
|
|
@ -366,12 +377,14 @@ Therefore, we invalidate the cache, so the user gets the latest state from the d
|
||||||
```js preview-story
|
```js preview-story
|
||||||
export const nonGETRequest = () => {
|
export const nonGETRequest = () => {
|
||||||
const actionLogger = renderLitAsNode(html`<sb-action-logger></sb-action-logger>`);
|
const actionLogger = renderLitAsNode(html`<sb-action-logger></sb-action-logger>`);
|
||||||
|
|
||||||
const fetchHandler = (name, method) => {
|
const fetchHandler = (name, method) => {
|
||||||
ajax.requestJson(`../assets/${name}.json`, { method }).then(result => {
|
ajax.fetchJson(`../assets/${name}.json`, { method }).then(result => {
|
||||||
actionLogger.log(`From cache: ${result.response.fromCache || false}`);
|
actionLogger.log(`From cache: ${result.response.fromCache || false}`);
|
||||||
actionLogger.log(JSON.stringify(result.body, null, 2));
|
actionLogger.log(JSON.stringify(result.body, null, 2));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<style>
|
<style>
|
||||||
sb-action-logger {
|
sb-action-logger {
|
||||||
|
|
@ -408,6 +421,7 @@ In this demo, invalidating the `pabu` endpoint will invalidate `naga`, but not t
|
||||||
```js preview-story
|
```js preview-story
|
||||||
export const invalidateRules = () => {
|
export const invalidateRules = () => {
|
||||||
const actionLogger = renderLitAsNode(html`<sb-action-logger></sb-action-logger>`);
|
const actionLogger = renderLitAsNode(html`<sb-action-logger></sb-action-logger>`);
|
||||||
|
|
||||||
const fetchHandler = (name, method) => {
|
const fetchHandler = (name, method) => {
|
||||||
const actionCacheOptions = {};
|
const actionCacheOptions = {};
|
||||||
if (name === 'pabu') {
|
if (name === 'pabu') {
|
||||||
|
|
@ -415,7 +429,7 @@ export const invalidateRules = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
ajax
|
ajax
|
||||||
.requestJson(`../assets/${name}.json`, {
|
.fetchJson(`../assets/${name}.json`, {
|
||||||
method,
|
method,
|
||||||
cacheOptions: actionCacheOptions,
|
cacheOptions: actionCacheOptions,
|
||||||
})
|
})
|
||||||
|
|
@ -424,6 +438,7 @@ export const invalidateRules = () => {
|
||||||
actionLogger.log(JSON.stringify(result.body, null, 2));
|
actionLogger.log(JSON.stringify(result.body, null, 2));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<style>
|
<style>
|
||||||
sb-action-logger {
|
sb-action-logger {
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,7 @@
|
||||||
```js script
|
```js script
|
||||||
import { html } from '@lion/core';
|
import { html } from '@lion/core';
|
||||||
import { renderLitAsNode } from '@lion/helpers';
|
import { renderLitAsNode } from '@lion/helpers';
|
||||||
import {
|
import { ajax, createCacheInterceptors } from '@lion/ajax';
|
||||||
ajax,
|
|
||||||
AjaxClient,
|
|
||||||
cacheRequestInterceptorFactory,
|
|
||||||
cacheResponseInterceptorFactory,
|
|
||||||
} from '@lion/ajax';
|
|
||||||
import '@lion/helpers/define';
|
import '@lion/helpers/define';
|
||||||
|
|
||||||
const getCacheIdentifier = () => {
|
const getCacheIdentifier = () => {
|
||||||
|
|
@ -25,11 +20,16 @@ const cacheOptions = {
|
||||||
timeToLive: 1000 * 60 * 10, // 10 minutes
|
timeToLive: 1000 * 60 * 10, // 10 minutes
|
||||||
};
|
};
|
||||||
|
|
||||||
ajax.addRequestInterceptor(cacheRequestInterceptorFactory(getCacheIdentifier, cacheOptions));
|
const [cacheRequestInterceptor, cacheResponseInterceptor] = createCacheInterceptors(
|
||||||
ajax.addResponseInterceptor(cacheResponseInterceptorFactory(getCacheIdentifier, cacheOptions));
|
getCacheIdentifier,
|
||||||
|
cacheOptions,
|
||||||
|
);
|
||||||
|
|
||||||
|
ajax.addRequestInterceptor(cacheRequestInterceptor);
|
||||||
|
ajax.addResponseInterceptor(cacheResponseInterceptor);
|
||||||
```
|
```
|
||||||
|
|
||||||
`ajax` is a small wrapper around `fetch` which:
|
`Ajax` is a small wrapper around `fetch` which:
|
||||||
|
|
||||||
- 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
|
||||||
|
|
@ -46,4 +46,4 @@ npm i --save @lion/ajax
|
||||||
|
|
||||||
### Relation to fetch
|
### Relation to fetch
|
||||||
|
|
||||||
`ajax` delegates all requests to fetch. `ajax.request` and `ajax.requestJson` have the same function signature as `window.fetch`, you can use any online resource to learn more about fetch. [MDN](http://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch) is a great start.
|
`Ajax` delegates all requests to fetch. `ajax.fetch` and `ajax.fetchJson` have the same function signature as `window.fetch`, you can use any online resource to learn more about fetch. [MDN](http://developer.mozilla.org/en-US/docs/Web/API/Fetch_API/Using_Fetch) is a great start.
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,12 @@
|
||||||
export { ajax, setAjax } from './src/ajax.js';
|
import { Ajax } from './src/Ajax.js';
|
||||||
export { AjaxClient } from './src/AjaxClient.js';
|
|
||||||
export { AjaxClientFetchError } from './src/AjaxClientFetchError.js';
|
|
||||||
|
|
||||||
|
export { Ajax } from './src/Ajax.js';
|
||||||
|
export { AjaxFetchError } from './src/AjaxFetchError.js';
|
||||||
export {
|
export {
|
||||||
acceptLanguageRequestInterceptor,
|
acceptLanguageRequestInterceptor,
|
||||||
createXSRFRequestInterceptor,
|
createXsrfRequestInterceptor,
|
||||||
getCookie,
|
createCacheInterceptors,
|
||||||
} from './src/interceptors.js';
|
} from './src/interceptors/index.js';
|
||||||
|
|
||||||
export {
|
// globally available instance
|
||||||
cacheRequestInterceptorFactory,
|
export const ajax = new Ajax();
|
||||||
cacheResponseInterceptorFactory,
|
|
||||||
validateOptions,
|
|
||||||
} from './src/interceptors-cache.js';
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
/* eslint-disable consistent-return */
|
/* eslint-disable consistent-return */
|
||||||
import {
|
import {
|
||||||
cacheRequestInterceptorFactory,
|
acceptLanguageRequestInterceptor,
|
||||||
cacheResponseInterceptorFactory,
|
createXsrfRequestInterceptor,
|
||||||
} from './interceptors-cache.js';
|
createCacheInterceptors,
|
||||||
import { acceptLanguageRequestInterceptor, createXSRFRequestInterceptor } from './interceptors.js';
|
} from './interceptors/index.js';
|
||||||
import { AjaxClientFetchError } from './AjaxClientFetchError.js';
|
import { AjaxFetchError } from './AjaxFetchError.js';
|
||||||
|
|
||||||
import './typedef.js';
|
import './typedef.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -13,13 +12,13 @@ import './typedef.js';
|
||||||
* intercept request and responses, for example to add authorization headers or logging. A
|
* intercept request and responses, for example to add authorization headers or logging. A
|
||||||
* request can also be prevented from reaching the network at all by returning the Response directly.
|
* request can also be prevented from reaching the network at all by returning the Response directly.
|
||||||
*/
|
*/
|
||||||
export class AjaxClient {
|
export class Ajax {
|
||||||
/**
|
/**
|
||||||
* @param {Partial<AjaxClientConfig>} config
|
* @param {Partial<AjaxConfig>} config
|
||||||
*/
|
*/
|
||||||
constructor(config = {}) {
|
constructor(config = {}) {
|
||||||
/**
|
/**
|
||||||
* @type {Partial<AjaxClientConfig>}
|
* @type {Partial<AjaxConfig>}
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
this.__config = {
|
this.__config = {
|
||||||
|
|
@ -45,23 +44,23 @@ export class AjaxClient {
|
||||||
|
|
||||||
const { xsrfCookieName, xsrfHeaderName } = this.__config;
|
const { xsrfCookieName, xsrfHeaderName } = this.__config;
|
||||||
if (xsrfCookieName && xsrfHeaderName) {
|
if (xsrfCookieName && xsrfHeaderName) {
|
||||||
this.addRequestInterceptor(createXSRFRequestInterceptor(xsrfCookieName, xsrfHeaderName));
|
this.addRequestInterceptor(createXsrfRequestInterceptor(xsrfCookieName, xsrfHeaderName));
|
||||||
}
|
}
|
||||||
|
|
||||||
const { cacheOptions } = this.__config;
|
const { cacheOptions } = this.__config;
|
||||||
if (cacheOptions?.useCache) {
|
if (cacheOptions?.useCache) {
|
||||||
this.addRequestInterceptor(
|
const [cacheRequestInterceptor, cacheResponseInterceptor] = createCacheInterceptors(
|
||||||
cacheRequestInterceptorFactory(cacheOptions.getCacheIdentifier, cacheOptions),
|
cacheOptions.getCacheIdentifier,
|
||||||
);
|
cacheOptions,
|
||||||
this.addResponseInterceptor(
|
|
||||||
cacheResponseInterceptorFactory(cacheOptions.getCacheIdentifier, cacheOptions),
|
|
||||||
);
|
);
|
||||||
|
this.addRequestInterceptor(/** @type {RequestInterceptor} */ (cacheRequestInterceptor));
|
||||||
|
this.addResponseInterceptor(/** @type {ResponseInterceptor} */ (cacheResponseInterceptor));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the config for the instance
|
* Sets the config for the instance
|
||||||
* @param {Partial<AjaxClientConfig>} config configuration for the AjaxClass instance
|
* @param {Partial<AjaxConfig>} config configuration for the AjaxClass instance
|
||||||
*/
|
*/
|
||||||
set options(config) {
|
set options(config) {
|
||||||
this.__config = config;
|
this.__config = config;
|
||||||
|
|
@ -103,7 +102,7 @@ export class AjaxClient {
|
||||||
* @param {RequestInit & Partial<CacheRequestExtension>} [init]
|
* @param {RequestInit & Partial<CacheRequestExtension>} [init]
|
||||||
* @returns {Promise<Response>}
|
* @returns {Promise<Response>}
|
||||||
*/
|
*/
|
||||||
async request(info, init) {
|
async fetch(info, init) {
|
||||||
const request = /** @type {CacheRequest} */ (new Request(info, { ...init }));
|
const request = /** @type {CacheRequest} */ (new Request(info, { ...init }));
|
||||||
request.cacheOptions = init?.cacheOptions;
|
request.cacheOptions = init?.cacheOptions;
|
||||||
request.params = init?.params;
|
request.params = init?.params;
|
||||||
|
|
@ -121,7 +120,7 @@ export class AjaxClient {
|
||||||
const interceptedResponse = await this.__interceptResponse(response);
|
const interceptedResponse = await this.__interceptResponse(response);
|
||||||
|
|
||||||
if (interceptedResponse.status >= 400 && interceptedResponse.status < 600) {
|
if (interceptedResponse.status >= 400 && interceptedResponse.status < 600) {
|
||||||
throw new AjaxClientFetchError(request, interceptedResponse);
|
throw new AjaxFetchError(request, interceptedResponse);
|
||||||
}
|
}
|
||||||
return interceptedResponse;
|
return interceptedResponse;
|
||||||
}
|
}
|
||||||
|
|
@ -135,7 +134,7 @@ export class AjaxClient {
|
||||||
* @template T
|
* @template T
|
||||||
* @returns {Promise<{ response: Response, body: T }>}
|
* @returns {Promise<{ response: Response, body: T }>}
|
||||||
*/
|
*/
|
||||||
async requestJson(info, init) {
|
async fetchJson(info, init) {
|
||||||
const lionInit = {
|
const lionInit = {
|
||||||
...init,
|
...init,
|
||||||
headers: {
|
headers: {
|
||||||
|
|
@ -152,7 +151,7 @@ export class AjaxClient {
|
||||||
|
|
||||||
// Now that we stringified lionInit.body, we can safely typecast LionRequestInit back to RequestInit
|
// Now that we stringified lionInit.body, we can safely typecast LionRequestInit back to RequestInit
|
||||||
const jsonInit = /** @type {RequestInit} */ (lionInit);
|
const jsonInit = /** @type {RequestInit} */ (lionInit);
|
||||||
const response = await this.request(info, jsonInit);
|
const response = await this.fetch(info, jsonInit);
|
||||||
let responseText = await response.text();
|
let responseText = await response.text();
|
||||||
|
|
||||||
const { jsonPrefix } = this.__config;
|
const { jsonPrefix } = this.__config;
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export class AjaxClientFetchError extends Error {
|
export class AjaxFetchError extends Error {
|
||||||
/**
|
/**
|
||||||
* @param {Request} request
|
* @param {Request} request
|
||||||
* @param {Response} response
|
* @param {Response} response
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import { AjaxClient } from './AjaxClient.js';
|
|
||||||
|
|
||||||
export let ajax = new AjaxClient(); // eslint-disable-line import/no-mutable-exports
|
|
||||||
|
|
||||||
/**
|
|
||||||
* setAjax allows the Application Developer to override the globally used instance of {@link:ajax}.
|
|
||||||
* All interactions with {@link:ajax} after the call to setAjax will use this new instance
|
|
||||||
* (so make sure to call this method before dependant code using {@link:ajax} is ran and this
|
|
||||||
* method is not called by any of your (indirect) dependencies.)
|
|
||||||
* @param {AjaxClient} newAjax the globally used instance of {@link:ajax}.
|
|
||||||
*/
|
|
||||||
export function setAjax(newAjax) {
|
|
||||||
ajax = newAjax;
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
/* eslint-disable consistent-return */
|
/* eslint-disable consistent-return */
|
||||||
/* eslint-disable no-param-reassign */
|
/* eslint-disable no-param-reassign */
|
||||||
|
|
||||||
import './typedef.js';
|
import './typedef.js';
|
||||||
|
|
||||||
const SECOND = 1000;
|
const SECOND = 1000;
|
||||||
|
|
@ -138,7 +137,7 @@ let caches = {};
|
||||||
* @param {Params} params query string parameters object
|
* @param {Params} params query string parameters object
|
||||||
* @returns {string} of querystring parameters WITHOUT `?` or empty string ''
|
* @returns {string} of querystring parameters WITHOUT `?` or empty string ''
|
||||||
*/
|
*/
|
||||||
export const searchParamSerializer = (params = {}) =>
|
export const stringifySearchParams = (params = {}) =>
|
||||||
typeof params === 'object' && params !== null ? new URLSearchParams(params).toString() : '';
|
typeof params === 'object' && params !== null ? new URLSearchParams(params).toString() : '';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -147,7 +146,7 @@ export const searchParamSerializer = (params = {}) =>
|
||||||
* and proactively clean it
|
* and proactively clean it
|
||||||
* @param {string} cacheIdentifier usually the refreshToken of the owner of the cache
|
* @param {string} cacheIdentifier usually the refreshToken of the owner of the cache
|
||||||
*/
|
*/
|
||||||
const getCache = cacheIdentifier => {
|
export const getCache = cacheIdentifier => {
|
||||||
if (caches[cacheIdentifier]?._validateCache()) {
|
if (caches[cacheIdentifier]?._validateCache()) {
|
||||||
return caches[cacheIdentifier];
|
return caches[cacheIdentifier];
|
||||||
}
|
}
|
||||||
|
|
@ -162,7 +161,7 @@ const getCache = cacheIdentifier => {
|
||||||
* @param {CacheOptions} options Options to match cache
|
* @param {CacheOptions} options Options to match cache
|
||||||
* @returns {ValidatedCacheOptions}
|
* @returns {ValidatedCacheOptions}
|
||||||
*/
|
*/
|
||||||
export const validateOptions = ({
|
export const validateCacheOptions = ({
|
||||||
useCache = false,
|
useCache = false,
|
||||||
methods = ['get'],
|
methods = ['get'],
|
||||||
timeToLive,
|
timeToLive,
|
||||||
|
|
@ -204,11 +203,9 @@ export const validateOptions = ({
|
||||||
throw new Error('Property `requestIdentificationFn` must be of type `function`');
|
throw new Error('Property `requestIdentificationFn` must be of type `function`');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
requestIdentificationFn = /** @param {any} data */ (
|
// eslint-disable-next-line no-shadow
|
||||||
{ url, params },
|
requestIdentificationFn = /** @param {any} data */ ({ url, params }, stringifySearchParams) => {
|
||||||
searchParamsSerializer,
|
const serializedParams = stringifySearchParams(params);
|
||||||
) => {
|
|
||||||
const serializedParams = searchParamsSerializer(params);
|
|
||||||
return serializedParams ? `${url}?${serializedParams}` : url;
|
return serializedParams ? `${url}?${serializedParams}` : url;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -222,122 +219,3 @@ export const validateOptions = ({
|
||||||
requestIdentificationFn,
|
requestIdentificationFn,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Request interceptor to return relevant cached requests
|
|
||||||
* @param {function(): string} getCacheIdentifier used to invalidate cache if identifier is changed
|
|
||||||
* @param {CacheOptions} globalCacheOptions
|
|
||||||
* @returns {CachedRequestInterceptor}
|
|
||||||
*/
|
|
||||||
export const cacheRequestInterceptorFactory = (getCacheIdentifier, globalCacheOptions) => {
|
|
||||||
const validatedInitialCacheOptions = validateOptions(globalCacheOptions);
|
|
||||||
|
|
||||||
return /** @param {CacheRequest} cacheRequest */ async cacheRequest => {
|
|
||||||
const cacheOptions = validateOptions({
|
|
||||||
...validatedInitialCacheOptions,
|
|
||||||
...cacheRequest.cacheOptions,
|
|
||||||
});
|
|
||||||
|
|
||||||
cacheRequest.cacheOptions = cacheOptions;
|
|
||||||
|
|
||||||
// don't use cache if 'useCache' === false
|
|
||||||
if (!cacheOptions.useCache) {
|
|
||||||
return cacheRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cacheId = cacheOptions.requestIdentificationFn(cacheRequest, searchParamSerializer);
|
|
||||||
// cacheIdentifier is used to bind the cache to the current session
|
|
||||||
const currentCache = getCache(getCacheIdentifier());
|
|
||||||
const { method } = cacheRequest;
|
|
||||||
|
|
||||||
// don't use cache if the request method is not part of the configs methods
|
|
||||||
if (!cacheOptions.methods.includes(method.toLowerCase())) {
|
|
||||||
// If it's NOT one of the config.methods, invalidate caches
|
|
||||||
currentCache.delete(cacheId);
|
|
||||||
// also invalidate caches matching to cacheOptions
|
|
||||||
if (cacheOptions.invalidateUrls) {
|
|
||||||
cacheOptions.invalidateUrls.forEach(
|
|
||||||
/** @type {string} */ invalidateUrl => {
|
|
||||||
currentCache.delete(invalidateUrl);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// also invalidate caches matching to invalidateUrlsRegex
|
|
||||||
if (cacheOptions.invalidateUrlsRegex) {
|
|
||||||
currentCache.deleteMatched(cacheOptions.invalidateUrlsRegex);
|
|
||||||
}
|
|
||||||
|
|
||||||
return cacheRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pendingRequest = currentCache.getPendingRequest(cacheId);
|
|
||||||
if (pendingRequest) {
|
|
||||||
// there is another concurrent request, wait for it to finish
|
|
||||||
await pendingRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cacheResponse = currentCache.get(cacheId, cacheOptions.timeToLive);
|
|
||||||
if (cacheResponse) {
|
|
||||||
cacheRequest.cacheOptions = cacheRequest.cacheOptions ?? { useCache: false };
|
|
||||||
const response = /** @type {CacheResponse} */ cacheResponse.clone();
|
|
||||||
response.request = cacheRequest;
|
|
||||||
response.fromCache = true;
|
|
||||||
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 reuse it from the cache
|
|
||||||
currentCache.setPendingRequest(cacheId);
|
|
||||||
|
|
||||||
return cacheRequest;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Response interceptor to cache relevant requests
|
|
||||||
* @param {function(): string} getCacheIdentifier used to invalidate cache if identifier is changed
|
|
||||||
* @param {CacheOptions} globalCacheOptions
|
|
||||||
* @returns {CachedResponseInterceptor}
|
|
||||||
*/
|
|
||||||
export const cacheResponseInterceptorFactory = (getCacheIdentifier, globalCacheOptions) => {
|
|
||||||
const validatedInitialCacheOptions = validateOptions(globalCacheOptions);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Axios response https://github.com/axios/axios#response-schema
|
|
||||||
*/
|
|
||||||
return /** @param {CacheResponse} cacheResponse */ async cacheResponse => {
|
|
||||||
if (!getCacheIdentifier()) {
|
|
||||||
throw new Error(`getCacheIdentifier returns falsy`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!cacheResponse.request) {
|
|
||||||
throw new Error('Missing request in response.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const cacheOptions = validateOptions({
|
|
||||||
...validatedInitialCacheOptions,
|
|
||||||
...cacheResponse.request?.cacheOptions,
|
|
||||||
});
|
|
||||||
|
|
||||||
// string that identifies cache entry
|
|
||||||
const cacheId = cacheOptions.requestIdentificationFn(
|
|
||||||
cacheResponse.request,
|
|
||||||
searchParamSerializer,
|
|
||||||
);
|
|
||||||
const currentCache = getCache(getCacheIdentifier());
|
|
||||||
const isAlreadyFromCache = !!cacheResponse.fromCache;
|
|
||||||
// caching all responses with not default `timeToLive`
|
|
||||||
const isCacheActive = cacheOptions.timeToLive > 0;
|
|
||||||
const isMethodSupported = cacheOptions.methods.includes(
|
|
||||||
cacheResponse.request.method.toLowerCase(),
|
|
||||||
);
|
|
||||||
// if the request is one of the options.methods; store response in cache
|
|
||||||
if (!isAlreadyFromCache && isCacheActive && isMethodSupported) {
|
|
||||||
// store the response data in the cache and mark request as resolved
|
|
||||||
currentCache.set(cacheId, cacheResponse.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
currentCache.resolvePendingRequest(cacheId);
|
|
||||||
return cacheResponse;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
16
packages/ajax/src/interceptors/acceptLanguageHeader.js
Normal file
16
packages/ajax/src/interceptors/acceptLanguageHeader.js
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import '../typedef.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms a request, adding an accept-language header with the current application's locale
|
||||||
|
* if it has not already been set.
|
||||||
|
* @type {RequestInterceptor}
|
||||||
|
*/
|
||||||
|
export async function acceptLanguageRequestInterceptor(request) {
|
||||||
|
if (!request.headers.has('accept-language')) {
|
||||||
|
const documentLocale = document.documentElement.lang;
|
||||||
|
const localizeLang = document.documentElement.getAttribute('data-localize-lang');
|
||||||
|
const locale = localizeLang || documentLocale || 'en';
|
||||||
|
request.headers.set('accept-language', locale);
|
||||||
|
}
|
||||||
|
return request;
|
||||||
|
}
|
||||||
137
packages/ajax/src/interceptors/cacheInterceptors.js
Normal file
137
packages/ajax/src/interceptors/cacheInterceptors.js
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
/* eslint-disable no-param-reassign */
|
||||||
|
import '../typedef.js';
|
||||||
|
import { validateCacheOptions, stringifySearchParams, getCache } from '../cache.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request interceptor to return relevant cached requests
|
||||||
|
* @param {function(): string} getCacheIdentifier used to invalidate cache if identifier is changed
|
||||||
|
* @param {CacheOptions} globalCacheOptions
|
||||||
|
* @returns {RequestInterceptor}
|
||||||
|
*/
|
||||||
|
const createCacheRequestInterceptor = (getCacheIdentifier, globalCacheOptions) => {
|
||||||
|
const validatedInitialCacheOptions = validateCacheOptions(globalCacheOptions);
|
||||||
|
|
||||||
|
return /** @param {CacheRequest} cacheRequest */ async cacheRequest => {
|
||||||
|
const cacheOptions = validateCacheOptions({
|
||||||
|
...validatedInitialCacheOptions,
|
||||||
|
...cacheRequest.cacheOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
cacheRequest.cacheOptions = cacheOptions;
|
||||||
|
|
||||||
|
// don't use cache if 'useCache' === false
|
||||||
|
if (!cacheOptions.useCache) {
|
||||||
|
return cacheRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheId = cacheOptions.requestIdentificationFn(cacheRequest, stringifySearchParams);
|
||||||
|
// cacheIdentifier is used to bind the cache to the current session
|
||||||
|
const currentCache = getCache(getCacheIdentifier());
|
||||||
|
const { method } = cacheRequest;
|
||||||
|
|
||||||
|
// don't use cache if the request method is not part of the configs methods
|
||||||
|
if (!cacheOptions.methods.includes(method.toLowerCase())) {
|
||||||
|
// If it's NOT one of the config.methods, invalidate caches
|
||||||
|
currentCache.delete(cacheId);
|
||||||
|
// also invalidate caches matching to cacheOptions
|
||||||
|
if (cacheOptions.invalidateUrls) {
|
||||||
|
cacheOptions.invalidateUrls.forEach(
|
||||||
|
/** @type {string} */ invalidateUrl => {
|
||||||
|
currentCache.delete(invalidateUrl);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// also invalidate caches matching to invalidateUrlsRegex
|
||||||
|
if (cacheOptions.invalidateUrlsRegex) {
|
||||||
|
currentCache.deleteMatched(cacheOptions.invalidateUrlsRegex);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cacheRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingRequest = currentCache.getPendingRequest(cacheId);
|
||||||
|
if (pendingRequest) {
|
||||||
|
// there is another concurrent request, wait for it to finish
|
||||||
|
await pendingRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheResponse = currentCache.get(cacheId, cacheOptions.timeToLive);
|
||||||
|
if (cacheResponse) {
|
||||||
|
cacheRequest.cacheOptions = cacheRequest.cacheOptions ?? { useCache: false };
|
||||||
|
const response = /** @type {CacheResponse} */ cacheResponse.clone();
|
||||||
|
response.request = cacheRequest;
|
||||||
|
response.fromCache = true;
|
||||||
|
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 reuse it from the cache
|
||||||
|
currentCache.setPendingRequest(cacheId);
|
||||||
|
|
||||||
|
return cacheRequest;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response interceptor to cache relevant requests
|
||||||
|
* @param {function(): string} getCacheIdentifier used to invalidate cache if identifier is changed
|
||||||
|
* @param {CacheOptions} globalCacheOptions
|
||||||
|
* @returns {ResponseInterceptor}
|
||||||
|
*/
|
||||||
|
const createCacheResponseInterceptor = (getCacheIdentifier, globalCacheOptions) => {
|
||||||
|
const validatedInitialCacheOptions = validateCacheOptions(globalCacheOptions);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
||||||
|
throw new Error('Missing request in response.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheOptions = validateCacheOptions({
|
||||||
|
...validatedInitialCacheOptions,
|
||||||
|
...cacheResponse.request?.cacheOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
// string that identifies cache entry
|
||||||
|
const cacheId = cacheOptions.requestIdentificationFn(
|
||||||
|
cacheResponse.request,
|
||||||
|
stringifySearchParams,
|
||||||
|
);
|
||||||
|
const currentCache = getCache(getCacheIdentifier());
|
||||||
|
const isAlreadyFromCache = !!cacheResponse.fromCache;
|
||||||
|
// caching all responses with not default `timeToLive`
|
||||||
|
const isCacheActive = cacheOptions.timeToLive > 0;
|
||||||
|
const isMethodSupported = cacheOptions.methods.includes(
|
||||||
|
cacheResponse.request.method.toLowerCase(),
|
||||||
|
);
|
||||||
|
// if the request is one of the options.methods; store response in cache
|
||||||
|
if (!isAlreadyFromCache && isCacheActive && isMethodSupported) {
|
||||||
|
// store the response data in the cache and mark request as resolved
|
||||||
|
currentCache.set(cacheId, cacheResponse.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
currentCache.resolvePendingRequest(cacheId);
|
||||||
|
return cacheResponse;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response interceptor to cache relevant requests
|
||||||
|
* @param {function(): string} getCacheIdentifier used to invalidate cache if identifier is changed
|
||||||
|
* @param {CacheOptions} globalCacheOptions
|
||||||
|
* @returns [{RequestInterceptor}, {ResponseInterceptor}]
|
||||||
|
*/
|
||||||
|
export const createCacheInterceptors = (getCacheIdentifier, globalCacheOptions) => {
|
||||||
|
const requestInterceptor = createCacheRequestInterceptor(getCacheIdentifier, globalCacheOptions);
|
||||||
|
const responseInterceptor = createCacheResponseInterceptor(
|
||||||
|
getCacheIdentifier,
|
||||||
|
globalCacheOptions,
|
||||||
|
);
|
||||||
|
return [requestInterceptor, responseInterceptor];
|
||||||
|
};
|
||||||
3
packages/ajax/src/interceptors/index.js
Normal file
3
packages/ajax/src/interceptors/index.js
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { acceptLanguageRequestInterceptor } from './acceptLanguageHeader.js';
|
||||||
|
export { createXsrfRequestInterceptor } from './xsrfHeader.js';
|
||||||
|
export { createCacheInterceptors } from './cacheInterceptors.js';
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import './typedef.js';
|
import '../typedef.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} name the cookie name
|
* @param {string} name the cookie name
|
||||||
|
|
@ -10,21 +10,6 @@ export function getCookie(name, _document = document) {
|
||||||
return match ? decodeURIComponent(match[3]) : null;
|
return match ? decodeURIComponent(match[3]) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Transforms a request, adding an accept-language header with the current application's locale
|
|
||||||
* if it has not already been set.
|
|
||||||
* @type {RequestInterceptor}
|
|
||||||
*/
|
|
||||||
export async function acceptLanguageRequestInterceptor(request) {
|
|
||||||
if (!request.headers.has('accept-language')) {
|
|
||||||
const documentLocale = document.documentElement.lang;
|
|
||||||
const localizeLang = document.documentElement.getAttribute('data-localize-lang');
|
|
||||||
const locale = localizeLang || documentLocale || 'en';
|
|
||||||
request.headers.set('accept-language', locale);
|
|
||||||
}
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a request transformer that adds a XSRF header for protecting
|
* Creates a request transformer that adds a XSRF header for protecting
|
||||||
* against cross-site request forgery.
|
* against cross-site request forgery.
|
||||||
|
|
@ -33,7 +18,7 @@ export async function acceptLanguageRequestInterceptor(request) {
|
||||||
* @param {Document | { cookie: string }} _document overwriteable for testing
|
* @param {Document | { cookie: string }} _document overwriteable for testing
|
||||||
* @returns {RequestInterceptor}
|
* @returns {RequestInterceptor}
|
||||||
*/
|
*/
|
||||||
export function createXSRFRequestInterceptor(cookieName, headerName, _document = document) {
|
export function createXsrfRequestInterceptor(cookieName, headerName, _document = document) {
|
||||||
/**
|
/**
|
||||||
* @type {RequestInterceptor}
|
* @type {RequestInterceptor}
|
||||||
*/
|
*/
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/**
|
/**
|
||||||
* @typedef {import('../types/types').LionRequestInit} LionRequestInit
|
* @typedef {import('../types/types').LionRequestInit} LionRequestInit
|
||||||
* @typedef {import('../types/types').AjaxClientConfig} AjaxClientConfig
|
* @typedef {import('../types/types').AjaxConfig} AjaxConfig
|
||||||
* @typedef {import('../types/types').RequestInterceptor} RequestInterceptor
|
* @typedef {import('../types/types').RequestInterceptor} RequestInterceptor
|
||||||
* @typedef {import('../types/types').ResponseInterceptor} ResponseInterceptor
|
* @typedef {import('../types/types').ResponseInterceptor} ResponseInterceptor
|
||||||
* @typedef {import('../types/types').CacheConfig} CacheConfig
|
* @typedef {import('../types/types').CacheConfig} CacheConfig
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,17 @@
|
||||||
import { expect } from '@open-wc/testing';
|
import { expect } from '@open-wc/testing';
|
||||||
import { stub, useFakeTimers } from 'sinon';
|
import { stub, useFakeTimers } from 'sinon';
|
||||||
import { AjaxClient, AjaxClientFetchError } from '@lion/ajax';
|
import { Ajax, AjaxFetchError } from '@lion/ajax';
|
||||||
|
|
||||||
describe('AjaxClient', () => {
|
describe('Ajax', () => {
|
||||||
/** @type {import('sinon').SinonStub} */
|
/** @type {import('sinon').SinonStub} */
|
||||||
let fetchStub;
|
let fetchStub;
|
||||||
/** @type {AjaxClient} */
|
/** @type {Ajax} */
|
||||||
let ajax;
|
let ajax;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fetchStub = stub(window, 'fetch');
|
fetchStub = stub(window, 'fetch');
|
||||||
fetchStub.returns(Promise.resolve(new Response('mock response')));
|
fetchStub.returns(Promise.resolve(new Response('mock response')));
|
||||||
ajax = new AjaxClient();
|
ajax = new Ajax();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|
@ -42,7 +42,7 @@ describe('AjaxClient', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
// When
|
// When
|
||||||
const ajax1 = new AjaxClient(config);
|
const ajax1 = new Ajax(config);
|
||||||
const result = ajax1.options;
|
const result = ajax1.options;
|
||||||
// Then
|
// Then
|
||||||
expect(result).to.deep.equal(expected);
|
expect(result).to.deep.equal(expected);
|
||||||
|
|
@ -58,7 +58,7 @@ describe('AjaxClient', () => {
|
||||||
};
|
};
|
||||||
// When
|
// When
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
const ajax1 = new AjaxClient(config);
|
const ajax1 = new Ajax(config);
|
||||||
const result = ajax1.options?.cacheOptions?.getCacheIdentifier;
|
const result = ajax1.options?.cacheOptions?.getCacheIdentifier;
|
||||||
// Then
|
// Then
|
||||||
expect(result).not.to.be.undefined;
|
expect(result).not.to.be.undefined;
|
||||||
|
|
@ -66,9 +66,9 @@ describe('AjaxClient', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('request()', () => {
|
describe('fetch()', () => {
|
||||||
it('calls fetch with the given args, returning the result', async () => {
|
it('calls fetch with the given args, returning the result', async () => {
|
||||||
const response = await (await ajax.request('/foo', { method: 'POST' })).text();
|
const response = await (await ajax.fetch('/foo', { method: 'POST' })).text();
|
||||||
|
|
||||||
expect(fetchStub).to.have.been.calledOnce;
|
expect(fetchStub).to.have.been.calledOnce;
|
||||||
const request = fetchStub.getCall(0).args[0];
|
const request = fetchStub.getCall(0).args[0];
|
||||||
|
|
@ -82,9 +82,9 @@ describe('AjaxClient', () => {
|
||||||
|
|
||||||
let thrown = false;
|
let thrown = false;
|
||||||
try {
|
try {
|
||||||
await ajax.request('/foo');
|
await ajax.fetch('/foo');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
expect(e).to.be.an.instanceOf(AjaxClientFetchError);
|
expect(e).to.be.an.instanceOf(AjaxFetchError);
|
||||||
expect(e.request).to.be.an.instanceOf(Request);
|
expect(e.request).to.be.an.instanceOf(Request);
|
||||||
expect(e.response).to.be.an.instanceOf(Response);
|
expect(e.response).to.be.an.instanceOf(Response);
|
||||||
thrown = true;
|
thrown = true;
|
||||||
|
|
@ -97,9 +97,9 @@ describe('AjaxClient', () => {
|
||||||
|
|
||||||
let thrown = false;
|
let thrown = false;
|
||||||
try {
|
try {
|
||||||
await ajax.request('/foo');
|
await ajax.fetch('/foo');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
expect(e).to.be.an.instanceOf(AjaxClientFetchError);
|
expect(e).to.be.an.instanceOf(AjaxFetchError);
|
||||||
expect(e.request).to.be.an.instanceOf(Request);
|
expect(e.request).to.be.an.instanceOf(Request);
|
||||||
expect(e.response).to.be.an.instanceOf(Response);
|
expect(e.response).to.be.an.instanceOf(Response);
|
||||||
thrown = true;
|
thrown = true;
|
||||||
|
|
@ -108,32 +108,32 @@ describe('AjaxClient', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('requestJson', () => {
|
describe('fetchtJson', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fetchStub.returns(Promise.resolve(new Response('{}')));
|
fetchStub.returns(Promise.resolve(new Response('{}')));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets json accept header', async () => {
|
it('sets json accept header', async () => {
|
||||||
await ajax.requestJson('/foo');
|
await ajax.fetchJson('/foo');
|
||||||
const request = fetchStub.getCall(0).args[0];
|
const request = fetchStub.getCall(0).args[0];
|
||||||
expect(request.headers.get('accept')).to.equal('application/json');
|
expect(request.headers.get('accept')).to.equal('application/json');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('decodes response from json', async () => {
|
it('decodes response from json', async () => {
|
||||||
fetchStub.returns(Promise.resolve(new Response('{"a":1,"b":2}')));
|
fetchStub.returns(Promise.resolve(new Response('{"a":1,"b":2}')));
|
||||||
const response = await ajax.requestJson('/foo');
|
const response = await ajax.fetchJson('/foo');
|
||||||
expect(response.body).to.eql({ a: 1, b: 2 });
|
expect(response.body).to.eql({ a: 1, b: 2 });
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('given a request body', () => {
|
describe('given a request body', () => {
|
||||||
it('encodes the request body as json', async () => {
|
it('encodes the request body as json', async () => {
|
||||||
await ajax.requestJson('/foo', { method: 'POST', body: { a: 1, b: 2 } });
|
await ajax.fetchJson('/foo', { method: 'POST', body: { a: 1, b: 2 } });
|
||||||
const request = fetchStub.getCall(0).args[0];
|
const request = fetchStub.getCall(0).args[0];
|
||||||
expect(await request.text()).to.equal('{"a":1,"b":2}');
|
expect(await request.text()).to.equal('{"a":1,"b":2}');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sets json content-type header', async () => {
|
it('sets json content-type header', async () => {
|
||||||
await ajax.requestJson('/foo', { method: 'POST', body: { a: 1, b: 2 } });
|
await ajax.fetchJson('/foo', { method: 'POST', body: { a: 1, b: 2 } });
|
||||||
const request = fetchStub.getCall(0).args[0];
|
const request = fetchStub.getCall(0).args[0];
|
||||||
expect(request.headers.get('content-type')).to.equal('application/json');
|
expect(request.headers.get('content-type')).to.equal('application/json');
|
||||||
});
|
});
|
||||||
|
|
@ -141,9 +141,9 @@ describe('AjaxClient', () => {
|
||||||
|
|
||||||
describe('given a json prefix', () => {
|
describe('given a json prefix', () => {
|
||||||
it('strips json prefix from response before decoding', async () => {
|
it('strips json prefix from response before decoding', async () => {
|
||||||
const localAjax = new AjaxClient({ jsonPrefix: '//.,!' });
|
const localAjax = new Ajax({ jsonPrefix: '//.,!' });
|
||||||
fetchStub.returns(Promise.resolve(new Response('//.,!{"a":1,"b":2}')));
|
fetchStub.returns(Promise.resolve(new Response('//.,!{"a":1,"b":2}')));
|
||||||
const response = await localAjax.requestJson('/foo');
|
const response = await localAjax.fetchJson('/foo');
|
||||||
expect(response.body).to.eql({ a: 1, b: 2 });
|
expect(response.body).to.eql({ a: 1, b: 2 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -154,7 +154,7 @@ describe('AjaxClient', () => {
|
||||||
ajax.addRequestInterceptor(async r => new Request(`${r.url}/intercepted-1`));
|
ajax.addRequestInterceptor(async r => new Request(`${r.url}/intercepted-1`));
|
||||||
ajax.addRequestInterceptor(async r => new Request(`${r.url}/intercepted-2`));
|
ajax.addRequestInterceptor(async r => new Request(`${r.url}/intercepted-2`));
|
||||||
|
|
||||||
await ajax.request('/foo', { method: 'POST' });
|
await ajax.fetch('/foo', { method: 'POST' });
|
||||||
|
|
||||||
const request = fetchStub.getCall(0).args[0];
|
const request = fetchStub.getCall(0).args[0];
|
||||||
expect(request.url).to.equal(`${window.location.origin}/foo/intercepted-1/intercepted-2`);
|
expect(request.url).to.equal(`${window.location.origin}/foo/intercepted-1/intercepted-2`);
|
||||||
|
|
@ -173,7 +173,7 @@ describe('AjaxClient', () => {
|
||||||
return new Response(`${body} intercepted-2`, { status, statusText, headers });
|
return new Response(`${body} intercepted-2`, { status, statusText, headers });
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await (await ajax.request('/foo', { method: 'POST' })).text();
|
const response = await (await ajax.fetch('/foo', { method: 'POST' })).text();
|
||||||
expect(response).to.equal('mock response intercepted-1 intercepted-2');
|
expect(response).to.equal('mock response intercepted-1 intercepted-2');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -190,7 +190,7 @@ describe('AjaxClient', () => {
|
||||||
ajax.addRequestInterceptor(interceptor3);
|
ajax.addRequestInterceptor(interceptor3);
|
||||||
ajax.removeRequestInterceptor(interceptor1);
|
ajax.removeRequestInterceptor(interceptor1);
|
||||||
|
|
||||||
await ajax.request('/foo', { method: 'POST' });
|
await ajax.fetch('/foo', { method: 'POST' });
|
||||||
|
|
||||||
const request = fetchStub.getCall(0).args[0];
|
const request = fetchStub.getCall(0).args[0];
|
||||||
expect(request.url).to.equal(`${window.location.origin}/foo/intercepted-2/intercepted-3`);
|
expect(request.url).to.equal(`${window.location.origin}/foo/intercepted-2/intercepted-3`);
|
||||||
|
|
@ -203,7 +203,7 @@ describe('AjaxClient', () => {
|
||||||
// @ts-expect-error we're mocking the response as a simple promise which returns a string
|
// @ts-expect-error we're mocking the response as a simple promise which returns a string
|
||||||
ajax.removeResponseInterceptor(interceptor);
|
ajax.removeResponseInterceptor(interceptor);
|
||||||
|
|
||||||
const response = await ajax.request('/foo', { method: 'POST' });
|
const response = await ajax.fetch('/foo', { method: 'POST' });
|
||||||
const text = await response.text();
|
const text = await response.text();
|
||||||
expect(text).to.equal('mock response');
|
expect(text).to.equal('mock response');
|
||||||
});
|
});
|
||||||
|
|
@ -211,14 +211,14 @@ describe('AjaxClient', () => {
|
||||||
|
|
||||||
describe('accept-language header', () => {
|
describe('accept-language header', () => {
|
||||||
it('is set by default based on localize.locale', async () => {
|
it('is set by default based on localize.locale', async () => {
|
||||||
await ajax.request('/foo');
|
await ajax.fetch('/foo');
|
||||||
const request = fetchStub.getCall(0).args[0];
|
const request = fetchStub.getCall(0).args[0];
|
||||||
expect(request.headers.get('accept-language')).to.equal('en');
|
expect(request.headers.get('accept-language')).to.equal('en');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('can be disabled', async () => {
|
it('can be disabled', async () => {
|
||||||
const customAjax = new AjaxClient({ addAcceptLanguage: false });
|
const customAjax = new Ajax({ addAcceptLanguage: false });
|
||||||
await customAjax.request('/foo');
|
await customAjax.fetch('/foo');
|
||||||
const request = fetchStub.getCall(0).args[0];
|
const request = fetchStub.getCall(0).args[0];
|
||||||
expect(request.headers.has('accept-language')).to.be.false;
|
expect(request.headers.has('accept-language')).to.be.false;
|
||||||
});
|
});
|
||||||
|
|
@ -237,27 +237,27 @@ describe('AjaxClient', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('XSRF token header is set based on cookie', async () => {
|
it('XSRF token header is set based on cookie', async () => {
|
||||||
await ajax.request('/foo');
|
await ajax.fetch('/foo');
|
||||||
|
|
||||||
const request = fetchStub.getCall(0).args[0];
|
const request = fetchStub.getCall(0).args[0];
|
||||||
expect(request.headers.get('X-XSRF-TOKEN')).to.equal('1234');
|
expect(request.headers.get('X-XSRF-TOKEN')).to.equal('1234');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('XSRF behavior can be disabled', async () => {
|
it('XSRF behavior can be disabled', async () => {
|
||||||
const customAjax = new AjaxClient({ xsrfCookieName: null, xsrfHeaderName: null });
|
const customAjax = new Ajax({ xsrfCookieName: null, xsrfHeaderName: null });
|
||||||
await customAjax.request('/foo');
|
await customAjax.fetch('/foo');
|
||||||
await ajax.request('/foo');
|
await ajax.fetch('/foo');
|
||||||
|
|
||||||
const request = fetchStub.getCall(0).args[0];
|
const request = fetchStub.getCall(0).args[0];
|
||||||
expect(request.headers.has('X-XSRF-TOKEN')).to.be.false;
|
expect(request.headers.has('X-XSRF-TOKEN')).to.be.false;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('XSRF token header and cookie can be customized', async () => {
|
it('XSRF token header and cookie can be customized', async () => {
|
||||||
const customAjax = new AjaxClient({
|
const customAjax = new Ajax({
|
||||||
xsrfCookieName: 'CSRF-TOKEN',
|
xsrfCookieName: 'CSRF-TOKEN',
|
||||||
xsrfHeaderName: 'X-CSRF-TOKEN',
|
xsrfHeaderName: 'X-CSRF-TOKEN',
|
||||||
});
|
});
|
||||||
await customAjax.request('/foo');
|
await customAjax.fetch('/foo');
|
||||||
|
|
||||||
const request = fetchStub.getCall(0).args[0];
|
const request = fetchStub.getCall(0).args[0];
|
||||||
expect(request.headers.get('X-CSRF-TOKEN')).to.equal('5678');
|
expect(request.headers.get('X-CSRF-TOKEN')).to.equal('5678');
|
||||||
|
|
@ -283,9 +283,9 @@ describe('AjaxClient', () => {
|
||||||
getCacheIdentifier = () => String(cacheId);
|
getCacheIdentifier = () => String(cacheId);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows configuring cache interceptors on the AjaxClient config', async () => {
|
it('allows configuring cache interceptors on the Ajax config', async () => {
|
||||||
newCacheId();
|
newCacheId();
|
||||||
const customAjax = new AjaxClient({
|
const customAjax = new Ajax({
|
||||||
cacheOptions: {
|
cacheOptions: {
|
||||||
useCache: true,
|
useCache: true,
|
||||||
timeToLive: 100,
|
timeToLive: 100,
|
||||||
|
|
@ -298,22 +298,22 @@ describe('AjaxClient', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Smoke test 1: verify caching works
|
// Smoke test 1: verify caching works
|
||||||
await customAjax.request('/foo');
|
await customAjax.fetch('/foo');
|
||||||
expect(fetchStub.callCount).to.equal(1);
|
expect(fetchStub.callCount).to.equal(1);
|
||||||
await customAjax.request('/foo');
|
await customAjax.fetch('/foo');
|
||||||
expect(fetchStub.callCount).to.equal(1);
|
expect(fetchStub.callCount).to.equal(1);
|
||||||
|
|
||||||
// Smoke test 2: verify caching is invalidated on non-get method
|
// Smoke test 2: verify caching is invalidated on non-get method
|
||||||
await customAjax.request('/foo', { method: 'POST' });
|
await customAjax.fetch('/foo', { method: 'POST' });
|
||||||
expect(fetchStub.callCount).to.equal(2);
|
expect(fetchStub.callCount).to.equal(2);
|
||||||
await customAjax.request('/foo');
|
await customAjax.fetch('/foo');
|
||||||
expect(fetchStub.callCount).to.equal(3);
|
expect(fetchStub.callCount).to.equal(3);
|
||||||
|
|
||||||
// Smoke test 3: verify caching is invalidated after TTL has passed
|
// Smoke test 3: verify caching is invalidated after TTL has passed
|
||||||
await customAjax.request('/foo');
|
await customAjax.fetch('/foo');
|
||||||
expect(fetchStub.callCount).to.equal(3);
|
expect(fetchStub.callCount).to.equal(3);
|
||||||
clock.tick(101);
|
clock.tick(101);
|
||||||
await customAjax.request('/foo');
|
await customAjax.fetch('/foo');
|
||||||
expect(fetchStub.callCount).to.equal(4);
|
expect(fetchStub.callCount).to.equal(4);
|
||||||
clock.restore();
|
clock.restore();
|
||||||
});
|
});
|
||||||
|
|
@ -326,7 +326,7 @@ describe('AjaxClient', () => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const { signal } = controller;
|
const { signal } = controller;
|
||||||
// Have to do a "real" request to be able to abort it and verify that this throws
|
// Have to do a "real" request to be able to abort it and verify that this throws
|
||||||
const req = ajax.request(new URL('./foo.json', import.meta.url).pathname, {
|
const req = ajax.fetch(new URL('./foo.json', import.meta.url).pathname, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import { expect } from '@open-wc/testing';
|
|
||||||
import { ajax, setAjax, AjaxClient } from '@lion/ajax';
|
|
||||||
|
|
||||||
describe('ajax', () => {
|
|
||||||
it('exports an instance of AjaxClient', () => {
|
|
||||||
expect(ajax).to.be.an.instanceOf(AjaxClient);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('can replace ajax with another instance', () => {
|
|
||||||
const newAjax = new AjaxClient();
|
|
||||||
setAjax(newAjax);
|
|
||||||
expect(ajax).to.equal(newAjax);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
35
packages/ajax/test/index.test.js
Normal file
35
packages/ajax/test/index.test.js
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { expect } from '@open-wc/testing';
|
||||||
|
import {
|
||||||
|
ajax,
|
||||||
|
Ajax,
|
||||||
|
AjaxFetchError,
|
||||||
|
acceptLanguageRequestInterceptor,
|
||||||
|
createXsrfRequestInterceptor,
|
||||||
|
createCacheInterceptors,
|
||||||
|
} from '@lion/ajax';
|
||||||
|
|
||||||
|
describe('public interface', () => {
|
||||||
|
it('exports ajax', () => {
|
||||||
|
expect(ajax).to.exist;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports Ajax', () => {
|
||||||
|
expect(Ajax).to.exist;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports AjaxFetchError', () => {
|
||||||
|
expect(AjaxFetchError).to.exist;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports acceptLanguageRequestInterceptor', () => {
|
||||||
|
expect(acceptLanguageRequestInterceptor).to.exist;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports createXsrfRequestInterceptor', () => {
|
||||||
|
expect(createXsrfRequestInterceptor).to.exist;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exports createCacheInterceptors', () => {
|
||||||
|
expect(createCacheInterceptors).to.exist;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,521 +0,0 @@
|
||||||
import { aTimeout, expect } from '@open-wc/testing';
|
|
||||||
import { spy, stub, useFakeTimers } from 'sinon';
|
|
||||||
import '../src/typedef.js';
|
|
||||||
|
|
||||||
import { cacheRequestInterceptorFactory, cacheResponseInterceptorFactory, ajax } from '../index.js';
|
|
||||||
|
|
||||||
describe('ajax cache', () => {
|
|
||||||
/** @type {number | undefined} */
|
|
||||||
let cacheId;
|
|
||||||
/** @type {import('sinon').SinonStub} */
|
|
||||||
let fetchStub;
|
|
||||||
/** @type {() => string} */
|
|
||||||
let getCacheIdentifier;
|
|
||||||
|
|
||||||
const newCacheId = () => {
|
|
||||||
if (!cacheId) {
|
|
||||||
cacheId = 1;
|
|
||||||
} else {
|
|
||||||
cacheId += 1;
|
|
||||||
}
|
|
||||||
return cacheId;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {ajax} ajaxInstance
|
|
||||||
* @param {CacheOptions} options
|
|
||||||
*/
|
|
||||||
const addCacheInterceptors = (ajaxInstance, options) => {
|
|
||||||
const requestInterceptorIndex =
|
|
||||||
ajaxInstance._requestInterceptors.push(
|
|
||||||
cacheRequestInterceptorFactory(getCacheIdentifier, options),
|
|
||||||
) - 1;
|
|
||||||
|
|
||||||
const responseInterceptorIndex =
|
|
||||||
ajaxInstance._responseInterceptors.push(
|
|
||||||
cacheResponseInterceptorFactory(getCacheIdentifier, options),
|
|
||||||
) - 1;
|
|
||||||
|
|
||||||
return {
|
|
||||||
requestInterceptorIndex,
|
|
||||||
responseInterceptorIndex,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {ajax} ajaxInstance
|
|
||||||
* @param {{requestInterceptorIndex: number, responseInterceptorIndex: number}} indexes
|
|
||||||
*/
|
|
||||||
const removeCacheInterceptors = (
|
|
||||||
ajaxInstance,
|
|
||||||
{ requestInterceptorIndex, responseInterceptorIndex },
|
|
||||||
) => {
|
|
||||||
ajaxInstance._requestInterceptors.splice(requestInterceptorIndex, 1);
|
|
||||||
ajaxInstance._responseInterceptors.splice(responseInterceptorIndex, 1);
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
getCacheIdentifier = () => String(cacheId);
|
|
||||||
fetchStub = stub(window, 'fetch');
|
|
||||||
fetchStub.returns(Promise.resolve(new Response('mock response')));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
fetchStub.restore();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Original ajax instance', () => {
|
|
||||||
it('allows direct ajax calls without cache interceptors configured', async () => {
|
|
||||||
await ajax.request('/test');
|
|
||||||
expect(fetchStub.callCount).to.equal(1);
|
|
||||||
await ajax.request('/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.request('/test').catch(
|
|
||||||
/** @param {Error} err */ err => {
|
|
||||||
expect(err.message).to.equal('getCacheIdentifier returns falsy');
|
|
||||||
|
|
||||||
removeCacheInterceptors(ajax, indexes);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws when using methods other than `['get']`", () => {
|
|
||||||
newCacheId();
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
const indexes = addCacheInterceptors(ajax, {
|
|
||||||
useCache: true,
|
|
||||||
methods: ['get', 'post'],
|
|
||||||
});
|
|
||||||
removeCacheInterceptors(ajax, indexes);
|
|
||||||
}).to.throw(/not yet supported/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws error when requestIdentificationFn is not a function', () => {
|
|
||||||
newCacheId();
|
|
||||||
|
|
||||||
expect(() => {
|
|
||||||
const indexes = addCacheInterceptors(ajax, {
|
|
||||||
useCache: true,
|
|
||||||
// @ts-ignore needed for test
|
|
||||||
requestIdentificationFn: 'not a function',
|
|
||||||
});
|
|
||||||
removeCacheInterceptors(ajax, indexes);
|
|
||||||
}).to.throw(/Property `requestIdentificationFn` must be of type `function`/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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, 'request');
|
|
||||||
|
|
||||||
await ajax.request('/test');
|
|
||||||
expect(ajaxRequestSpy.calledOnce).to.be.true;
|
|
||||||
expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
|
|
||||||
await ajax.request('/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, 'request');
|
|
||||||
|
|
||||||
await ajax.request('/test');
|
|
||||||
expect(ajaxRequestSpy.calledOnce).to.be.true;
|
|
||||||
expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
|
|
||||||
expect(fetchStub.callCount).to.equal(1);
|
|
||||||
await ajax.request('/test');
|
|
||||||
expect(fetchStub.callCount).to.equal(2);
|
|
||||||
await ajax.request('/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, 'request');
|
|
||||||
|
|
||||||
await ajax.request('/test', {
|
|
||||||
params: {
|
|
||||||
q: 'test',
|
|
||||||
page: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(ajaxRequestSpy.calledOnce).to.be.true;
|
|
||||||
expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
|
|
||||||
await ajax.request('/test', {
|
|
||||||
params: {
|
|
||||||
q: 'test',
|
|
||||||
page: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(fetchStub.callCount).to.equal(1);
|
|
||||||
// a request with different param should not be cached
|
|
||||||
await ajax.request('/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, 'request');
|
|
||||||
|
|
||||||
await ajax.request('/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.request('/test');
|
|
||||||
expect(fetchStub.callCount).to.equal(1);
|
|
||||||
clock.tick(5100);
|
|
||||||
await ajax.request('/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.request('/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.request('/test'); // new url
|
|
||||||
expect(fetchStub.callCount).to.equal(1);
|
|
||||||
await ajax.request('/test'); // cached
|
|
||||||
expect(fetchStub.callCount).to.equal(1);
|
|
||||||
|
|
||||||
await ajax.request('/foo-request-1'); // new url
|
|
||||||
expect(fetchStub.callCount).to.equal(2);
|
|
||||||
await ajax.request('/foo-request-1'); // cached
|
|
||||||
expect(fetchStub.callCount).to.equal(2);
|
|
||||||
|
|
||||||
await ajax.request('/foo-request-3'); // new url
|
|
||||||
expect(fetchStub.callCount).to.equal(3);
|
|
||||||
|
|
||||||
await ajax.request('/test', { method: 'POST' }); // clear cache
|
|
||||||
expect(fetchStub.callCount).to.equal(4);
|
|
||||||
await ajax.request('/foo-request-1'); // not cached anymore
|
|
||||||
expect(fetchStub.callCount).to.equal(5);
|
|
||||||
await ajax.request('/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.request('/test');
|
|
||||||
await ajax.request('/test'); // cached
|
|
||||||
expect(fetchStub.callCount).to.equal(1);
|
|
||||||
await ajax.request('/posts');
|
|
||||||
expect(fetchStub.callCount).to.equal(2);
|
|
||||||
await ajax.request('/posts'); // cached
|
|
||||||
expect(fetchStub.callCount).to.equal(2);
|
|
||||||
await ajax.request('/posts/1');
|
|
||||||
expect(fetchStub.callCount).to.equal(3);
|
|
||||||
await ajax.request('/posts/1'); // cached
|
|
||||||
expect(fetchStub.callCount).to.equal(3);
|
|
||||||
// cleans cache for defined urls
|
|
||||||
await ajax.request('/test', { method: 'POST' });
|
|
||||||
expect(fetchStub.callCount).to.equal(4);
|
|
||||||
await ajax.request('/posts'); // no longer cached => new request
|
|
||||||
expect(fetchStub.callCount).to.equal(5);
|
|
||||||
await ajax.request('/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, 'request');
|
|
||||||
const indexes = addCacheInterceptors(ajax, {
|
|
||||||
useCache: true,
|
|
||||||
timeToLive: 1000 * 60 * 60,
|
|
||||||
});
|
|
||||||
|
|
||||||
await ajax.request('/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.request('/test-hour');
|
|
||||||
expect(fetchStub.callCount).to.equal(1);
|
|
||||||
clock.tick(1000 * 60 * 2); // +2 minutes => 1:01 hour
|
|
||||||
await ajax.request('/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.request('/test-valid-url', { ...actionConfig });
|
|
||||||
expect(fetchStub.callCount).to.equal(1);
|
|
||||||
await ajax.request('/test-invalid-url');
|
|
||||||
expect(fetchStub.callCount).to.equal(2);
|
|
||||||
// 'post' will invalidate 'own' cache and the one mentioned in config
|
|
||||||
await ajax.request('/test-valid-url', { ...actionConfig, method: 'POST' });
|
|
||||||
expect(fetchStub.callCount).to.equal(3);
|
|
||||||
await ajax.request('/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, 'request');
|
|
||||||
|
|
||||||
await ajax.request('/test-post');
|
|
||||||
expect(ajaxRequestSpy.calledOnce).to.be.true;
|
|
||||||
expect(ajaxRequestSpy.calledWith('/test-post')).to.be.true;
|
|
||||||
expect(fetchStub.callCount).to.equal(1);
|
|
||||||
await ajax.request('/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.request('/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, 'request');
|
|
||||||
|
|
||||||
await ajax.request('/test');
|
|
||||||
const clock = useFakeTimers();
|
|
||||||
expect(ajaxRequestSpy.calledOnce).to.be.true;
|
|
||||||
expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
|
|
||||||
clock.tick(1);
|
|
||||||
clock.restore();
|
|
||||||
await ajax.request('/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, 'request');
|
|
||||||
const indexes = addCacheInterceptors(ajax, { useCache: true });
|
|
||||||
|
|
||||||
await ajax.request('/test');
|
|
||||||
expect(ajaxAlwaysRequestSpy.calledOnce, 'calledOnce').to.be.true;
|
|
||||||
expect(ajaxAlwaysRequestSpy.calledWith('/test'));
|
|
||||||
await ajax.request('/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, 'request');
|
|
||||||
|
|
||||||
const request1 = ajax.request('/test');
|
|
||||||
const request2 = ajax.request('/test');
|
|
||||||
await aTimeout(1);
|
|
||||||
const request3 = ajax.request('/test');
|
|
||||||
await aTimeout(3);
|
|
||||||
const request4 = ajax.request('/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, 'request');
|
|
||||||
|
|
||||||
const response1 = await ajax.request('/test');
|
|
||||||
const response2 = await ajax.request('/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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
import { expect } from '@open-wc/testing';
|
import { aTimeout, expect } from '@open-wc/testing';
|
||||||
import {
|
import { spy, stub, useFakeTimers } from 'sinon';
|
||||||
createXSRFRequestInterceptor,
|
import '../src/typedef.js';
|
||||||
getCookie,
|
import { acceptLanguageRequestInterceptor } from '../src/interceptors/acceptLanguageHeader.js';
|
||||||
acceptLanguageRequestInterceptor,
|
import { createXsrfRequestInterceptor, getCookie } from '../src/interceptors/xsrfHeader.js';
|
||||||
} from '@lion/ajax';
|
import { createCacheInterceptors } from '../src/interceptors/cacheInterceptors.js';
|
||||||
|
import { Ajax } from '../index.js';
|
||||||
|
|
||||||
|
const ajax = new Ajax();
|
||||||
|
|
||||||
describe('interceptors', () => {
|
describe('interceptors', () => {
|
||||||
describe('getCookie()', () => {
|
describe('getCookie()', () => {
|
||||||
|
|
@ -40,9 +43,9 @@ describe('interceptors', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createXSRFRequestInterceptor()', () => {
|
describe('createXsrfRequestInterceptor()', () => {
|
||||||
it('adds the xsrf token header to the request', () => {
|
it('adds the xsrf token header to the request', () => {
|
||||||
const interceptor = createXSRFRequestInterceptor('XSRF-TOKEN', 'X-XSRF-TOKEN', {
|
const interceptor = createXsrfRequestInterceptor('XSRF-TOKEN', 'X-XSRF-TOKEN', {
|
||||||
cookie: 'XSRF-TOKEN=foo',
|
cookie: 'XSRF-TOKEN=foo',
|
||||||
});
|
});
|
||||||
const request = new Request('/foo/');
|
const request = new Request('/foo/');
|
||||||
|
|
@ -51,7 +54,7 @@ describe('interceptors', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not set anything if the cookie is not there', () => {
|
it('does not set anything if the cookie is not there', () => {
|
||||||
const interceptor = createXSRFRequestInterceptor('XSRF-TOKEN', 'X-XSRF-TOKEN', {
|
const interceptor = createXsrfRequestInterceptor('XSRF-TOKEN', 'X-XSRF-TOKEN', {
|
||||||
cookie: 'XXSRF-TOKEN=foo',
|
cookie: 'XXSRF-TOKEN=foo',
|
||||||
});
|
});
|
||||||
const request = new Request('/foo/');
|
const request = new Request('/foo/');
|
||||||
|
|
@ -59,4 +62,525 @@ describe('interceptors', () => {
|
||||||
expect(request.headers.get('X-XSRF-TOKEN')).to.equal(null);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
6
packages/ajax/types/types.d.ts
vendored
6
packages/ajax/types/types.d.ts
vendored
|
|
@ -1,5 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* We have a method requestJson that encodes JS Object to
|
* We have a method fetchJson that encodes JS Object to
|
||||||
* a string automatically for `body` property.
|
* a string automatically for `body` property.
|
||||||
* Sadly, Typescript doesn't allow us to extend RequestInit
|
* Sadly, Typescript doesn't allow us to extend RequestInit
|
||||||
* and override body prop because it is incompatible, so we
|
* and override body prop because it is incompatible, so we
|
||||||
|
|
@ -10,7 +10,7 @@ export interface LionRequestInit extends Omit<RequestInit, 'body'> {
|
||||||
request?: CacheRequest;
|
request?: CacheRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AjaxClientConfig {
|
export interface AjaxConfig {
|
||||||
addAcceptLanguage: boolean;
|
addAcceptLanguage: boolean;
|
||||||
xsrfCookieName: string | null;
|
xsrfCookieName: string | null;
|
||||||
xsrfHeaderName: string | null;
|
xsrfHeaderName: string | null;
|
||||||
|
|
@ -29,7 +29,7 @@ export type Params = { [key: string]: any };
|
||||||
|
|
||||||
export type RequestIdentificationFn = (
|
export type RequestIdentificationFn = (
|
||||||
request: Partial<CacheRequest>,
|
request: Partial<CacheRequest>,
|
||||||
searchParamsSerializer: (params: Params) => string,
|
stringifySearchParams: (params: Params) => string,
|
||||||
) => string;
|
) => string;
|
||||||
|
|
||||||
export interface CacheOptions {
|
export interface CacheOptions {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue