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 = {
|
||||
stories: [
|
||||
'../{packages,packages-node}/!(ajax)*/README.md',
|
||||
'../{packages,packages-node}/*/README.md',
|
||||
'../{packages,packages-node}/*/docs/*.md',
|
||||
'../{packages,packages-node}/*/docs/!(assets)**/*.md',
|
||||
'../packages/helpers/*/README.md',
|
||||
|
|
|
|||
|
|
@ -2,6 +2,36 @@
|
|||
|
||||
# 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:
|
||||
|
||||
- Allows globally registering request and response interceptors
|
||||
|
|
@ -27,11 +57,27 @@ npm i --save @lion/ajax
|
|||
|
||||
#### GET request
|
||||
|
||||
```js
|
||||
import { ajax } from '@lion/ajax';
|
||||
|
||||
const response = await ajax.request('/api/users');
|
||||
const users = await response.json();
|
||||
```js preview-story
|
||||
export const getRequest = () => {
|
||||
const actionLogger = renderLitAsNode(html`<sb-action-logger></sb-action-logger>`);
|
||||
const fetchHandler = (name) => {
|
||||
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
|
||||
|
|
@ -48,14 +94,33 @@ const newUser = await response.json();
|
|||
|
||||
### 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
|
||||
|
||||
```js
|
||||
import { ajax } from '@lion/ajax';
|
||||
|
||||
const { response, body } = await ajax.requestJson('/api/users');
|
||||
```js preview-story
|
||||
export const getJsonRequest = () => {
|
||||
const actionLogger = renderLitAsNode(html`<sb-action-logger></sb-action-logger>`);
|
||||
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
|
||||
|
|
@ -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:
|
||||
|
||||
```js
|
||||
import { ajax } from '@lion/ajax';
|
||||
|
||||
```js preview-story
|
||||
export const errorHandling = () => {
|
||||
const actionLogger = renderLitAsNode(html`<sb-action-logger></sb-action-logger>`);
|
||||
const fetchHandler = async () => {
|
||||
try {
|
||||
const users = await ajax.requestJson('/api/users');
|
||||
} catch (error) {
|
||||
|
|
@ -83,22 +149,43 @@ try {
|
|||
if (error.response.status === 400) {
|
||||
// handle a specific status code, for example 400 bad request
|
||||
} else {
|
||||
console.error(error);
|
||||
actionLogger.log(error);
|
||||
}
|
||||
} else {
|
||||
// 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
|
||||
|
||||
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`.
|
||||
|
||||
> Technical documentation and decisions can be found in
|
||||
> [./docs/technical-docs.md](./docs/technical-docs.md)
|
||||
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.
|
||||
|
||||
The **response interceptor**'s goal is to determine **when to cache** the
|
||||
requested response, based on the options that are being passed.
|
||||
|
||||
### Getting started
|
||||
|
||||
|
|
@ -133,113 +220,247 @@ ajax.addResponseInterceptor(
|
|||
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
|
||||
|
||||
```js
|
||||
import {
|
||||
ajax,
|
||||
cacheRequestInterceptorFactory,
|
||||
cacheResponseInterceptorFactory,
|
||||
} from '@lion-web/ajax';
|
||||
> Let's assume that we have a user session, for this demo purposes we already created an identifier function for this and set the cache interceptors.
|
||||
|
||||
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,
|
||||
timeToLive: 50, // default: one hour (the cache instance will be replaced in 1 hour, regardless of this setting)
|
||||
methods: ['get'], // default: ['get'] NOTE for now only 'get' is supported
|
||||
// requestIdentificationFn: (requestConfig) => { }, // see docs below for more info
|
||||
// invalidateUrls: [], see docs below for more info
|
||||
// invalidateUrlsRegex: RegExp, // see docs below for more info
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// pass a function to the interceptorFactory that retrieves a cache identifier
|
||||
// ajax.interceptors.request.use(cacheRequestInterceptorFactory(getCacheIdentifier, cacheOptions));
|
||||
// ajax.interceptors.response.use(
|
||||
// cacheResponseInterceptorFactory(getCacheIdentifier, cacheOptions),
|
||||
// );
|
||||
ajax.requestJson(`./packages/ajax/docs/${name}.json`, { 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')}>Fetch Pabu</button>
|
||||
<button @click=${() => fetchHandler('naga')}>Fetch Naga</button>
|
||||
${actionLogger}
|
||||
`;
|
||||
}
|
||||
```
|
||||
|
||||
class TodoService {
|
||||
constructor() {
|
||||
this.localAjaxConfig = {
|
||||
### Invalidating cache
|
||||
|
||||
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: {
|
||||
invalidateUrls: ['/api/todosbykeyword'], // default: []
|
||||
},
|
||||
};
|
||||
timeToLive: 1000 * 3, // 3 seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all todos from cache if not older than 5 minutes
|
||||
*/
|
||||
getTodos() {
|
||||
return ajax.requestJson(`/api/todos`, this.localAjaxConfig);
|
||||
})
|
||||
.then(result => {
|
||||
actionLogger.log(`From cache: ${result.response.fromCache || false}`);
|
||||
actionLogger.log(JSON.stringify(result.body, null, 2));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
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 });
|
||||
return html`
|
||||
<style>
|
||||
sb-action-logger {
|
||||
--sb-action-logger-max-height: 300px;
|
||||
}
|
||||
</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
|
||||
const cacheOptions = {
|
||||
// `useCache`: determines wether or not to use the cache
|
||||
// can be boolean
|
||||
// default: false
|
||||
useCache: true,
|
||||
Now we will allow you to change this identifier to invalidate the cache.
|
||||
|
||||
// `timeToLive`: is the time the cache should be kept in ms
|
||||
// default: 0
|
||||
// Note: regardless of this setting, the cache instance holding all the caches
|
||||
// will be invalidated after one hour
|
||||
timeToLive: 1000 * 60 * 5,
|
||||
```js preview-story
|
||||
export const changeCacheIdentifier = () => {
|
||||
const actionLogger = renderLitAsNode(html`<sb-action-logger></sb-action-logger>`);
|
||||
const fetchHandler = () => {
|
||||
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
|
||||
// Note: when `useCache` is `false` this will not be used
|
||||
// NOTE: ONLY GET IS SUPPORTED
|
||||
// default: ['get']
|
||||
methods: ['get'],
|
||||
const changeUserHandler = () => {
|
||||
const currentUser = parseInt(localStorage.getItem('lion-ajax-cache-demo-user-id'), 10);
|
||||
localStorage.setItem('lion-ajax-cache-demo-user-id', `${currentUser + 1}`);
|
||||
}
|
||||
|
||||
// `invalidateUrls`: an array of strings that for each string that partially
|
||||
// occurs as key in the cache, will be removed
|
||||
// default: []
|
||||
// Note: can be invalidated only by non-get request to the same url
|
||||
invalidateUrls: ['/api/todosbykeyword'],
|
||||
|
||||
// `invalidateUrlsRegex`: a RegExp object to match and delete
|
||||
// each matched key in the cache
|
||||
// Note: can be invalidated only by non-get request to the same url
|
||||
invalidateUrlsRegex: /posts/
|
||||
|
||||
// `requestIdentificationFn`: a function to provide a string that should be
|
||||
// taken as a key in the cache.
|
||||
// This can be used to cache post-requests.
|
||||
// default: (requestConfig, searchParamsSerializer) => url + params
|
||||
requestIdentificationFn: (request, serializer) => {
|
||||
return `${request.url}?${serializer(request.params)}`;
|
||||
},
|
||||
};
|
||||
return html`
|
||||
<style>
|
||||
sb-action-logger {
|
||||
--sb-action-logger-max-height: 300px;
|
||||
}
|
||||
</style>
|
||||
<button @click=${fetchHandler}>Fetch Pabu</button>
|
||||
<button @click=${changeUserHandler}>Change user</button>
|
||||
${actionLogger}
|
||||
`;
|
||||
}
|
||||
```
|
||||
|
||||
## 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 */
|
||||
import {
|
||||
cacheRequestInterceptorFactory,
|
||||
cacheResponseInterceptorFactory,
|
||||
} from './interceptors-cache.js';
|
||||
import { acceptLanguageRequestInterceptor, createXSRFRequestInterceptor } from './interceptors.js';
|
||||
import { AjaxClientFetchError } from './AjaxClientFetchError.js';
|
||||
|
||||
|
|
@ -14,11 +18,16 @@ export class AjaxClient {
|
|||
* @param {Partial<AjaxClientConfig>} config
|
||||
*/
|
||||
constructor(config = {}) {
|
||||
/** @type {Partial<AjaxClientConfig>} */
|
||||
this.__config = {
|
||||
addAcceptLanguage: true,
|
||||
xsrfCookieName: 'XSRF-TOKEN',
|
||||
xsrfHeaderName: 'X-XSRF-TOKEN',
|
||||
jsonPrefix: '',
|
||||
cacheOptions: {
|
||||
getCacheIdentifier: () => '_default',
|
||||
...config.cacheOptions,
|
||||
},
|
||||
...config,
|
||||
};
|
||||
|
||||
|
|
@ -36,11 +45,26 @@ export class AjaxClient {
|
|||
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
|
||||
* @param {AjaxClientConfig} config configuration for the AjaxClass instance
|
||||
* @param {Partial<AjaxClientConfig>} config configuration for the AjaxClass instance
|
||||
*/
|
||||
set options(config) {
|
||||
this.__config = config;
|
||||
|
|
|
|||
|
|
@ -204,22 +204,16 @@ export const validateOptions = ({
|
|||
* @returns {ValidatedCacheOptions}
|
||||
*/
|
||||
function composeCacheOptions(validatedInitialCacheOptions, configCacheOptions) {
|
||||
/** @type {any} */
|
||||
let actionCacheOptions = {};
|
||||
let actionCacheOptions = validatedInitialCacheOptions;
|
||||
|
||||
actionCacheOptions =
|
||||
configCacheOptions &&
|
||||
validateOptions({
|
||||
if (configCacheOptions) {
|
||||
actionCacheOptions = validateOptions({
|
||||
...validatedInitialCacheOptions,
|
||||
...configCacheOptions,
|
||||
});
|
||||
}
|
||||
|
||||
const cacheOptions = {
|
||||
...validatedInitialCacheOptions,
|
||||
...actionCacheOptions,
|
||||
};
|
||||
|
||||
return cacheOptions;
|
||||
return actionCacheOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -249,7 +243,6 @@ export const cacheRequestInterceptorFactory = (getCacheIdentifier, globalCacheOp
|
|||
|
||||
// cacheIdentifier is used to bind the cache to the current session
|
||||
const currentCache = getCache(getCacheIdentifier());
|
||||
|
||||
const cacheResponse = currentCache.get(cacheId, cacheOptions.timeToLive);
|
||||
|
||||
// don't use cache if the request method is not part of the configs methods
|
||||
|
|
@ -260,6 +253,7 @@ export const cacheRequestInterceptorFactory = (getCacheIdentifier, globalCacheOp
|
|||
if (cacheOptions.invalidateUrls) {
|
||||
cacheOptions.invalidateUrls.forEach(
|
||||
/** @type {string} */ invalidateUrl => {
|
||||
console.log('invalidaaaating', currentCache._cacheObject);
|
||||
currentCache.delete(invalidateUrl);
|
||||
},
|
||||
);
|
||||
|
|
@ -277,16 +271,17 @@ export const cacheRequestInterceptorFactory = (getCacheIdentifier, globalCacheOp
|
|||
if (!cacheRequest.cacheOptions) {
|
||||
cacheRequest.cacheOptions = { useCache: false };
|
||||
}
|
||||
cacheRequest.cacheOptions.fromCache = true;
|
||||
|
||||
const init = /** @type {LionRequestInit} */ ({
|
||||
status,
|
||||
statusText,
|
||||
headers,
|
||||
request: cacheRequest,
|
||||
});
|
||||
|
||||
return /** @type {CacheResponse} */ (new Response(cacheResponse, init));
|
||||
const response = /** @type {CacheResponse} */ (new Response(cacheResponse, init));
|
||||
response.request = cacheRequest;
|
||||
response.fromCache = true;
|
||||
return response;
|
||||
}
|
||||
|
||||
return cacheRequest;
|
||||
|
|
@ -315,7 +310,7 @@ export const cacheResponseInterceptorFactory = (getCacheIdentifier, globalCacheO
|
|||
cacheResponse.request?.cacheOptions,
|
||||
);
|
||||
|
||||
const isAlreadyFromCache = !!cacheOptions.fromCache;
|
||||
const isAlreadyFromCache = !!cacheResponse.fromCache;
|
||||
// caching all responses with not default `timeToLive`
|
||||
const isCacheActive = cacheOptions.timeToLive > 0;
|
||||
|
||||
|
|
@ -334,8 +329,9 @@ export const cacheResponseInterceptorFactory = (getCacheIdentifier, globalCacheO
|
|||
searchParamSerializer,
|
||||
);
|
||||
|
||||
const responseBody = await cacheResponse.clone().text();
|
||||
// store the response data in the cache
|
||||
getCache(getCacheIdentifier()).set(cacheId, cacheResponse.body);
|
||||
getCache(getCacheIdentifier()).set(cacheId, responseBody);
|
||||
} else {
|
||||
// don't store in cache if the request method is not part of the configs methods
|
||||
return cacheResponse;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { expect } from '@open-wc/testing';
|
||||
import { stub } from 'sinon';
|
||||
import { stub, useFakeTimers } from 'sinon';
|
||||
import { AjaxClient, AjaxClientFetchError } from '@lion/ajax';
|
||||
|
||||
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', () => {
|
||||
it('support aborting requests with AbortController', async () => {
|
||||
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;
|
||||
xsrfCookieName: string | null;
|
||||
xsrfHeaderName: string | null;
|
||||
cacheOptions: CacheOptionsWithIdentifier;
|
||||
jsonPrefix: string;
|
||||
}
|
||||
|
||||
|
|
@ -38,17 +39,17 @@ export interface CacheOptions {
|
|||
invalidateUrls?: string[];
|
||||
invalidateUrlsRegex?: RegExp;
|
||||
requestIdentificationFn?: RequestIdentificationFn;
|
||||
fromCache?: boolean;
|
||||
}
|
||||
|
||||
export interface ValidatedCacheOptions {
|
||||
export interface CacheOptionsWithIdentifier extends CacheOptions {
|
||||
getCacheIdentifier: () => string;
|
||||
}
|
||||
|
||||
export interface ValidatedCacheOptions extends CacheOptions {
|
||||
useCache: boolean;
|
||||
methods: string[];
|
||||
timeToLive: number;
|
||||
invalidateUrls?: string[];
|
||||
invalidateUrlsRegex?: RegExp;
|
||||
requestIdentificationFn: RequestIdentificationFn;
|
||||
fromCache?: boolean;
|
||||
}
|
||||
|
||||
export interface CacheRequestExtension {
|
||||
|
|
@ -67,6 +68,7 @@ export interface CacheResponseRequest {
|
|||
export interface CacheResponseExtension {
|
||||
request: CacheResponseRequest;
|
||||
data: object | string;
|
||||
fromCache?: boolean;
|
||||
}
|
||||
|
||||
export type CacheRequest = Request & Partial<CacheRequestExtension>;
|
||||
|
|
|
|||
Loading…
Reference in a new issue