From 2cd7993da879ec2dc19565168174099cd72fb660 Mon Sep 17 00:00:00 2001 From: Joren Broekema Date: Thu, 18 Feb 2021 10:53:35 +0100 Subject: [PATCH] feat: add ajax cache improvements and demos/docs --- .changeset/eleven-tips-grow.md | 5 + .storybook/main.js | 2 +- packages/ajax/README.md | 459 +++++++++++++++------ packages/ajax/docs/cache-technical-docs.md | 45 -- packages/ajax/docs/naga.json | 9 + packages/ajax/docs/pabu.json | 9 + packages/ajax/src/AjaxClient.js | 26 +- packages/ajax/src/interceptors-cache.js | 30 +- packages/ajax/test/AjaxClient.test.js | 57 ++- packages/ajax/types/types.d.ts | 12 +- 10 files changed, 465 insertions(+), 189 deletions(-) create mode 100644 .changeset/eleven-tips-grow.md delete mode 100644 packages/ajax/docs/cache-technical-docs.md create mode 100644 packages/ajax/docs/naga.json create mode 100644 packages/ajax/docs/pabu.json diff --git a/.changeset/eleven-tips-grow.md b/.changeset/eleven-tips-grow.md new file mode 100644 index 000000000..3518bbc0c --- /dev/null +++ b/.changeset/eleven-tips-grow.md @@ -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. diff --git a/.storybook/main.js b/.storybook/main.js index eb21ffd6c..81b7fdd96 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -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', diff --git a/packages/ajax/README.md b/packages/ajax/README.md index e5cc99163..40705a2cd 100644 --- a/packages/ajax/README.md +++ b/packages/ajax/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``); + 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` + + + + ${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``); + 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` + + + + ${actionLogger} + `; +} ``` #### 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: -```js -import { ajax } from '@lion/ajax'; - -try { - const users = await ajax.requestJson('/api/users'); -} catch (error) { - if (error.response) { - if (error.response.status === 400) { - // handle a specific status code, for example 400 bad request - } else { - console.error(error); +```js preview-story +export const errorHandling = () => { + const actionLogger = renderLitAsNode(html``); + const fetchHandler = async () => { + try { + const users = await ajax.requestJson('/api/users'); + } catch (error) { + if (error.response) { + if (error.response.status === 400) { + // handle a specific status code, for example 400 bad request + } 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` + + + ${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'); ``` -### Ajax cache example +Alternatively, most often for subclassers, you can extend or import `AjaxClient` yourself, and pass cacheOptions when instantiating the ajax singleton. ```js -import { - ajax, - cacheRequestInterceptorFactory, - cacheResponseInterceptorFactory, -} from '@lion-web/ajax'; +import { AjaxClient } from '@lion/ajax'; -const getCacheIdentifier = () => getActiveProfile().profileId; +export const ajax = new AjaxClient({ + cacheOptions: { + useCache: true, + timeToLive: 1000 * 60 * 5, // 5 minutes + getCacheIdentifier: () => getActiveProfile().profileId, + }, +}) +``` -const globalCacheOptions = { - 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 -}; +### Ajax cache example -// 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), -// ); +> 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. -class TodoService { - constructor() { - this.localAjaxConfig = { - cacheOptions: { - invalidateUrls: ['/api/todosbykeyword'], // default: [] - }, - }; - } - - /** - * Returns all todos from cache if not older than 5 minutes - */ - 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 }); +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. + +```js preview-story +export const cache = () => { + const actionLogger = renderLitAsNode(html``); + 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` + + + + ${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 -const cacheOptions = { - // `useCache`: determines wether or not to use the cache - // can be boolean - // default: false - useCache: true, +```js preview-story +export const cacheActionOptions = () => { + const actionLogger = renderLitAsNode(html``); + const fetchHandler = (name) => { + let actionCacheOptions; + if (name === 'naga') { + actionCacheOptions = { + useCache: false, + } + } - // `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, - - // `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'], - - // `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)}`; - }, -}; + 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` + + + + ${actionLogger} + `; +} ``` -## 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``); + 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` + + + ${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``); + 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` + + + + ${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``); + 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` + + + + + + ${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. `://:/` so it's often easier to use invalidateUrlsRegex + +```js preview-story +export const invalidateRules = () => { + const actionLogger = renderLitAsNode(html``); + 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` + + + + + + ${actionLogger} + `; +} +``` \ No newline at end of file diff --git a/packages/ajax/docs/cache-technical-docs.md b/packages/ajax/docs/cache-technical-docs.md deleted file mode 100644 index b6975ef77..000000000 --- a/packages/ajax/docs/cache-technical-docs.md +++ /dev/null @@ -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`. diff --git a/packages/ajax/docs/naga.json b/packages/ajax/docs/naga.json new file mode 100644 index 000000000..0445fee3f --- /dev/null +++ b/packages/ajax/docs/naga.json @@ -0,0 +1,9 @@ +{ + "id": "2", + "type": "Polar Bear Dog", + "name": "Naga", + "skin": { + "type": "fur", + "color": "white" + } +} diff --git a/packages/ajax/docs/pabu.json b/packages/ajax/docs/pabu.json new file mode 100644 index 000000000..9c693a143 --- /dev/null +++ b/packages/ajax/docs/pabu.json @@ -0,0 +1,9 @@ +{ + "id": "4", + "type": "Fire Ferret", + "name": "Pabu", + "skin": { + "type": "fur", + "color": "red" + } +} diff --git a/packages/ajax/src/AjaxClient.js b/packages/ajax/src/AjaxClient.js index 0709f359b..37b72a2c8 100644 --- a/packages/ajax/src/AjaxClient.js +++ b/packages/ajax/src/AjaxClient.js @@ -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} config */ constructor(config = {}) { + /** @type {Partial} */ 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} config configuration for the AjaxClass instance */ set options(config) { this.__config = config; diff --git a/packages/ajax/src/interceptors-cache.js b/packages/ajax/src/interceptors-cache.js index 68eef2a60..5c861b318 100644 --- a/packages/ajax/src/interceptors-cache.js +++ b/packages/ajax/src/interceptors-cache.js @@ -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; diff --git a/packages/ajax/test/AjaxClient.test.js b/packages/ajax/test/AjaxClient.test.js index 55442c466..8d1330863 100644 --- a/packages/ajax/test/AjaxClient.test.js +++ b/packages/ajax/test/AjaxClient.test.js @@ -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(); diff --git a/packages/ajax/types/types.d.ts b/packages/ajax/types/types.d.ts index 750bc4f83..e12b405d2 100644 --- a/packages/ajax/types/types.d.ts +++ b/packages/ajax/types/types.d.ts @@ -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;