feat: add ajax cache improvements and demos/docs
This commit is contained in:
parent
0d97ab5475
commit
2cd7993da8
10 changed files with 465 additions and 189 deletions
5
.changeset/eleven-tips-grow.md
Normal file
5
.changeset/eleven-tips-grow.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
'@lion/ajax': minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Set fromCache property on the Response, for user consumption. Allow setting cacheOptions on the AjaxClient upon instantiation. Create docs/demos.
|
||||||
|
|
@ -3,7 +3,7 @@ const path = require('path');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
stories: [
|
stories: [
|
||||||
'../{packages,packages-node}/!(ajax)*/README.md',
|
'../{packages,packages-node}/*/README.md',
|
||||||
'../{packages,packages-node}/*/docs/*.md',
|
'../{packages,packages-node}/*/docs/*.md',
|
||||||
'../{packages,packages-node}/*/docs/!(assets)**/*.md',
|
'../{packages,packages-node}/*/docs/!(assets)**/*.md',
|
||||||
'../packages/helpers/*/README.md',
|
'../packages/helpers/*/README.md',
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,36 @@
|
||||||
|
|
||||||
# Ajax
|
# Ajax
|
||||||
|
|
||||||
|
```js script
|
||||||
|
import { html } from '@lion/core';
|
||||||
|
import { renderLitAsNode } from '@lion/helpers';
|
||||||
|
import { ajax, AjaxClient, cacheRequestInterceptorFactory, cacheResponseInterceptorFactory } from '@lion/ajax';
|
||||||
|
import '@lion/helpers/sb-action-logger';
|
||||||
|
|
||||||
|
const getCacheIdentifier = () => {
|
||||||
|
let userId = localStorage.getItem('lion-ajax-cache-demo-user-id');
|
||||||
|
if (!userId) {
|
||||||
|
localStorage.setItem('lion-ajax-cache-demo-user-id', '1');
|
||||||
|
userId = '1';
|
||||||
|
}
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheOptions = {
|
||||||
|
useCache: true,
|
||||||
|
timeToLive: 1000 * 60 * 10, // 10 minutes
|
||||||
|
};
|
||||||
|
|
||||||
|
ajax.addRequestInterceptor(cacheRequestInterceptorFactory(getCacheIdentifier, cacheOptions));
|
||||||
|
ajax.addResponseInterceptor(
|
||||||
|
cacheResponseInterceptorFactory(getCacheIdentifier, cacheOptions),
|
||||||
|
);
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Ajax/Ajax',
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
`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
|
||||||
|
|
@ -27,11 +57,27 @@ npm i --save @lion/ajax
|
||||||
|
|
||||||
#### GET request
|
#### GET request
|
||||||
|
|
||||||
```js
|
```js preview-story
|
||||||
import { ajax } from '@lion/ajax';
|
export const getRequest = () => {
|
||||||
|
const actionLogger = renderLitAsNode(html`<sb-action-logger></sb-action-logger>`);
|
||||||
const response = await ajax.request('/api/users');
|
const fetchHandler = (name) => {
|
||||||
const users = await response.json();
|
ajax.request(`./packages/ajax/docs/${name}.json`)
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(result => {
|
||||||
|
actionLogger.log(JSON.stringify(result, null, 2));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return html`
|
||||||
|
<style>
|
||||||
|
sb-action-logger {
|
||||||
|
--sb-action-logger-max-height: 300px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<button @click=${() => fetchHandler('pabu')}>Fetch Pabu</button>
|
||||||
|
<button @click=${() => fetchHandler('naga')}>Fetch Naga</button>
|
||||||
|
${actionLogger}
|
||||||
|
`;
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### POST request
|
#### POST request
|
||||||
|
|
@ -48,14 +94,33 @@ 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 `requestJson` 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`.
|
||||||
|
|
||||||
#### GET JSON request
|
#### GET JSON request
|
||||||
|
|
||||||
```js
|
```js preview-story
|
||||||
import { ajax } from '@lion/ajax';
|
export const getJsonRequest = () => {
|
||||||
|
const actionLogger = renderLitAsNode(html`<sb-action-logger></sb-action-logger>`);
|
||||||
const { response, body } = await ajax.requestJson('/api/users');
|
const fetchHandler = (name) => {
|
||||||
|
ajax.requestJson(`./packages/ajax/docs/${name}.json`)
|
||||||
|
.then(result => {
|
||||||
|
console.log(result.response);
|
||||||
|
actionLogger.log(JSON.stringify(result.body, null, 2));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return html`
|
||||||
|
<style>
|
||||||
|
sb-action-logger {
|
||||||
|
--sb-action-logger-max-height: 300px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<button @click=${() => fetchHandler('pabu')}>Fetch Pabu</button>
|
||||||
|
<button @click=${() => fetchHandler('naga')}>Fetch Naga</button>
|
||||||
|
${actionLogger}
|
||||||
|
`;
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### POST JSON request
|
#### POST JSON request
|
||||||
|
|
@ -73,9 +138,10 @@ const { response, body } = await ajax.requestJson('/api/users', {
|
||||||
|
|
||||||
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
|
```js preview-story
|
||||||
import { ajax } from '@lion/ajax';
|
export const errorHandling = () => {
|
||||||
|
const actionLogger = renderLitAsNode(html`<sb-action-logger></sb-action-logger>`);
|
||||||
|
const fetchHandler = async () => {
|
||||||
try {
|
try {
|
||||||
const users = await ajax.requestJson('/api/users');
|
const users = await ajax.requestJson('/api/users');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -83,22 +149,43 @@ try {
|
||||||
if (error.response.status === 400) {
|
if (error.response.status === 400) {
|
||||||
// handle a specific status code, for example 400 bad request
|
// handle a specific status code, for example 400 bad request
|
||||||
} else {
|
} else {
|
||||||
console.error(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
|
||||||
console.error(error);
|
actionLogger.log(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return html`
|
||||||
|
<style>
|
||||||
|
sb-action-logger {
|
||||||
|
--sb-action-logger-max-height: 300px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<button @click=${fetchHandler}>Fetch</button>
|
||||||
|
${actionLogger}
|
||||||
|
`;
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Fetch Polyfill
|
||||||
|
|
||||||
|
For IE11 you will need a polyfill for fetch. You should add this on your top level layer, e.g. your application.
|
||||||
|
|
||||||
|
[This is the polyfill we recommend](https://github.com/github/fetch). It also has a [section for polyfilling AbortController](https://github.com/github/fetch#aborting-requests)
|
||||||
|
|
||||||
## Ajax Cache
|
## Ajax Cache
|
||||||
|
|
||||||
A caching library that uses `lion-web/ajax` and adds cache interceptors to provide caching for use in
|
A caching library that uses `@lion/ajax` and adds cache interceptors to provide caching for use in
|
||||||
frontend `services`.
|
frontend `services`.
|
||||||
|
|
||||||
> Technical documentation and decisions can be found in
|
The **request interceptor**'s main goal is to determine whether or not to
|
||||||
> [./docs/technical-docs.md](./docs/technical-docs.md)
|
**return the cached object**. This is done based on the options that are being
|
||||||
|
passed.
|
||||||
|
|
||||||
|
The **response interceptor**'s goal is to determine **when to cache** the
|
||||||
|
requested response, based on the options that are being passed.
|
||||||
|
|
||||||
### Getting started
|
### Getting started
|
||||||
|
|
||||||
|
|
@ -133,113 +220,247 @@ ajax.addResponseInterceptor(
|
||||||
const { response, body } = await ajax.requestJson('/my-url');
|
const { response, body } = await ajax.requestJson('/my-url');
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Alternatively, most often for subclassers, you can extend or import `AjaxClient` yourself, and pass cacheOptions when instantiating the ajax singleton.
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { AjaxClient } from '@lion/ajax';
|
||||||
|
|
||||||
|
export const ajax = new AjaxClient({
|
||||||
|
cacheOptions: {
|
||||||
|
useCache: true,
|
||||||
|
timeToLive: 1000 * 60 * 5, // 5 minutes
|
||||||
|
getCacheIdentifier: () => getActiveProfile().profileId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
### Ajax cache example
|
### Ajax cache example
|
||||||
|
|
||||||
```js
|
> Let's assume that we have a user session, for this demo purposes we already created an identifier function for this and set the cache interceptors.
|
||||||
import {
|
|
||||||
ajax,
|
|
||||||
cacheRequestInterceptorFactory,
|
|
||||||
cacheResponseInterceptorFactory,
|
|
||||||
} from '@lion-web/ajax';
|
|
||||||
|
|
||||||
const getCacheIdentifier = () => getActiveProfile().profileId;
|
We can see if a response is served from the cache by checking the `response.fromCache` property,
|
||||||
|
which is either undefined for normal requests, or set to true for responses that were served from cache.
|
||||||
|
|
||||||
const globalCacheOptions = {
|
```js preview-story
|
||||||
|
export const cache = () => {
|
||||||
|
const actionLogger = renderLitAsNode(html`<sb-action-logger></sb-action-logger>`);
|
||||||
|
const fetchHandler = (name) => {
|
||||||
|
ajax.requestJson(`./packages/ajax/docs/${name}.json`)
|
||||||
|
.then(result => {
|
||||||
|
actionLogger.log(`From cache: ${result.response.fromCache || false}`);
|
||||||
|
actionLogger.log(JSON.stringify(result.body, null, 2));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return html`
|
||||||
|
<style>
|
||||||
|
sb-action-logger {
|
||||||
|
--sb-action-logger-max-height: 300px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<button @click=${() => fetchHandler('pabu')}>Fetch Pabu</button>
|
||||||
|
<button @click=${() => fetchHandler('naga')}>Fetch Naga</button>
|
||||||
|
${actionLogger}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also change the cache options per request, which is handy if you don't want to remove and re-add the interceptors for a simple configuration change.
|
||||||
|
|
||||||
|
In this demo, when we fetch naga, we always pass `useCache: false` so the Response is never a cached one.
|
||||||
|
|
||||||
|
```js preview-story
|
||||||
|
export const cacheActionOptions = () => {
|
||||||
|
const actionLogger = renderLitAsNode(html`<sb-action-logger></sb-action-logger>`);
|
||||||
|
const fetchHandler = (name) => {
|
||||||
|
let actionCacheOptions;
|
||||||
|
if (name === 'naga') {
|
||||||
|
actionCacheOptions = {
|
||||||
useCache: false,
|
useCache: false,
|
||||||
timeToLive: 50, // default: one hour (the cache instance will be replaced in 1 hour, regardless of this setting)
|
}
|
||||||
methods: ['get'], // default: ['get'] NOTE for now only 'get' is supported
|
}
|
||||||
// requestIdentificationFn: (requestConfig) => { }, // see docs below for more info
|
|
||||||
// invalidateUrls: [], see docs below for more info
|
|
||||||
// invalidateUrlsRegex: RegExp, // see docs below for more info
|
|
||||||
};
|
|
||||||
|
|
||||||
// pass a function to the interceptorFactory that retrieves a cache identifier
|
ajax.requestJson(`./packages/ajax/docs/${name}.json`, { cacheOptions: actionCacheOptions })
|
||||||
// ajax.interceptors.request.use(cacheRequestInterceptorFactory(getCacheIdentifier, cacheOptions));
|
.then(result => {
|
||||||
// ajax.interceptors.response.use(
|
actionLogger.log(`From cache: ${result.response.fromCache || false}`);
|
||||||
// cacheResponseInterceptorFactory(getCacheIdentifier, cacheOptions),
|
actionLogger.log(JSON.stringify(result.body, null, 2));
|
||||||
// );
|
});
|
||||||
|
}
|
||||||
|
return html`
|
||||||
|
<style>
|
||||||
|
sb-action-logger {
|
||||||
|
--sb-action-logger-max-height: 300px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<button @click=${() => fetchHandler('pabu')}>Fetch Pabu</button>
|
||||||
|
<button @click=${() => fetchHandler('naga')}>Fetch Naga</button>
|
||||||
|
${actionLogger}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
class TodoService {
|
### Invalidating cache
|
||||||
constructor() {
|
|
||||||
this.localAjaxConfig = {
|
Invalidating the cache, or cache busting, can be done in multiple ways:
|
||||||
|
|
||||||
|
- Going past the `timeToLive` of the cache object
|
||||||
|
- Changing cache identifier (e.g. user session or active profile changes)
|
||||||
|
- Doing a non GET request to the cached endpoint
|
||||||
|
- Invalidates the cache of that endpoint
|
||||||
|
- Invalidates the cache of all other endpoints matching `invalidatesUrls` and `invalidateUrlsRegex`
|
||||||
|
|
||||||
|
#### Time to live
|
||||||
|
|
||||||
|
In this demo we pass a timeToLive of three seconds.
|
||||||
|
Try clicking the fetch button and watch fromCache change whenever TTL expires.
|
||||||
|
|
||||||
|
After TTL expires, the next request will set the cache again, and for the next 3 seconds you will get cached responses for subsequent requests.
|
||||||
|
|
||||||
|
```js preview-story
|
||||||
|
export const cacheTimeToLive = () => {
|
||||||
|
const actionLogger = renderLitAsNode(html`<sb-action-logger></sb-action-logger>`);
|
||||||
|
const fetchHandler = () => {
|
||||||
|
ajax.requestJson(`./packages/ajax/docs/pabu.json`, {
|
||||||
cacheOptions: {
|
cacheOptions: {
|
||||||
invalidateUrls: ['/api/todosbykeyword'], // default: []
|
timeToLive: 1000 * 3, // 3 seconds
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
})
|
||||||
/**
|
.then(result => {
|
||||||
* Returns all todos from cache if not older than 5 minutes
|
actionLogger.log(`From cache: ${result.response.fromCache || false}`);
|
||||||
*/
|
actionLogger.log(JSON.stringify(result.body, null, 2));
|
||||||
getTodos() {
|
});
|
||||||
return ajax.requestJson(`/api/todos`, this.localAjaxConfig);
|
|
||||||
}
|
}
|
||||||
|
return html`
|
||||||
/**
|
<style>
|
||||||
*
|
sb-action-logger {
|
||||||
*/
|
--sb-action-logger-max-height: 300px;
|
||||||
getTodosByKeyword(keyword) {
|
|
||||||
return ajax.requestJson(`/api/todosbykeyword/${keyword}`, this.localAjaxConfig);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates new todo and invalidates cache.
|
|
||||||
* `getTodos` will NOT take the response from cache
|
|
||||||
*/
|
|
||||||
saveTodo(todo) {
|
|
||||||
return ajax.requestJson(`/api/todos`, { method: 'POST', body: todo, ...this.localAjaxConfig });
|
|
||||||
}
|
}
|
||||||
|
</style>
|
||||||
|
<button @click=${fetchHandler}>Fetch Pabu</button>
|
||||||
|
${actionLogger}
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
If a value returned by `cacheIdentifier` changes the cache is reset. We avoid situation of accessing old cache and proactively clean it, for instance when a user session is ended.
|
#### Changing cache identifier
|
||||||
|
|
||||||
### Ajax cache Options
|
For this demo we use localStorage to set a user id to `'1'`.
|
||||||
|
|
||||||
```js
|
Now we will allow you to change this identifier to invalidate the cache.
|
||||||
const cacheOptions = {
|
|
||||||
// `useCache`: determines wether or not to use the cache
|
|
||||||
// can be boolean
|
|
||||||
// default: false
|
|
||||||
useCache: true,
|
|
||||||
|
|
||||||
// `timeToLive`: is the time the cache should be kept in ms
|
```js preview-story
|
||||||
// default: 0
|
export const changeCacheIdentifier = () => {
|
||||||
// Note: regardless of this setting, the cache instance holding all the caches
|
const actionLogger = renderLitAsNode(html`<sb-action-logger></sb-action-logger>`);
|
||||||
// will be invalidated after one hour
|
const fetchHandler = () => {
|
||||||
timeToLive: 1000 * 60 * 5,
|
ajax.requestJson(`./packages/ajax/docs/pabu.json`)
|
||||||
|
.then(result => {
|
||||||
|
actionLogger.log(`From cache: ${result.response.fromCache || false}`);
|
||||||
|
actionLogger.log(JSON.stringify(result.body, null, 2));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// `methods`: an array of methods on which this configuration is applied
|
const changeUserHandler = () => {
|
||||||
// Note: when `useCache` is `false` this will not be used
|
const currentUser = parseInt(localStorage.getItem('lion-ajax-cache-demo-user-id'), 10);
|
||||||
// NOTE: ONLY GET IS SUPPORTED
|
localStorage.setItem('lion-ajax-cache-demo-user-id', `${currentUser + 1}`);
|
||||||
// default: ['get']
|
}
|
||||||
methods: ['get'],
|
|
||||||
|
|
||||||
// `invalidateUrls`: an array of strings that for each string that partially
|
return html`
|
||||||
// occurs as key in the cache, will be removed
|
<style>
|
||||||
// default: []
|
sb-action-logger {
|
||||||
// Note: can be invalidated only by non-get request to the same url
|
--sb-action-logger-max-height: 300px;
|
||||||
invalidateUrls: ['/api/todosbykeyword'],
|
}
|
||||||
|
</style>
|
||||||
// `invalidateUrlsRegex`: a RegExp object to match and delete
|
<button @click=${fetchHandler}>Fetch Pabu</button>
|
||||||
// each matched key in the cache
|
<button @click=${changeUserHandler}>Change user</button>
|
||||||
// Note: can be invalidated only by non-get request to the same url
|
${actionLogger}
|
||||||
invalidateUrlsRegex: /posts/
|
`;
|
||||||
|
}
|
||||||
// `requestIdentificationFn`: a function to provide a string that should be
|
|
||||||
// taken as a key in the cache.
|
|
||||||
// This can be used to cache post-requests.
|
|
||||||
// default: (requestConfig, searchParamsSerializer) => url + params
|
|
||||||
requestIdentificationFn: (request, serializer) => {
|
|
||||||
return `${request.url}?${serializer(request.params)}`;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Considerations
|
#### Non-GET request
|
||||||
|
|
||||||
## Fetch Polyfill
|
In this demo we show that by doing a PATCH request, you invalidate the cache of the endpoint for subsequent GET requests.
|
||||||
|
|
||||||
For IE11 you will need a polyfill for fetch. You should add this on your top level layer, e.g. your application.
|
Try clicking the GET pabu button twice so you see a cached response.
|
||||||
|
Then click the PATCH pabu button, followed by another GET, and you will see that this one is not served from cache, because the PATCH invalidated it.
|
||||||
|
|
||||||
[This is the polyfill we recommend](https://github.com/github/fetch). It also has a [section for polyfilling AbortController](https://github.com/github/fetch#aborting-requests)
|
The rationale is that if a user does a non-GET request to an endpoint, it will make the client-side caching of this endpoint outdated.
|
||||||
|
This is because non-GET requests usually in some way mutate the state of the database through interacting with this endpoint.
|
||||||
|
Therefore, we invalidate the cache, so the user gets the latest state from the database on the next GET request.
|
||||||
|
|
||||||
|
> Ignore the browser errors when clicking PATCH buttons, JSON files (our mock database) don't accept PATCH requests.
|
||||||
|
|
||||||
|
```js preview-story
|
||||||
|
export const nonGETRequest = () => {
|
||||||
|
const actionLogger = renderLitAsNode(html`<sb-action-logger></sb-action-logger>`);
|
||||||
|
const fetchHandler = (name, method) => {
|
||||||
|
ajax.requestJson(`./packages/ajax/docs/${name}.json`, { method })
|
||||||
|
.then(result => {
|
||||||
|
actionLogger.log(`From cache: ${result.response.fromCache || false}`);
|
||||||
|
actionLogger.log(JSON.stringify(result.body, null, 2));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return html`
|
||||||
|
<style>
|
||||||
|
sb-action-logger {
|
||||||
|
--sb-action-logger-max-height: 300px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<button @click=${() => fetchHandler('pabu', 'GET')}>GET Pabu</button>
|
||||||
|
<button @click=${() => fetchHandler('pabu', 'PATCH')}>PATCH Pabu</button>
|
||||||
|
<button @click=${() => fetchHandler('naga', 'GET')}>GET Naga</button>
|
||||||
|
<button @click=${() => fetchHandler('naga', 'PATCH')}>PATCH Naga</button>
|
||||||
|
${actionLogger}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Invalidate Rules
|
||||||
|
|
||||||
|
There are two kinds of invalidate rules:
|
||||||
|
|
||||||
|
- `invalidateUrls` (array of URL like strings)
|
||||||
|
- `invalidateUrlsRegex` (RegExp)
|
||||||
|
|
||||||
|
If a non-GET method is fired, by default it only invalidates its own endpoint.
|
||||||
|
Invalidating `/api/users` cache by doing a PATCH, will not invalidate `/api/accounts` cache.
|
||||||
|
|
||||||
|
However, in the case of users and accounts, they may be very interconnected, so perhaps you do want to invalidate `/api/accounts` when invalidating `/api/users`.
|
||||||
|
|
||||||
|
This is what the invalidate rules are for.
|
||||||
|
|
||||||
|
In this demo, invalidating the `pabu` endpoint will invalidate `naga`, but not the other way around.
|
||||||
|
|
||||||
|
> For invalidateUrls you need the full URL e.g. `<protocol>://<domain>:<port>/<url>` so it's often easier to use invalidateUrlsRegex
|
||||||
|
|
||||||
|
```js preview-story
|
||||||
|
export const invalidateRules = () => {
|
||||||
|
const actionLogger = renderLitAsNode(html`<sb-action-logger></sb-action-logger>`);
|
||||||
|
const fetchHandler = (name, method) => {
|
||||||
|
const actionCacheOptions = {};
|
||||||
|
if (name === 'pabu') {
|
||||||
|
actionCacheOptions.invalidateUrlsRegex = /\/packages\/ajax\/docs\/naga.json/;
|
||||||
|
}
|
||||||
|
|
||||||
|
ajax.requestJson(`./packages/ajax/docs/${name}.json`, {
|
||||||
|
method,
|
||||||
|
cacheOptions: actionCacheOptions,
|
||||||
|
})
|
||||||
|
.then(result => {
|
||||||
|
actionLogger.log(`From cache: ${result.response.fromCache || false}`);
|
||||||
|
actionLogger.log(JSON.stringify(result.body, null, 2));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return html`
|
||||||
|
<style>
|
||||||
|
sb-action-logger {
|
||||||
|
--sb-action-logger-max-height: 300px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<button @click=${() => fetchHandler('pabu', 'GET')}>GET Pabu</button>
|
||||||
|
<button @click=${() => fetchHandler('pabu', 'PATCH')}>PATCH Pabu</button>
|
||||||
|
<button @click=${() => fetchHandler('naga', 'GET')}>GET Naga</button>
|
||||||
|
<button @click=${() => fetchHandler('naga', 'PATCH')}>PATCH Naga</button>
|
||||||
|
${actionLogger}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
# Ajax Cache
|
|
||||||
|
|
||||||
## Technical documentation
|
|
||||||
|
|
||||||
The library consists of 2 major parts:
|
|
||||||
|
|
||||||
1. A cache class
|
|
||||||
2. Request and Response Interceptors
|
|
||||||
|
|
||||||
### Cache class
|
|
||||||
|
|
||||||
The cache class is responsible for keeping cached data and keeping it valid.
|
|
||||||
This class isn't exposed outside, and remains private. Together with this class
|
|
||||||
we provide a `getCache(cacheIdentifier)` method that enforces a clean cache when
|
|
||||||
the `cacheIdentifier` changes.
|
|
||||||
|
|
||||||
> **Note**: the `cacheIdentifier` should be bound to the users session.
|
|
||||||
> Advice: Use the sessionToken as cacheIdentifier
|
|
||||||
|
|
||||||
Core invalidation rules are:
|
|
||||||
|
|
||||||
1. The `LionCache` instance is bound to a `cacheIdentifier`. When the `getCache`
|
|
||||||
receives another token, all instances of `LionCache` will be invalidated.
|
|
||||||
2. The `LionCache` instance is created with an expiration date **one hour** in
|
|
||||||
the future. Each method on the `LionCache` validates that this time hasn't
|
|
||||||
passed, and if it does, the cache object in the `LionCache` is cleared.
|
|
||||||
|
|
||||||
### Request and Response Interceptors
|
|
||||||
|
|
||||||
The interceptors are the core of the logic of when to cache.
|
|
||||||
|
|
||||||
To make the cache mechanism work, these interceptors have to be added to an ajax instance (for caching needs).
|
|
||||||
|
|
||||||
The **request interceptor**'s main goal is to determine whether or not to
|
|
||||||
**return the cached object**. This is done based on the options that are being
|
|
||||||
passed to the factory function.
|
|
||||||
|
|
||||||
The **response interceptor**'s goal is to determine **when to cache** the
|
|
||||||
requested response, based on the options that are being passed in the factory
|
|
||||||
function.
|
|
||||||
|
|
||||||
Interceptors require `cacheIdentifier` function and `cacheOptions` config.
|
|
||||||
The configuration is used by the interceptors to determine what to put in the cache and when to use the cached data.
|
|
||||||
|
|
||||||
A cache configuration per action (pre `get` etc) can be placed in ajax configuration in `lionCacheOptions` field, it needed for situations when you want your, for instance, `get` request to have specific cache parameters, like `timeToLive`.
|
|
||||||
9
packages/ajax/docs/naga.json
Normal file
9
packages/ajax/docs/naga.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"id": "2",
|
||||||
|
"type": "Polar Bear Dog",
|
||||||
|
"name": "Naga",
|
||||||
|
"skin": {
|
||||||
|
"type": "fur",
|
||||||
|
"color": "white"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
packages/ajax/docs/pabu.json
Normal file
9
packages/ajax/docs/pabu.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"id": "4",
|
||||||
|
"type": "Fire Ferret",
|
||||||
|
"name": "Pabu",
|
||||||
|
"skin": {
|
||||||
|
"type": "fur",
|
||||||
|
"color": "red"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
/* eslint-disable consistent-return */
|
/* eslint-disable consistent-return */
|
||||||
|
import {
|
||||||
|
cacheRequestInterceptorFactory,
|
||||||
|
cacheResponseInterceptorFactory,
|
||||||
|
} from './interceptors-cache.js';
|
||||||
import { acceptLanguageRequestInterceptor, createXSRFRequestInterceptor } from './interceptors.js';
|
import { acceptLanguageRequestInterceptor, createXSRFRequestInterceptor } from './interceptors.js';
|
||||||
import { AjaxClientFetchError } from './AjaxClientFetchError.js';
|
import { AjaxClientFetchError } from './AjaxClientFetchError.js';
|
||||||
|
|
||||||
|
|
@ -14,11 +18,16 @@ export class AjaxClient {
|
||||||
* @param {Partial<AjaxClientConfig>} config
|
* @param {Partial<AjaxClientConfig>} config
|
||||||
*/
|
*/
|
||||||
constructor(config = {}) {
|
constructor(config = {}) {
|
||||||
|
/** @type {Partial<AjaxClientConfig>} */
|
||||||
this.__config = {
|
this.__config = {
|
||||||
addAcceptLanguage: true,
|
addAcceptLanguage: true,
|
||||||
xsrfCookieName: 'XSRF-TOKEN',
|
xsrfCookieName: 'XSRF-TOKEN',
|
||||||
xsrfHeaderName: 'X-XSRF-TOKEN',
|
xsrfHeaderName: 'X-XSRF-TOKEN',
|
||||||
jsonPrefix: '',
|
jsonPrefix: '',
|
||||||
|
cacheOptions: {
|
||||||
|
getCacheIdentifier: () => '_default',
|
||||||
|
...config.cacheOptions,
|
||||||
|
},
|
||||||
...config,
|
...config,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -36,11 +45,26 @@ export class AjaxClient {
|
||||||
createXSRFRequestInterceptor(this.__config.xsrfCookieName, this.__config.xsrfHeaderName),
|
createXSRFRequestInterceptor(this.__config.xsrfCookieName, this.__config.xsrfHeaderName),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.__config.cacheOptions && this.__config.cacheOptions.useCache) {
|
||||||
|
this.addRequestInterceptor(
|
||||||
|
cacheRequestInterceptorFactory(
|
||||||
|
this.__config.cacheOptions.getCacheIdentifier,
|
||||||
|
this.__config.cacheOptions,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
this.addResponseInterceptor(
|
||||||
|
cacheResponseInterceptorFactory(
|
||||||
|
this.__config.cacheOptions.getCacheIdentifier,
|
||||||
|
this.__config.cacheOptions,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the config for the instance
|
* Sets the config for the instance
|
||||||
* @param {AjaxClientConfig} config configuration for the AjaxClass instance
|
* @param {Partial<AjaxClientConfig>} config configuration for the AjaxClass instance
|
||||||
*/
|
*/
|
||||||
set options(config) {
|
set options(config) {
|
||||||
this.__config = config;
|
this.__config = config;
|
||||||
|
|
|
||||||
|
|
@ -204,22 +204,16 @@ export const validateOptions = ({
|
||||||
* @returns {ValidatedCacheOptions}
|
* @returns {ValidatedCacheOptions}
|
||||||
*/
|
*/
|
||||||
function composeCacheOptions(validatedInitialCacheOptions, configCacheOptions) {
|
function composeCacheOptions(validatedInitialCacheOptions, configCacheOptions) {
|
||||||
/** @type {any} */
|
let actionCacheOptions = validatedInitialCacheOptions;
|
||||||
let actionCacheOptions = {};
|
|
||||||
|
|
||||||
actionCacheOptions =
|
if (configCacheOptions) {
|
||||||
configCacheOptions &&
|
actionCacheOptions = validateOptions({
|
||||||
validateOptions({
|
|
||||||
...validatedInitialCacheOptions,
|
...validatedInitialCacheOptions,
|
||||||
...configCacheOptions,
|
...configCacheOptions,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const cacheOptions = {
|
return actionCacheOptions;
|
||||||
...validatedInitialCacheOptions,
|
|
||||||
...actionCacheOptions,
|
|
||||||
};
|
|
||||||
|
|
||||||
return cacheOptions;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -249,7 +243,6 @@ export const cacheRequestInterceptorFactory = (getCacheIdentifier, globalCacheOp
|
||||||
|
|
||||||
// cacheIdentifier is used to bind the cache to the current session
|
// cacheIdentifier is used to bind the cache to the current session
|
||||||
const currentCache = getCache(getCacheIdentifier());
|
const currentCache = getCache(getCacheIdentifier());
|
||||||
|
|
||||||
const cacheResponse = currentCache.get(cacheId, cacheOptions.timeToLive);
|
const cacheResponse = currentCache.get(cacheId, cacheOptions.timeToLive);
|
||||||
|
|
||||||
// don't use cache if the request method is not part of the configs methods
|
// don't use cache if the request method is not part of the configs methods
|
||||||
|
|
@ -260,6 +253,7 @@ export const cacheRequestInterceptorFactory = (getCacheIdentifier, globalCacheOp
|
||||||
if (cacheOptions.invalidateUrls) {
|
if (cacheOptions.invalidateUrls) {
|
||||||
cacheOptions.invalidateUrls.forEach(
|
cacheOptions.invalidateUrls.forEach(
|
||||||
/** @type {string} */ invalidateUrl => {
|
/** @type {string} */ invalidateUrl => {
|
||||||
|
console.log('invalidaaaating', currentCache._cacheObject);
|
||||||
currentCache.delete(invalidateUrl);
|
currentCache.delete(invalidateUrl);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -277,16 +271,17 @@ export const cacheRequestInterceptorFactory = (getCacheIdentifier, globalCacheOp
|
||||||
if (!cacheRequest.cacheOptions) {
|
if (!cacheRequest.cacheOptions) {
|
||||||
cacheRequest.cacheOptions = { useCache: false };
|
cacheRequest.cacheOptions = { useCache: false };
|
||||||
}
|
}
|
||||||
cacheRequest.cacheOptions.fromCache = true;
|
|
||||||
|
|
||||||
const init = /** @type {LionRequestInit} */ ({
|
const init = /** @type {LionRequestInit} */ ({
|
||||||
status,
|
status,
|
||||||
statusText,
|
statusText,
|
||||||
headers,
|
headers,
|
||||||
request: cacheRequest,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return /** @type {CacheResponse} */ (new Response(cacheResponse, init));
|
const response = /** @type {CacheResponse} */ (new Response(cacheResponse, init));
|
||||||
|
response.request = cacheRequest;
|
||||||
|
response.fromCache = true;
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
return cacheRequest;
|
return cacheRequest;
|
||||||
|
|
@ -315,7 +310,7 @@ export const cacheResponseInterceptorFactory = (getCacheIdentifier, globalCacheO
|
||||||
cacheResponse.request?.cacheOptions,
|
cacheResponse.request?.cacheOptions,
|
||||||
);
|
);
|
||||||
|
|
||||||
const isAlreadyFromCache = !!cacheOptions.fromCache;
|
const isAlreadyFromCache = !!cacheResponse.fromCache;
|
||||||
// caching all responses with not default `timeToLive`
|
// caching all responses with not default `timeToLive`
|
||||||
const isCacheActive = cacheOptions.timeToLive > 0;
|
const isCacheActive = cacheOptions.timeToLive > 0;
|
||||||
|
|
||||||
|
|
@ -334,8 +329,9 @@ export const cacheResponseInterceptorFactory = (getCacheIdentifier, globalCacheO
|
||||||
searchParamSerializer,
|
searchParamSerializer,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const responseBody = await cacheResponse.clone().text();
|
||||||
// store the response data in the cache
|
// store the response data in the cache
|
||||||
getCache(getCacheIdentifier()).set(cacheId, cacheResponse.body);
|
getCache(getCacheIdentifier()).set(cacheId, responseBody);
|
||||||
} else {
|
} else {
|
||||||
// don't store in cache if the request method is not part of the configs methods
|
// don't store in cache if the request method is not part of the configs methods
|
||||||
return cacheResponse;
|
return cacheResponse;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { expect } from '@open-wc/testing';
|
import { expect } from '@open-wc/testing';
|
||||||
import { stub } from 'sinon';
|
import { stub, useFakeTimers } from 'sinon';
|
||||||
import { AjaxClient, AjaxClientFetchError } from '@lion/ajax';
|
import { AjaxClient, AjaxClientFetchError } from '@lion/ajax';
|
||||||
|
|
||||||
describe('AjaxClient', () => {
|
describe('AjaxClient', () => {
|
||||||
|
|
@ -210,6 +210,61 @@ describe('AjaxClient', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Caching', () => {
|
||||||
|
/** @type {number | undefined} */
|
||||||
|
let cacheId;
|
||||||
|
/** @type {() => string} */
|
||||||
|
let getCacheIdentifier;
|
||||||
|
|
||||||
|
const newCacheId = () => {
|
||||||
|
if (!cacheId) {
|
||||||
|
cacheId = 1;
|
||||||
|
} else {
|
||||||
|
cacheId += 1;
|
||||||
|
}
|
||||||
|
return cacheId;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
getCacheIdentifier = () => String(cacheId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows configuring cache interceptors on the AjaxClient config', async () => {
|
||||||
|
newCacheId();
|
||||||
|
const customAjax = new AjaxClient({
|
||||||
|
cacheOptions: {
|
||||||
|
useCache: true,
|
||||||
|
timeToLive: 100,
|
||||||
|
getCacheIdentifier,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const clock = useFakeTimers({
|
||||||
|
shouldAdvanceTime: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Smoke test 1: verify caching works
|
||||||
|
await customAjax.request('/foo');
|
||||||
|
expect(fetchStub.callCount).to.equal(1);
|
||||||
|
await customAjax.request('/foo');
|
||||||
|
expect(fetchStub.callCount).to.equal(1);
|
||||||
|
|
||||||
|
// Smoke test 2: verify caching is invalidated on non-get method
|
||||||
|
await customAjax.request('/foo', { method: 'POST' });
|
||||||
|
expect(fetchStub.callCount).to.equal(2);
|
||||||
|
await customAjax.request('/foo');
|
||||||
|
expect(fetchStub.callCount).to.equal(3);
|
||||||
|
|
||||||
|
// Smoke test 3: verify caching is invalidated after TTL has passed
|
||||||
|
await customAjax.request('/foo');
|
||||||
|
expect(fetchStub.callCount).to.equal(3);
|
||||||
|
clock.tick(101);
|
||||||
|
await customAjax.request('/foo');
|
||||||
|
expect(fetchStub.callCount).to.equal(4);
|
||||||
|
clock.restore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Abort', () => {
|
describe('Abort', () => {
|
||||||
it('support aborting requests with AbortController', async () => {
|
it('support aborting requests with AbortController', async () => {
|
||||||
fetchStub.restore();
|
fetchStub.restore();
|
||||||
|
|
|
||||||
12
packages/ajax/types/types.d.ts
vendored
12
packages/ajax/types/types.d.ts
vendored
|
|
@ -14,6 +14,7 @@ export interface AjaxClientConfig {
|
||||||
addAcceptLanguage: boolean;
|
addAcceptLanguage: boolean;
|
||||||
xsrfCookieName: string | null;
|
xsrfCookieName: string | null;
|
||||||
xsrfHeaderName: string | null;
|
xsrfHeaderName: string | null;
|
||||||
|
cacheOptions: CacheOptionsWithIdentifier;
|
||||||
jsonPrefix: string;
|
jsonPrefix: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -38,17 +39,17 @@ export interface CacheOptions {
|
||||||
invalidateUrls?: string[];
|
invalidateUrls?: string[];
|
||||||
invalidateUrlsRegex?: RegExp;
|
invalidateUrlsRegex?: RegExp;
|
||||||
requestIdentificationFn?: RequestIdentificationFn;
|
requestIdentificationFn?: RequestIdentificationFn;
|
||||||
fromCache?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ValidatedCacheOptions {
|
export interface CacheOptionsWithIdentifier extends CacheOptions {
|
||||||
|
getCacheIdentifier: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidatedCacheOptions extends CacheOptions {
|
||||||
useCache: boolean;
|
useCache: boolean;
|
||||||
methods: string[];
|
methods: string[];
|
||||||
timeToLive: number;
|
timeToLive: number;
|
||||||
invalidateUrls?: string[];
|
|
||||||
invalidateUrlsRegex?: RegExp;
|
|
||||||
requestIdentificationFn: RequestIdentificationFn;
|
requestIdentificationFn: RequestIdentificationFn;
|
||||||
fromCache?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CacheRequestExtension {
|
export interface CacheRequestExtension {
|
||||||
|
|
@ -67,6 +68,7 @@ export interface CacheResponseRequest {
|
||||||
export interface CacheResponseExtension {
|
export interface CacheResponseExtension {
|
||||||
request: CacheResponseRequest;
|
request: CacheResponseRequest;
|
||||||
data: object | string;
|
data: object | string;
|
||||||
|
fromCache?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CacheRequest = Request & Partial<CacheRequestExtension>;
|
export type CacheRequest = Request & Partial<CacheRequestExtension>;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue