From bbffd7105fa9bd8f1e7f8a55a7393d7fbd33f1df Mon Sep 17 00:00:00 2001 From: Yevgeniy Valeyev Date: Fri, 15 Jan 2021 14:19:52 +0100 Subject: [PATCH] feat: port caching feature to fetch proposal Co-authored-by: Yevgeniy Valeyev --- .changeset/twelve-apes-reflect.md | 5 + packages/ajax/README.md | 149 +++++ packages/ajax/docs/cache-technical-docs.md | 45 ++ packages/ajax/index.js | 6 + packages/ajax/src/AjaxClient.js | 82 ++- packages/ajax/src/interceptors-cache.js | 346 ++++++++++ packages/ajax/src/interceptors.js | 4 +- packages/ajax/src/typedef.js | 18 + packages/ajax/test/AjaxClient.test.js | 23 +- packages/ajax/test/interceptors-cache.test.js | 596 ++++++++++++++++++ packages/ajax/types/ajaxClientTypes.d.ts | 10 - packages/ajax/types/types.d.ts | 80 +++ 12 files changed, 1298 insertions(+), 66 deletions(-) create mode 100644 .changeset/twelve-apes-reflect.md create mode 100644 packages/ajax/docs/cache-technical-docs.md create mode 100644 packages/ajax/src/interceptors-cache.js create mode 100644 packages/ajax/src/typedef.js create mode 100644 packages/ajax/test/interceptors-cache.test.js delete mode 100644 packages/ajax/types/ajaxClientTypes.d.ts create mode 100644 packages/ajax/types/types.d.ts diff --git a/.changeset/twelve-apes-reflect.md b/.changeset/twelve-apes-reflect.md new file mode 100644 index 000000000..99282a370 --- /dev/null +++ b/.changeset/twelve-apes-reflect.md @@ -0,0 +1,5 @@ +--- +'@lion/ajax': minor +--- + +Added Ajax cache interceptors. diff --git a/packages/ajax/README.md b/packages/ajax/README.md index aa55067a1..e5cc99163 100644 --- a/packages/ajax/README.md +++ b/packages/ajax/README.md @@ -27,6 +27,9 @@ 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(); ``` @@ -89,6 +92,152 @@ try { } ``` +## Ajax Cache + +A caching library that uses `lion-web/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) + +### Getting started + +Consume the global `ajax` instance and add the interceptors to it, using a cache configuration +which is applied on application level. If a developer wants to add specifics to cache behavior +they have to provide a cache config per action (`get`, `post`, etc.) via `cacheOptions` field of local ajax config, +see examples below. + +> **Note**: make sure to add the **interceptors** only **once**. This is usually +> done on app-level + +```js +import { + ajax, + cacheRequestInterceptorFactory, + cacheResponseInterceptorFactory, +} from '@lion-web/ajax.js'; + +const globalCacheOptions = { + useCache: true, + timeToLive: 1000 * 60 * 5, // 5 minutes +}; +// Cache is removed each time an identifier changes, +// for instance when a current user is logged out +const getCacheIdentifier = () => getActiveProfile().profileId; + +ajax.addRequestInterceptor(cacheRequestInterceptorFactory(getCacheIdentifier, globalCacheOptions)); +ajax.addResponseInterceptor( + cacheResponseInterceptorFactory(getCacheIdentifier, globalCacheOptions), +); + +const { response, body } = await ajax.requestJson('/my-url'); +``` + +### Ajax cache example + +```js +import { + ajax, + cacheRequestInterceptorFactory, + cacheResponseInterceptorFactory, +} from '@lion-web/ajax'; + +const 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 +}; + +// 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), +// ); + +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 }); + } +} +``` + +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. + +### Ajax cache Options + +```js +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 + // 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)}`; + }, +}; +``` + +## Considerations + ## Fetch Polyfill For IE11 you will need a polyfill for fetch. You should add this on your top level layer, e.g. your application. diff --git a/packages/ajax/docs/cache-technical-docs.md b/packages/ajax/docs/cache-technical-docs.md new file mode 100644 index 000000000..b6975ef77 --- /dev/null +++ b/packages/ajax/docs/cache-technical-docs.md @@ -0,0 +1,45 @@ +# 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/index.js b/packages/ajax/index.js index 54d09a030..eb9a60ab9 100644 --- a/packages/ajax/index.js +++ b/packages/ajax/index.js @@ -7,3 +7,9 @@ export { createXSRFRequestInterceptor, getCookie, } from './src/interceptors.js'; + +export { + cacheRequestInterceptorFactory, + cacheResponseInterceptorFactory, + validateOptions, +} from './src/interceptors-cache.js'; diff --git a/packages/ajax/src/AjaxClient.js b/packages/ajax/src/AjaxClient.js index 38354d7c9..0709f359b 100644 --- a/packages/ajax/src/AjaxClient.js +++ b/packages/ajax/src/AjaxClient.js @@ -2,31 +2,7 @@ import { acceptLanguageRequestInterceptor, createXSRFRequestInterceptor } from './interceptors.js'; import { AjaxClientFetchError } from './AjaxClientFetchError.js'; -/** - * @typedef {Object} AjaxClientConfig configuration for the AjaxClient instance - * @property {boolean} [addAcceptLanguage] the Accept-Language request HTTP header advertises - * which languages the client is able to understand, and which locale variant is preferred. - * @property {string|null} [xsrfCookieName] name of the XSRF cookie to read from - * @property {string|null} [xsrfHeaderName] name of the XSRF header to set - * @property {string} [jsonPrefix] the json prefix to use when fetching json (if any) - */ - -/** - * Intercepts a Request before fetching. Must return an instance of Request or Response. - * If a Respone is returned, the network call is skipped and it is returned as is. - * @typedef {(request: Request) => Promise} RequestInterceptor - */ - -/** - * Intercepts a Response before returning. Must return an instance of Response. - * @typedef {(response: Response) => Promise} ResponseInterceptor - */ - -/** - * Overrides the body property to also allow javascript objects - * as they get string encoded automatically - * @typedef {import('../types/ajaxClientTypes').LionRequestInit} LionRequestInit - */ +import './typedef.js'; /** * HTTP Client which acts as a small wrapper around `fetch`. Allows registering hooks which @@ -35,32 +11,45 @@ import { AjaxClientFetchError } from './AjaxClientFetchError.js'; */ export class AjaxClient { /** - * @param {AjaxClientConfig} config + * @param {Partial} config */ constructor(config = {}) { - const { - addAcceptLanguage = true, - xsrfCookieName = 'XSRF-TOKEN', - xsrfHeaderName = 'X-XSRF-TOKEN', - jsonPrefix, - } = config; + this.__config = { + addAcceptLanguage: true, + xsrfCookieName: 'XSRF-TOKEN', + xsrfHeaderName: 'X-XSRF-TOKEN', + jsonPrefix: '', + ...config, + }; - /** @type {string | undefined} */ - this._jsonPrefix = jsonPrefix; - /** @type {RequestInterceptor[]} */ + /** @type {Array.} */ this._requestInterceptors = []; - /** @type {ResponseInterceptor[]} */ + /** @type {Array.} */ this._responseInterceptors = []; - if (addAcceptLanguage) { + if (this.__config.addAcceptLanguage) { this.addRequestInterceptor(acceptLanguageRequestInterceptor); } - if (xsrfCookieName && xsrfHeaderName) { - this.addRequestInterceptor(createXSRFRequestInterceptor(xsrfCookieName, xsrfHeaderName)); + if (this.__config.xsrfCookieName && this.__config.xsrfHeaderName) { + this.addRequestInterceptor( + createXSRFRequestInterceptor(this.__config.xsrfCookieName, this.__config.xsrfHeaderName), + ); } } + /** + * Sets the config for the instance + * @param {AjaxClientConfig} config configuration for the AjaxClass instance + */ + set options(config) { + this.__config = config; + } + + get options() { + return this.__config; + } + /** @param {RequestInterceptor} requestInterceptor */ addRequestInterceptor(requestInterceptor) { this._requestInterceptors.push(requestInterceptor); @@ -92,11 +81,13 @@ export class AjaxClient { * interceptors. * * @param {RequestInfo} info - * @param {RequestInit} [init] + * @param {RequestInit & Partial} [init] * @returns {Promise} */ async request(info, init) { - const request = new Request(info, init); + const request = /** @type {CacheRequest} */ (new Request(info, { ...init })); + request.cacheOptions = init?.cacheOptions; + request.params = init?.params; // run request interceptors, returning directly and skipping the network // if a interceptor returns a Response @@ -112,7 +103,8 @@ export class AjaxClient { } } - const response = await fetch(interceptedRequest); + const response = /** @type {CacheResponse} */ (await fetch(interceptedRequest)); + response.request = interceptedRequest; let interceptedResponse = response; for (const intercept of this._responseInterceptors) { @@ -156,9 +148,9 @@ export class AjaxClient { const response = await this.request(info, jsonInit); let responseText = await response.text(); - if (typeof this._jsonPrefix === 'string') { - if (responseText.startsWith(this._jsonPrefix)) { - responseText = responseText.substring(this._jsonPrefix.length); + if (typeof this.__config.jsonPrefix === 'string') { + if (responseText.startsWith(this.__config.jsonPrefix)) { + responseText = responseText.substring(this.__config.jsonPrefix.length); } } diff --git a/packages/ajax/src/interceptors-cache.js b/packages/ajax/src/interceptors-cache.js new file mode 100644 index 000000000..68eef2a60 --- /dev/null +++ b/packages/ajax/src/interceptors-cache.js @@ -0,0 +1,346 @@ +/* eslint-disable consistent-return */ +/* eslint-disable no-param-reassign */ + +import './typedef.js'; + +const SECOND = 1000; +const MINUTE = SECOND * 60; +const HOUR = MINUTE * 60; + +class Cache { + constructor() { + this.expiration = new Date().getTime() + HOUR; + /** + * @type {{[url: string]: CacheConfig }} + */ + this.cacheConfig = {}; + + /** + * @type {{[url: string]: {expires: number, data: object} }} + */ + this._cacheObject = {}; + } + + /** + * Store an item in the cache + * @param {string} url key by which the cache is stored + * @param {object} data the cached object + */ + set(url, data) { + this._validateCache(); + this._cacheObject[url] = { + expires: new Date().getTime(), + data, + }; + } + + /** + * Retrieve an item from the cache + * @param {string} url key by which the cache is stored + * @param {number} timeToLive maximum time to allow cache to live + */ + get(url, timeToLive) { + this._validateCache(); + + const cacheResult = this._cacheObject[url]; + if (!cacheResult) { + return false; + } + const cacheAge = new Date().getTime() - cacheResult.expires; + + if (timeToLive !== null && cacheAge > timeToLive) { + return false; + } + return cacheResult.data; + } + + /** + * Delete all items from the cache that contain the given url + * @param {string} url key by which the cache is stored + */ + delete(url) { + this._validateCache(); + + Object.keys(this._cacheObject).forEach(key => { + if (key.indexOf(url) > -1) { + delete this._cacheObject[key]; + } + }); + } + + /** + * Delete all items from the cache that match given regex + * @param {RegExp} regex an regular expression to match cache entries + */ + deleteMatched(regex) { + this._validateCache(); + + Object.keys(this._cacheObject).forEach(key => { + const notMatch = !new RegExp(regex).test(key); + + if (notMatch) return; + + const isDataDeleted = delete this._cacheObject[key]; + + if (!isDataDeleted) { + throw new Error(`Failed to delete cache for a request '${key}'`); + } + }); + } + + /** + * Validate cache on each call to the Cache + * When the expiration date has passed, the _cacheObject will be replaced by an + * empty object + */ + _validateCache() { + if (new Date().getTime() > this.expiration) { + // @ts-ignore + this._cacheObject = {}; + } + return true; + } +} + +let caches = {}; + +/** + * Serialize search parameters into url query string parameters. + * If params === null, returns '' + * @param {Params} params query string parameters object + * @returns {string} of querystring parameters WITHOUT `?` or empty string '' + */ +export const searchParamSerializer = (params = {}) => + // @ts-ignore + typeof params === 'object' ? new URLSearchParams(params).toString() : ''; + +/** + * Returns the active cache instance for the current session + * If 'cacheIdentifier' changes the cache is reset, we avoid situation of accessing old cache + * and proactively clean it + * @param {string} cacheIdentifier usually the refreshToken of the owner of the cache + */ +const getCache = cacheIdentifier => { + if (caches[cacheIdentifier] && caches[cacheIdentifier]._validateCache()) { + return caches[cacheIdentifier]; + } + + // invalidate old caches + caches = {}; + // create new cache + caches[cacheIdentifier] = new Cache(); + return caches[cacheIdentifier]; +}; + +/** + * @param {CacheOptions} options Options to match cache + * @returns {ValidatedCacheOptions} + */ +export const validateOptions = ({ + useCache = false, + methods = ['get'], + timeToLive, + invalidateUrls, + invalidateUrlsRegex, + requestIdentificationFn, +}) => { + // validate 'cache' + if (typeof useCache !== 'boolean') { + throw new Error('Property `useCache` should be `true` or `false`'); + } + + if (methods[0] !== 'get' || methods.length !== 1) { + throw new Error('Functionality to use cache on anything except "get" is not yet supported'); + } + + // validate 'timeToLive', default 1 hour + if (timeToLive === undefined) { + timeToLive = 0; + } + if (Number.isNaN(parseInt(String(timeToLive), 10))) { + throw new Error('Property `timeToLive` must be of type `number`'); + } + // validate 'invalidateUrls', must be an `Array` or `falsy` + if (invalidateUrls) { + if (!Array.isArray(invalidateUrls)) { + throw new Error('Property `invalidateUrls` must be of type `Array` or `falsy`'); + } + } + // validate 'invalidateUrlsRegex', must be an regex expression or `falsy` + if (invalidateUrlsRegex) { + if (!(invalidateUrlsRegex instanceof RegExp)) { + throw new Error('Property `invalidateUrlsRegex` must be of type `RegExp` or `falsy`'); + } + } + // validate 'requestIdentificationFn', default is url + searchParams + if (requestIdentificationFn) { + if (typeof requestIdentificationFn !== 'function') { + throw new Error('Property `requestIdentificationFn` must be of type `function` or `falsy`'); + } + } else { + requestIdentificationFn = /** @param {any} data */ ( + { url, params }, + searchParamsSerializer, + ) => { + const serializedParams = searchParamsSerializer(params); + return serializedParams ? `${url}?${serializedParams}` : url; + }; + } + + return { + useCache, + methods, + timeToLive, + invalidateUrls, + invalidateUrlsRegex, + requestIdentificationFn, + }; +}; + +/** + * Request interceptor to return relevant cached requests + * @param {ValidatedCacheOptions} validatedInitialCacheOptions + * @param {CacheOptions=} configCacheOptions + * @returns {ValidatedCacheOptions} + */ +function composeCacheOptions(validatedInitialCacheOptions, configCacheOptions) { + /** @type {any} */ + let actionCacheOptions = {}; + + actionCacheOptions = + configCacheOptions && + validateOptions({ + ...validatedInitialCacheOptions, + ...configCacheOptions, + }); + + const cacheOptions = { + ...validatedInitialCacheOptions, + ...actionCacheOptions, + }; + + return cacheOptions; +} + +/** + * Request interceptor to return relevant cached requests + * @param {function(): string} getCacheIdentifier used to invalidate cache if identifier is changed + * @param {CacheOptions} globalCacheOptions + * @returns {CachedRequestInterceptor} + */ +export const cacheRequestInterceptorFactory = (getCacheIdentifier, globalCacheOptions) => { + const validatedInitialCacheOptions = validateOptions(globalCacheOptions); + + return /** @param {CacheRequest} cacheRequest */ async cacheRequest => { + const { method, status, statusText, headers } = cacheRequest; + + const cacheOptions = composeCacheOptions( + validatedInitialCacheOptions, + cacheRequest.cacheOptions, + ); + cacheRequest.cacheOptions = cacheOptions; + + // don't use cache if 'useCache' === false + if (!cacheOptions.useCache) { + return cacheRequest; + } + + const cacheId = cacheOptions.requestIdentificationFn(cacheRequest, searchParamSerializer); + + // cacheIdentifier is used to bind the cache to the current session + const currentCache = getCache(getCacheIdentifier()); + + const cacheResponse = currentCache.get(cacheId, cacheOptions.timeToLive); + + // don't use cache if the request method is not part of the configs methods + if (cacheOptions.methods.indexOf(method.toLowerCase()) === -1) { + // If it's NOT one of the config.methods, invalidate caches + currentCache.delete(cacheId); + // also invalidate caches matching to cacheOptions + if (cacheOptions.invalidateUrls) { + cacheOptions.invalidateUrls.forEach( + /** @type {string} */ invalidateUrl => { + currentCache.delete(invalidateUrl); + }, + ); + } + // also invalidate caches matching to invalidateUrlsRegex + if (cacheOptions.invalidateUrlsRegex) { + currentCache.deleteMatched(cacheOptions.invalidateUrlsRegex); + } + + return cacheRequest; + } + + if (cacheResponse) { + // eslint-disable-next-line no-param-reassign + 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)); + } + + return cacheRequest; + }; +}; + +/** + * Response interceptor to cache relevant requests + * @param {function(): string} getCacheIdentifier used to invalidate cache if identifier is changed + * @param {CacheOptions} globalCacheOptions + * @returns {CachedResponseInterceptor} + */ +export const cacheResponseInterceptorFactory = (getCacheIdentifier, globalCacheOptions) => { + const validatedInitialCacheOptions = validateOptions(globalCacheOptions); + + /** + * Axios response https://github.com/axios/axios#response-schema + */ + return /** @param {CacheResponse} cacheResponse */ async cacheResponse => { + if (!getCacheIdentifier()) { + throw new Error(`getCacheIdentifier returns falsy`); + } + + const cacheOptions = composeCacheOptions( + validatedInitialCacheOptions, + cacheResponse.request?.cacheOptions, + ); + + const isAlreadyFromCache = !!cacheOptions.fromCache; + // caching all responses with not default `timeToLive` + const isCacheActive = cacheOptions.timeToLive > 0; + + if (isAlreadyFromCache || !isCacheActive) { + return cacheResponse; + } + + // if the request is one of the options.methods; store response in cache + if ( + cacheResponse.request && + cacheOptions.methods.indexOf(cacheResponse.request.method.toLowerCase()) > -1 + ) { + // string that identifies cache entry + const cacheId = cacheOptions.requestIdentificationFn( + cacheResponse.request, + searchParamSerializer, + ); + + // store the response data in the cache + getCache(getCacheIdentifier()).set(cacheId, cacheResponse.body); + } else { + // don't store in cache if the request method is not part of the configs methods + return cacheResponse; + } + + return cacheResponse; + }; +}; diff --git a/packages/ajax/src/interceptors.js b/packages/ajax/src/interceptors.js index f0eb63be1..7a45b0fd3 100644 --- a/packages/ajax/src/interceptors.js +++ b/packages/ajax/src/interceptors.js @@ -1,6 +1,4 @@ -/** - * @typedef {import('./AjaxClient').RequestInterceptor} RequestInterceptor - */ +import './typedef.js'; /** * @param {string} name the cookie name diff --git a/packages/ajax/src/typedef.js b/packages/ajax/src/typedef.js new file mode 100644 index 000000000..002770087 --- /dev/null +++ b/packages/ajax/src/typedef.js @@ -0,0 +1,18 @@ +/** + * @typedef {import('../types/types').LionRequestInit} LionRequestInit + * @typedef {import('../types/types').AjaxClientConfig} AjaxClientConfig + * @typedef {import('../types/types').RequestInterceptor} RequestInterceptor + * @typedef {import('../types/types').ResponseInterceptor} ResponseInterceptor + * @typedef {import('../types/types').CacheConfig} CacheConfig + * @typedef {import('../types/types').Params} Params + * @typedef {import('../types/types').RequestIdentificationFn} RequestIdentificationFn + * @typedef {import('../types/types').CacheOptions} CacheOptions + * @typedef {import('../types/types').ValidatedCacheOptions} ValidatedCacheOptions + * @typedef {import('../types/types').CacheRequestExtension} CacheRequestExtension + * @typedef {import('../types/types').CacheResponseExtension} CacheResponseExtension + * @typedef {import('../types/types').CacheResponseRequest} CacheResponseRequest + * @typedef {import('../types/types').CacheRequest} CacheRequest + * @typedef {import('../types/types').CacheResponse} CacheResponse + * @typedef {import('../types/types').CachedRequestInterceptor} CachedRequestInterceptor + * @typedef {import('../types/types').CachedResponseInterceptor} CachedResponseInterceptor + */ diff --git a/packages/ajax/test/AjaxClient.test.js b/packages/ajax/test/AjaxClient.test.js index 2db34abbd..55442c466 100644 --- a/packages/ajax/test/AjaxClient.test.js +++ b/packages/ajax/test/AjaxClient.test.js @@ -10,7 +10,7 @@ describe('AjaxClient', () => { beforeEach(() => { fetchStub = stub(window, 'fetch'); - fetchStub.returns(Promise.resolve('mock response')); + fetchStub.returns(Promise.resolve(new Response('mock response'))); ajax = new AjaxClient(); }); @@ -20,7 +20,7 @@ describe('AjaxClient', () => { describe('request()', () => { it('calls fetch with the given args, returning the result', async () => { - const response = await ajax.request('/foo', { method: 'POST' }); + const response = await (await ajax.request('/foo', { method: 'POST' })).text(); expect(fetchStub).to.have.been.calledOnce; const request = fetchStub.getCall(0).args[0]; @@ -115,12 +115,19 @@ describe('AjaxClient', () => { }); it('addResponseInterceptor() adds a function which intercepts the response', async () => { - // @ts-expect-error we're mocking the response as a simple promise which returns a string - ajax.addResponseInterceptor(r => `${r} intercepted-1`); - // @ts-expect-error we're mocking the response as a simple promise which returns a string - ajax.addResponseInterceptor(r => `${r} intercepted-2`); + ajax.addResponseInterceptor(async r => { + const { status, statusText, headers } = r; + const body = await r.text(); + return new Response(`${body} intercepted-1`, { status, statusText, headers }); + }); - const response = await ajax.request('/foo', { method: 'POST' }); + ajax.addResponseInterceptor(async r => { + const { status, statusText, headers } = r; + const body = await r.text(); + return new Response(`${body} intercepted-2`, { status, statusText, headers }); + }); + + const response = await (await ajax.request('/foo', { method: 'POST' })).text(); expect(response).to.equal('mock response intercepted-1 intercepted-2'); }); @@ -143,7 +150,7 @@ describe('AjaxClient', () => { // @ts-expect-error we're mocking the response as a simple promise which returns a string ajax.removeResponseInterceptor(interceptor); - const response = await ajax.request('/foo', { method: 'POST' }); + const response = await (await ajax.request('/foo', { method: 'POST' })).text(); expect(response).to.equal('mock response'); }); }); diff --git a/packages/ajax/test/interceptors-cache.test.js b/packages/ajax/test/interceptors-cache.test.js new file mode 100644 index 000000000..80d4c4061 --- /dev/null +++ b/packages/ajax/test/interceptors-cache.test.js @@ -0,0 +1,596 @@ +import { expect } from '@open-wc/testing'; +import { spy, stub, useFakeTimers } from 'sinon'; +import '../src/typedef.js'; + +import { cacheRequestInterceptorFactory, cacheResponseInterceptorFactory, ajax } from '../index.js'; + +describe('ajax cache', function describeLibCache() { + /** @type {number | undefined} */ + let cacheId; + /** @type {import('sinon').SinonStub} */ + let fetchStub; + /** @type {() => string} */ + let getCacheIdentifier; + + const newCacheId = () => { + if (!cacheId) { + cacheId = 1; + } else { + cacheId += 1; + } + return cacheId; + }; + + /** + * @param {ajax} ajaxInstance + * @param {CacheOptions} options + */ + const addCacheInterceptors = (ajaxInstance, options) => { + const requestInterceptorIndex = + ajaxInstance._requestInterceptors.push( + cacheRequestInterceptorFactory(getCacheIdentifier, options), + ) - 1; + + const responseInterceptorIndex = + ajaxInstance._responseInterceptors.push( + cacheResponseInterceptorFactory(getCacheIdentifier, options), + ) - 1; + + return { + requestInterceptorIndex, + responseInterceptorIndex, + }; + }; + + /** + * @param {ajax} ajaxInstance + * @param {{requestInterceptorIndex: number, responseInterceptorIndex: number}} indexes + */ + const removeCacheInterceptors = ( + ajaxInstance, + { requestInterceptorIndex, responseInterceptorIndex }, + ) => { + ajaxInstance._requestInterceptors.splice(requestInterceptorIndex, 1); + ajaxInstance._responseInterceptors.splice(responseInterceptorIndex, 1); + }; + + beforeEach(() => { + getCacheIdentifier = () => String(cacheId); + fetchStub = stub(window, 'fetch'); + fetchStub.returns(Promise.resolve(new Response('mock response'))); + }); + + afterEach(() => { + fetchStub.restore(); + }); + + describe('Original ajax instance', () => { + it('allows direct ajax calls without cache interceptors configured', () => { + return ajax + .request('/test') + .then(() => { + expect(fetchStub.callCount).to.equal(1); + }) + .then(() => ajax.request('/test')) + .then(() => { + expect(fetchStub.callCount).to.equal(2); + }); + }); + }); + + describe('Cache config validation', () => { + it('validates `useCache`', () => { + newCacheId(); + const test = () => { + const indexes = addCacheInterceptors(ajax, { + // @ts-ignore needed for test + useCache: 'fakeUseCacheType', + }); + removeCacheInterceptors(ajax, indexes); + }; + expect(test).to.throw(); + }); + + it('validates property `timeToLive` throws if not type `number`', () => { + newCacheId(); + expect(() => { + const indexes = addCacheInterceptors(ajax, { + useCache: true, + // @ts-ignore needed for test + timeToLive: '', + }); + removeCacheInterceptors(ajax, indexes); + }).to.throw(); + }); + + it('validates cache identifier function', () => { + // @ts-ignore needed for test + cacheId = ''; + + const indexes = addCacheInterceptors(ajax, { useCache: true }); + + return ajax.request('/test').catch( + /** @param {Error} err */ err => { + expect(err.message).to.equal('getCacheIdentifier returns falsy'); + + removeCacheInterceptors(ajax, indexes); + }, + ); + }); + + it("throws when using methods other than `['get']`", () => { + newCacheId(); + + expect(() => { + const indexes = addCacheInterceptors(ajax, { + useCache: true, + methods: ['get', 'post'], + }); + removeCacheInterceptors(ajax, indexes); + }).to.throw(/not yet supported/); + }); + + it('throws error when requestIdentificationFn is not a function', () => { + newCacheId(); + + expect(() => { + const indexes = addCacheInterceptors(ajax, { + useCache: true, + // @ts-ignore needed for test + requestIdentificationFn: 'not a function', + }); + removeCacheInterceptors(ajax, indexes); + }).to.throw(/Property `requestIdentificationFn` must be of type `function` or `falsy`/); + }); + }); + + describe('Cached responses', () => { + it('returns the cached object on second call with `useCache: true`', () => { + newCacheId(); + + const indexes = addCacheInterceptors(ajax, { + useCache: true, + timeToLive: 100, + }); + const ajaxRequestSpy = spy(ajax, 'request'); + + return ajax + .request('/test') + .then(() => { + expect(ajaxRequestSpy.calledOnce).to.be.true; + expect(ajaxRequestSpy.calledWith('/test')).to.be.true; + }) + .then(() => ajax.request('/test')) + .then(() => { + expect(fetchStub.callCount).to.equal(1); + }) + .finally(() => { + ajaxRequestSpy.restore(); + removeCacheInterceptors(ajax, indexes); + }); + }); + + it('all calls with non-default `timeToLive` are cached proactively', () => { + newCacheId(); + + const indexes = addCacheInterceptors(ajax, { + useCache: false, + timeToLive: 100, + }); + const ajaxRequestSpy = spy(ajax, 'request'); + + return ajax + .request('/test') + .then(() => { + expect(ajaxRequestSpy.calledOnce).to.be.true; + expect(ajaxRequestSpy.calledWith('/test')).to.be.true; + }) + .then(() => { + expect(fetchStub.callCount).to.equal(1); + }) + .then(() => ajax.request('/test')) + .then(() => { + expect(fetchStub.callCount).to.equal(2); + }) + .then(() => + ajax.request('/test', { + cacheOptions: { + useCache: true, + }, + }), + ) + .then(() => { + expect(fetchStub.callCount).to.equal(2); + }) + .finally(() => { + ajaxRequestSpy.restore(); + removeCacheInterceptors(ajax, indexes); + }); + }); + + it('returns the cached object on second call with `useCache: true`, with querystring parameters', () => { + newCacheId(); + + const indexes = addCacheInterceptors(ajax, { + useCache: true, + timeToLive: 100, + }); + + const ajaxRequestSpy = spy(ajax, 'request'); + + return ajax + .request('/test', { + params: { + q: 'test', + page: 1, + }, + }) + .then(() => { + expect(ajaxRequestSpy.calledOnce).to.be.true; + expect(ajaxRequestSpy.calledWith('/test')).to.be.true; + }) + .then(() => + ajax.request('/test', { + params: { + q: 'test', + page: 1, + }, + }), + ) + .then(() => { + expect(fetchStub.callCount).to.equal(1); + }) + .then(() => + // a request with different param should not be cached + ajax.request('/test', { + params: { + q: 'test', + page: 2, + }, + }), + ) + .then(() => { + expect(fetchStub.callCount).to.equal(2); + }) + .finally(() => { + ajaxRequestSpy.restore(); + removeCacheInterceptors(ajax, indexes); + }); + }); + + it('uses cache when inside `timeToLive: 5000` window', () => { + newCacheId(); + const clock = useFakeTimers({ + shouldAdvanceTime: true, + }); + + const indexes = addCacheInterceptors(ajax, { + useCache: true, + timeToLive: 5000, + }); + const ajaxRequestSpy = spy(ajax, 'request'); + + return ajax + .request('/test') + .then(() => { + expect(ajaxRequestSpy.calledOnce).to.be.true; + expect(ajaxRequestSpy.calledWith('/test')).to.be.true; + expect(fetchStub.callCount).to.equal(1); + }) + .then(() => { + clock.tick(4900); + }) + .then(() => ajax.request('/test')) + .then(() => { + expect(fetchStub.callCount).to.equal(1); + clock.tick(5100); + }) + .then(() => ajax.request('/test')) + .then(() => { + expect(fetchStub.callCount).to.equal(2); + }) + .finally(() => { + ajaxRequestSpy.restore(); + clock.restore(); + removeCacheInterceptors(ajax, indexes); + }); + }); + + it('uses custom requestIdentificationFn when passed', () => { + newCacheId(); + + const customRequestIdFn = /** @type {RequestIdentificationFn} */ (request, serializer) => { + let serializedRequestParams = ''; + if (request.params) { + serializedRequestParams = `?${serializer(request.params)}`; + } + return `${new URL(/** @type {string} */ (request.url)).pathname}-${request.headers?.get( + 'x-id', + )}${serializedRequestParams}`; + }; + const reqIdSpy = spy(customRequestIdFn); + const indexes = addCacheInterceptors(ajax, { + useCache: true, + requestIdentificationFn: reqIdSpy, + }); + + return ajax + .request('/test', { headers: { 'x-id': '1' } }) + .then(() => { + expect(reqIdSpy.calledOnce); + expect(reqIdSpy.returnValues[0]).to.equal(`/test-1`); + }) + .finally(() => { + removeCacheInterceptors(ajax, indexes); + }); + }); + }); + + describe('Cache invalidation', () => { + it('previously cached data has to be invalidated when regex invalidation rule triggered', () => { + newCacheId(); + + const indexes = addCacheInterceptors(ajax, { + useCache: true, + timeToLive: 1000, + invalidateUrlsRegex: /foo/gi, + }); + + return ajax + .request('/test') + .then(() => ajax.request('/test')) + .then(() => { + expect(fetchStub.callCount).to.equal(1); + }) + .then(() => ajax.request('/foo-request-1')) + .then(() => { + expect(fetchStub.callCount).to.equal(2); + }) + .then(() => ajax.request('/foo-request-1')) + .then(() => { + expect(fetchStub.callCount).to.equal(2); + }) + .then(() => ajax.request('/foo-request-2')) + .then(() => { + expect(fetchStub.callCount).to.equal(3); + }) + .then(() => ajax.request('/foo-request-2')) + .then(() => { + expect(fetchStub.callCount).to.equal(3); + }) + .then(() => ajax.request('/test', { method: 'POST' })) + .then(() => { + expect(fetchStub.callCount).to.equal(4); + }) + .then(() => ajax.request('/foo-request-1')) + .then(() => { + expect(fetchStub.callCount).to.equal(5); + }) + .then(() => ajax.request('/foo-request-2')) + .then(() => { + expect(fetchStub.callCount).to.equal(6); + }) + .finally(() => { + removeCacheInterceptors(ajax, indexes); + }); + }); + + it('previously cached data has to be invalidated when regex invalidation rule triggered and urls are nested', () => { + newCacheId(); + + const indexes = addCacheInterceptors(ajax, { + useCache: true, + timeToLive: 1000, + invalidateUrlsRegex: /posts/gi, + }); + + return ajax + .request('/test') + .then(() => ajax.request('/test')) + .then(() => { + expect(fetchStub.callCount).to.equal(1); + }) + .then(() => ajax.request('/posts')) + .then(() => { + expect(fetchStub.callCount).to.equal(2); + }) + .then(() => ajax.request('/posts')) + .then(() => { + // no new requests, cached + expect(fetchStub.callCount).to.equal(2); + }) + .then(() => ajax.request('/posts/1')) + .then(() => { + expect(fetchStub.callCount).to.equal(3); + }) + .then(() => ajax.request('/posts/1')) + .then(() => { + // no new requests, cached + expect(fetchStub.callCount).to.equal(3); + }) + .then(() => + // cleans cache for defined urls + ajax.request('/test', { method: 'POST' }), + ) + .then(() => { + expect(fetchStub.callCount).to.equal(4); + }) + .then(() => ajax.request('/posts')) + .then(() => { + // new requests, cache is cleaned + expect(fetchStub.callCount).to.equal(5); + }) + .then(() => ajax.request('/posts/1')) + .then(() => { + // new requests, cache is cleaned + expect(fetchStub.callCount).to.equal(6); + }) + .finally(() => { + removeCacheInterceptors(ajax, indexes); + }); + }); + + it('deletes cache after one hour', () => { + newCacheId(); + const clock = useFakeTimers({ + shouldAdvanceTime: true, + }); + + const ajaxRequestSpy = spy(ajax, 'request'); + const indexes = addCacheInterceptors(ajax, { + useCache: true, + timeToLive: 1000 * 60 * 60, + }); + + return ajax + .request('/test-hour') + .then(() => { + expect(ajaxRequestSpy.calledOnce).to.be.true; + expect(ajaxRequestSpy.calledWith('/test-hour')).to.be.true; + expect(fetchStub.callCount).to.equal(1); + }) + .then(() => { + clock.tick(1000 * 60 * 59); // 0:59 hour + }) + .then(() => ajax.request('/test-hour')) + .then(() => { + expect(fetchStub.callCount).to.equal(1); + clock.tick(1000 * 60 * 61); // 1:01 hour + }) + .then(() => ajax.request('/test-hour')) + .then(() => { + expect(fetchStub.callCount).to.equal(2); + }) + .finally(() => { + ajaxRequestSpy.restore(); + clock.restore(); + removeCacheInterceptors(ajax, indexes); + }); + }); + + it('invalidates invalidateUrls endpoints', () => { + newCacheId(); + + const indexes = addCacheInterceptors(ajax, { + useCache: true, + timeToLive: 500, + }); + + const actionConfig = { + cacheOptions: { + invalidateUrls: ['/test-invalid-url'], + }, + }; + + return ajax + .request('/test-valid-url', { ...actionConfig }) + .then(() => { + expect(fetchStub.callCount).to.equal(1); + }) + .then(() => ajax.request('/test-invalid-url')) + .then(() => { + expect(fetchStub.callCount).to.equal(2); + }) + .then(() => + // 'post' will invalidate 'own' cache and the one mentioned in config + ajax.request('/test-valid-url', { ...actionConfig, method: 'POST' }), + ) + .then(() => { + expect(fetchStub.callCount).to.equal(3); + }) + .then(() => ajax.request('/test-invalid-url')) + .then(() => { + // indicates that 'test-invalid-url' cache was removed + // because the server registered new request + expect(fetchStub.callCount).to.equal(4); + }) + .finally(() => { + removeCacheInterceptors(ajax, indexes); + }); + }); + + it('invalidates cache on a post', () => { + newCacheId(); + + const indexes = addCacheInterceptors(ajax, { + useCache: true, + timeToLive: 100, + }); + const ajaxRequestSpy = spy(ajax, 'request'); + + return ajax + .request('/test-post') + .then(() => { + expect(ajaxRequestSpy.calledOnce).to.be.true; + expect(ajaxRequestSpy.calledWith('/test-post')).to.be.true; + expect(fetchStub.callCount).to.equal(1); + }) + .then(() => ajax.request('/test-post', { method: 'POST', body: 'data-post' })) + .then(() => { + expect(ajaxRequestSpy.calledTwice).to.be.true; + expect(ajaxRequestSpy.calledWith('/test-post')).to.be.true; + expect(fetchStub.callCount).to.equal(2); + }) + .then(() => ajax.request('/test-post')) + .then(() => { + expect(fetchStub.callCount).to.equal(3); + }) + .finally(() => { + ajaxRequestSpy.restore(); + removeCacheInterceptors(ajax, indexes); + }); + }); + + it('caches response but does not return it when expiration time is 0', () => { + newCacheId(); + + const indexes = addCacheInterceptors(ajax, { + useCache: true, + timeToLive: 0, + }); + + const ajaxRequestSpy = spy(ajax, 'request'); + + return ajax + .request('/test') + .then(() => { + const clock = useFakeTimers(); + expect(ajaxRequestSpy.calledOnce).to.be.true; + expect(ajaxRequestSpy.calledWith('/test')).to.be.true; + clock.tick(1); + clock.restore(); + }) + .then(() => ajax.request('/test')) + .then(() => { + expect(fetchStub.callCount).to.equal(2); + }) + .finally(() => { + ajaxRequestSpy.restore(); + removeCacheInterceptors(ajax, indexes); + }); + }); + + it('does not use cache when `useCache: false` in the action', () => { + newCacheId(); + getCacheIdentifier = () => 'cacheIdentifier2'; + + const ajaxAlwaysRequestSpy = spy(ajax, 'request'); + const indexes = addCacheInterceptors(ajax, { useCache: true }); + + return ajax + .request('/test') + .then(() => { + expect(ajaxAlwaysRequestSpy.calledOnce, 'calledOnce').to.be.true; + expect(ajaxAlwaysRequestSpy.calledWith('/test')); + }) + .then(() => ajax.request('/test', { cacheOptions: { useCache: false } })) + .then(() => { + expect(fetchStub.callCount).to.equal(2); + }) + .finally(() => { + ajaxAlwaysRequestSpy.restore(); + removeCacheInterceptors(ajax, indexes); + }); + }); + }); +}); diff --git a/packages/ajax/types/ajaxClientTypes.d.ts b/packages/ajax/types/ajaxClientTypes.d.ts deleted file mode 100644 index 94d591a55..000000000 --- a/packages/ajax/types/ajaxClientTypes.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * We have a method requestJson that encodes JS Object to - * a string automatically for `body` property. - * Sadly, Typescript doesn't allow us to extend RequestInit - * and override body prop because it is incompatible, so we - * omit it first from the base RequestInit. - */ -export interface LionRequestInit extends Omit { - body?: BodyInit | null | Object; -} diff --git a/packages/ajax/types/types.d.ts b/packages/ajax/types/types.d.ts new file mode 100644 index 000000000..750bc4f83 --- /dev/null +++ b/packages/ajax/types/types.d.ts @@ -0,0 +1,80 @@ +/** + * We have a method requestJson that encodes JS Object to + * a string automatically for `body` property. + * Sadly, Typescript doesn't allow us to extend RequestInit + * and override body prop because it is incompatible, so we + * omit it first from the base RequestInit. + */ +export interface LionRequestInit extends Omit { + body?: BodyInit | null | Object; + request?: CacheRequest; +} + +export interface AjaxClientConfig { + addAcceptLanguage: boolean; + xsrfCookieName: string | null; + xsrfHeaderName: string | null; + jsonPrefix: string; +} + +export type RequestInterceptor = (request: Request) => Promise; +export type ResponseInterceptor = (response: Response) => Promise; + +export interface CacheConfig { + expires: string; +} + +export type Params = { [key: string]: any }; + +export type RequestIdentificationFn = ( + request: Partial, + searchParamsSerializer: (params: Params) => string, +) => string; + +export interface CacheOptions { + useCache?: boolean; + methods?: string[]; + timeToLive?: number; + invalidateUrls?: string[]; + invalidateUrlsRegex?: RegExp; + requestIdentificationFn?: RequestIdentificationFn; + fromCache?: boolean; +} + +export interface ValidatedCacheOptions { + useCache: boolean; + methods: string[]; + timeToLive: number; + invalidateUrls?: string[]; + invalidateUrlsRegex?: RegExp; + requestIdentificationFn: RequestIdentificationFn; + fromCache?: boolean; +} + +export interface CacheRequestExtension { + cacheOptions?: CacheOptions; + adapter: any; + status: number; + statusText: string; + params: Params; +} + +export interface CacheResponseRequest { + cacheOptions?: CacheOptions; + method: string; +} + +export interface CacheResponseExtension { + request: CacheResponseRequest; + data: object | string; +} + +export type CacheRequest = Request & Partial; + +export type CacheResponse = Response & Partial; + +export type CachedRequestInterceptor = ( + request: CacheRequest, +) => Promise; + +export type CachedResponseInterceptor = (request: CacheResponse) => Promise;