feat: add ajax cache improvements and demos/docs

This commit is contained in:
Joren Broekema 2021-02-18 10:53:35 +01:00 committed by Thomas Allmer
parent 0d97ab5475
commit 2cd7993da8
10 changed files with 465 additions and 189 deletions

View 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.

View file

@ -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',

View file

@ -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,32 +138,54 @@ 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>`);
try { const fetchHandler = async () => {
const users = await ajax.requestJson('/api/users'); try {
} catch (error) { const users = await ajax.requestJson('/api/users');
if (error.response) { } catch (error) {
if (error.response.status === 400) { if (error.response) {
// handle a specific status code, for example 400 bad request if (error.response.status === 400) {
} else { // handle a specific status code, for example 400 bad request
console.error(error); } else {
actionLogger.log(error);
}
} else {
// an error happened before receiving a response, ex. an incorrect request or network error
actionLogger.log(error);
}
} }
} else {
// an error happened before receiving a response, ex. an incorrect request or network error
console.error(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');
``` ```
### Ajax cache example Alternatively, most often for subclassers, you can extend or import `AjaxClient` yourself, and pass cacheOptions when instantiating the ajax singleton.
```js ```js
import { import { AjaxClient } from '@lion/ajax';
ajax,
cacheRequestInterceptorFactory,
cacheResponseInterceptorFactory,
} from '@lion-web/ajax';
const getCacheIdentifier = () => getActiveProfile().profileId; export const ajax = new AjaxClient({
cacheOptions: {
useCache: true,
timeToLive: 1000 * 60 * 5, // 5 minutes
getCacheIdentifier: () => getActiveProfile().profileId,
},
})
```
const globalCacheOptions = { ### Ajax cache example
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 > 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.
// ajax.interceptors.request.use(cacheRequestInterceptorFactory(getCacheIdentifier, cacheOptions));
// ajax.interceptors.response.use(
// cacheResponseInterceptorFactory(getCacheIdentifier, cacheOptions),
// );
class TodoService { We can see if a response is served from the cache by checking the `response.fromCache` property,
constructor() { which is either undefined for normal requests, or set to true for responses that were served from cache.
this.localAjaxConfig = {
cacheOptions: { ```js preview-story
invalidateUrls: ['/api/todosbykeyword'], // default: [] 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}`);
* Returns all todos from cache if not older than 5 minutes actionLogger.log(JSON.stringify(result.body, null, 2));
*/ });
getTodos() {
return ajax.requestJson(`/api/todos`, this.localAjaxConfig);
}
/**
*
*/
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('pabu')}>Fetch Pabu</button>
<button @click=${() => fetchHandler('naga')}>Fetch Naga</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. 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.
### Ajax cache Options In this demo, when we fetch naga, we always pass `useCache: false` so the Response is never a cached one.
```js ```js preview-story
const cacheOptions = { export const cacheActionOptions = () => {
// `useCache`: determines wether or not to use the cache const actionLogger = renderLitAsNode(html`<sb-action-logger></sb-action-logger>`);
// can be boolean const fetchHandler = (name) => {
// default: false let actionCacheOptions;
useCache: true, if (name === 'naga') {
actionCacheOptions = {
useCache: false,
}
}
// `timeToLive`: is the time the cache should be kept in ms ajax.requestJson(`./packages/ajax/docs/${name}.json`, { cacheOptions: actionCacheOptions })
// default: 0 .then(result => {
// Note: regardless of this setting, the cache instance holding all the caches actionLogger.log(`From cache: ${result.response.fromCache || false}`);
// will be invalidated after one hour actionLogger.log(JSON.stringify(result.body, null, 2));
timeToLive: 1000 * 60 * 5, });
}
// `methods`: an array of methods on which this configuration is applied return html`
// Note: when `useCache` is `false` this will not be used <style>
// NOTE: ONLY GET IS SUPPORTED sb-action-logger {
// default: ['get'] --sb-action-logger-max-height: 300px;
methods: ['get'], }
</style>
// `invalidateUrls`: an array of strings that for each string that partially <button @click=${() => fetchHandler('pabu')}>Fetch Pabu</button>
// occurs as key in the cache, will be removed <button @click=${() => fetchHandler('naga')}>Fetch Naga</button>
// default: [] ${actionLogger}
// 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)}`;
},
};
``` ```
## Considerations ### Invalidating cache
## Fetch Polyfill Invalidating the cache, or cache busting, can be done in multiple ways:
For IE11 you will need a polyfill for fetch. You should add this on your top level layer, e.g. your application. - 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`
[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) #### 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: {
timeToLive: 1000 * 3, // 3 seconds
}
})
.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}>Fetch Pabu</button>
${actionLogger}
`;
}
```
#### Changing cache identifier
For this demo we use localStorage to set a user id to `'1'`.
Now we will allow you to change this identifier to invalidate the cache.
```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));
});
}
const changeUserHandler = () => {
const currentUser = parseInt(localStorage.getItem('lion-ajax-cache-demo-user-id'), 10);
localStorage.setItem('lion-ajax-cache-demo-user-id', `${currentUser + 1}`);
}
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}
`;
}
```
#### Non-GET request
In this demo we show that by doing a PATCH request, you invalidate the cache of the endpoint for subsequent GET requests.
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.
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}
`;
}
```

View file

@ -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`.

View file

@ -0,0 +1,9 @@
{
"id": "2",
"type": "Polar Bear Dog",
"name": "Naga",
"skin": {
"type": "fur",
"color": "white"
}
}

View file

@ -0,0 +1,9 @@
{
"id": "4",
"type": "Fire Ferret",
"name": "Pabu",
"skin": {
"type": "fur",
"color": "red"
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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();

View file

@ -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>;