From 558edcb6862cb05981e4a3010b37dc1737487438 Mon Sep 17 00:00:00 2001 From: Martin Pool Date: Mon, 9 May 2022 13:10:41 +0200 Subject: [PATCH] feat(ajax): add a `maxResponseSize` cache option to specify a max size for responses to be cached --- .changeset/good-berries-watch.md | 5 ++ .changeset/pretty-mice-beam.md | 2 +- packages/ajax/src/cacheManager.js | 6 ++ .../src/interceptors/cacheInterceptors.js | 24 +++++- packages/ajax/test/cacheManager.test.js | 44 +++++++++++ .../interceptors/cacheInterceptors.test.js | 74 +++++++++++++++++++ packages/ajax/types/types.d.ts | 1 + 7 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 .changeset/good-berries-watch.md diff --git a/.changeset/good-berries-watch.md b/.changeset/good-berries-watch.md new file mode 100644 index 000000000..031f8215d --- /dev/null +++ b/.changeset/good-berries-watch.md @@ -0,0 +1,5 @@ +--- +'@lion/ajax': minor +--- + +Adds a `maxResponseSize` cache option to specify a max size for responses to be cached. The option prevents caching and cache retrieval for responses that are larger than the given maximum as reported in the `Content-Length` header. If this header is missing nothing happens, that is to say caching is not prevented. diff --git a/.changeset/pretty-mice-beam.md b/.changeset/pretty-mice-beam.md index 667407ed7..d83441287 100644 --- a/.changeset/pretty-mice-beam.md +++ b/.changeset/pretty-mice-beam.md @@ -2,4 +2,4 @@ '@lion/ajax': minor --- -Add cache filter for content types +Adds a `contentTypes` cache option to specify a whitelist of content types to be cached. The option prevents caching and cache retrieval for responses that do not have one of these values in the `Content-Type` header. diff --git a/packages/ajax/src/cacheManager.js b/packages/ajax/src/cacheManager.js index 3e07eeeb8..933287b4a 100644 --- a/packages/ajax/src/cacheManager.js +++ b/packages/ajax/src/cacheManager.js @@ -83,6 +83,7 @@ export const extendCacheOptions = ({ invalidateUrls, invalidateUrlsRegex, contentTypes, + maxResponseSize, }) => ({ useCache, methods, @@ -91,6 +92,7 @@ export const extendCacheOptions = ({ invalidateUrls, invalidateUrlsRegex, contentTypes, + maxResponseSize, }); /** @@ -104,6 +106,7 @@ export const validateCacheOptions = ({ invalidateUrls, invalidateUrlsRegex, contentTypes, + maxResponseSize, } = {}) => { if (useCache !== undefined && typeof useCache !== 'boolean') { throw new Error('Property `useCache` must be a `boolean`'); @@ -126,6 +129,9 @@ export const validateCacheOptions = ({ if (contentTypes !== undefined && !Array.isArray(contentTypes)) { throw new Error('Property `contentTypes` must be an `Array` or `undefined`'); } + if (maxResponseSize !== undefined && !Number.isFinite(maxResponseSize)) { + throw new Error('Property `maxResponseSize` must be a finite `number`'); + } }; /** diff --git a/packages/ajax/src/interceptors/cacheInterceptors.js b/packages/ajax/src/interceptors/cacheInterceptors.js index e33b90af9..bf7f3df28 100644 --- a/packages/ajax/src/interceptors/cacheInterceptors.js +++ b/packages/ajax/src/interceptors/cacheInterceptors.js @@ -31,6 +31,21 @@ const isResponseContentTypeSupported = (response, { contentTypes } = {}) => { return contentTypes.includes(String(response.headers.get('Content-Type'))); }; +/** + * Tests whether the response size is not too large to be cached according to the `maxResponseSize` property + * @param {Response} response + * @param {CacheOptions} cacheOptions + * @returns {boolean} `true` if the `maxResponseSize` property is not larger than zero, or if the Content-Length header is not present, or if the value of the header is not larger than the `maxResponseSize` property + */ +const isResponseSizeSupported = (response, { maxResponseSize } = {}) => { + const responseSize = +(response.headers.get('Content-Length') || 0); + + if (!maxResponseSize) return true; + if (!responseSize) return true; + + return responseSize <= maxResponseSize; +}; + /** * Request interceptor to return relevant cached requests * @param {function(): string} getCacheId used to invalidate cache if identifier is changed @@ -70,7 +85,11 @@ const createCacheRequestInterceptor = } const cachedResponse = ajaxCache.get(requestId, cacheOptions.maxAge); - if (cachedResponse && isResponseContentTypeSupported(cachedResponse, cacheOptions)) { + if ( + cachedResponse && + isResponseContentTypeSupported(cachedResponse, cacheOptions) && + isResponseSizeSupported(cachedResponse, cacheOptions) + ) { // Return the response from cache request.cacheOptions = request.cacheOptions ?? { useCache: false }; /** @type {CacheResponse} */ @@ -106,7 +125,8 @@ const createCacheResponseInterceptor = if ( isCurrentSessionId(response.request.cacheSessionId) && - isResponseContentTypeSupported(response, cacheOptions) + isResponseContentTypeSupported(response, cacheOptions) && + isResponseSizeSupported(response, cacheOptions) ) { // Cache the response ajaxCache.set(requestId, response.clone()); diff --git a/packages/ajax/test/cacheManager.test.js b/packages/ajax/test/cacheManager.test.js index 9aed123dd..2b00e0260 100644 --- a/packages/ajax/test/cacheManager.test.js +++ b/packages/ajax/test/cacheManager.test.js @@ -75,6 +75,7 @@ describe('cacheManager', () => { invalidateUrls: invalidateUrlsResult, invalidateUrlsRegex: invalidateUrlsRegexResult, contentTypes, + maxResponseSize, } = extendCacheOptions({ invalidateUrls, invalidateUrlsRegex }); // Assert expect(useCache).to.be.false; @@ -84,6 +85,7 @@ describe('cacheManager', () => { expect(invalidateUrlsResult).to.equal(invalidateUrls); expect(invalidateUrlsRegexResult).to.equal(invalidateUrlsRegex); expect(contentTypes).to.be.undefined; + expect(maxResponseSize).to.be.undefined; }); it('the DEFAULT_GET_REQUEST_ID function throws when called with no arguments', () => { @@ -131,18 +133,22 @@ describe('cacheManager', () => { it('does not accept null as argument', () => { expect(() => validateCacheOptions(null)).to.throw(TypeError); }); + it('accepts an empty object', () => { expect(() => validateCacheOptions({})).not.to.throw( 'Property `useCache` must be a `boolean`', ); }); + describe('the useCache property', () => { it('accepts a boolean', () => { expect(() => validateCacheOptions({ useCache: false })).not.to.throw; }); + it('accepts undefined', () => { expect(() => validateCacheOptions({ useCache: undefined })).not.to.throw; }); + it('does not accept anything else', () => { // @ts-ignore expect(() => validateCacheOptions({ useCache: '' })).to.throw( @@ -150,13 +156,16 @@ describe('cacheManager', () => { ); }); }); + describe('the methods property', () => { it('accepts an array with the value `get`', () => { expect(() => validateCacheOptions({ methods: ['get'] })).not.to.throw; }); + it('accepts undefined', () => { expect(() => validateCacheOptions({ methods: undefined })).not.to.throw; }); + it('does not accept anything else', () => { expect(() => validateCacheOptions({ methods: [] })).to.throw( 'Cache can only be utilized with `GET` method', @@ -169,13 +178,16 @@ describe('cacheManager', () => { ); }); }); + describe('the maxAge property', () => { it('accepts a finite number', () => { expect(() => validateCacheOptions({ maxAge: 42 })).not.to.throw; }); + it('accepts undefined', () => { expect(() => validateCacheOptions({ maxAge: undefined })).not.to.throw; }); + it('does not accept anything else', () => { // @ts-ignore expect(() => validateCacheOptions({ maxAge: 'string' })).to.throw( @@ -186,6 +198,7 @@ describe('cacheManager', () => { ); }); }); + describe('the invalidateUrls property', () => { it('accepts an array', () => { // @ts-ignore Typescript requires this to be an array of string, but this is not checked by validateCacheOptions @@ -193,9 +206,11 @@ describe('cacheManager', () => { validateCacheOptions({ invalidateUrls: [6, 'elements', 'in', 1, true, Array] }), ).not.to.throw; }); + it('accepts undefined', () => { expect(() => validateCacheOptions({ invalidateUrls: undefined })).not.to.throw; }); + it('does not accept anything else', () => { // @ts-ignore expect(() => validateCacheOptions({ invalidateUrls: 'not-an-array' })).to.throw( @@ -203,14 +218,17 @@ describe('cacheManager', () => { ); }); }); + describe('the invalidateUrlsRegex property', () => { it('accepts a regular expression', () => { expect(() => validateCacheOptions({ invalidateUrlsRegex: /this is a very picky regex/ })) .not.to.throw; }); + it('accepts undefined', () => { expect(() => validateCacheOptions({ invalidateUrlsRegex: undefined })).not.to.throw; }); + it('does not accept anything else', () => { // @ts-ignore expect(() => @@ -218,6 +236,7 @@ describe('cacheManager', () => { ).to.throw('Property `invalidateUrlsRegex` must be a `RegExp` or `undefined`'); }); }); + describe('the requestIdFunction property', () => { it('accepts a function', () => { // @ts-ignore Typescript requires the requestIdFunction to return a string, but this is not checked by validateCacheOptions @@ -225,9 +244,11 @@ describe('cacheManager', () => { validateCacheOptions({ requestIdFunction: () => ['this-is-ok-outside-typescript'] }), ).not.to.throw; }); + it('accepts undefined', () => { expect(() => validateCacheOptions({ requestIdFunction: undefined })).not.to.throw; }); + it('does not accept anything else', () => { // @ts-ignore expect(() => validateCacheOptions({ requestIdFunction: 'not a function' })).to.throw( @@ -235,15 +256,18 @@ describe('cacheManager', () => { ); }); }); + describe('the contentTypes property', () => { it('accepts an array', () => { // @ts-ignore Typescript requires this to be an array of string, but this is not checked by validateCacheOptions expect(() => validateCacheOptions({ contentTypes: [6, 'elements', 'in', 1, true, Array] })) .not.to.throw; }); + it('accepts undefined', () => { expect(() => validateCacheOptions({ contentTypes: undefined })).not.to.throw; }); + it('does not accept anything else', () => { // @ts-ignore expect(() => validateCacheOptions({ contentTypes: 'not-an-array' })).to.throw( @@ -251,6 +275,26 @@ describe('cacheManager', () => { ); }); }); + + describe('the maxResponseSize property', () => { + it('accepts a finite number', () => { + expect(() => validateCacheOptions({ maxResponseSize: 42 })).not.to.throw; + }); + + it('accepts undefined', () => { + expect(() => validateCacheOptions({ maxResponseSize: undefined })).not.to.throw; + }); + + it('does not accept anything else', () => { + // @ts-ignore + expect(() => validateCacheOptions({ maxResponseSize: 'string' })).to.throw( + 'Property `maxResponseSize` must be a finite `number`', + ); + expect(() => validateCacheOptions({ maxResponseSize: Infinity })).to.throw( + 'Property `maxResponseSize` must be a finite `number`', + ); + }); + }); }); describe('invalidateMatchingCache', () => { diff --git a/packages/ajax/test/interceptors/cacheInterceptors.test.js b/packages/ajax/test/interceptors/cacheInterceptors.test.js index 3b88c4dc3..8d38bab87 100644 --- a/packages/ajax/test/interceptors/cacheInterceptors.test.js +++ b/packages/ajax/test/interceptors/cacheInterceptors.test.js @@ -395,6 +395,40 @@ describe('cache interceptors', () => { // Then expect(fetchStub.callCount).to.equal(1); }); + + it('does save to the cache when `maxResponseSize` is specified and the response size is within the threshold', async () => { + // Given + newCacheId(); + mockResponse.headers.set('content-length', '20000'); + addCacheInterceptors(ajax, { + useCache: true, + maxResponseSize: 50000, + }); + + // When + await ajax.fetch('/test'); + await ajax.fetch('/test'); + + // Then + expect(fetchStub.callCount).to.equal(1); + }); + + it('does save to the cache when `maxResponseSize` is specified and the response size is unknown', async () => { + // Given + newCacheId(); + + addCacheInterceptors(ajax, { + useCache: true, + maxResponseSize: 50000, + }); + + // When + await ajax.fetch('/test'); + await ajax.fetch('/test'); + + // Then + expect(fetchStub.callCount).to.equal(1); + }); }); describe('Bypassing the cache', () => { @@ -481,6 +515,46 @@ describe('cache interceptors', () => { // Then expect(fetchStub.callCount).to.equal(2); }); + + it('does not save to the cache when `maxResponseSize` is specified and a larger content-length is specified in the response', async () => { + // Given + newCacheId(); + mockResponse.headers.set('content-length', '80000'); + addCacheInterceptors(ajax, { + useCache: true, + maxResponseSize: 50000, + }); + + // When + await ajax.fetch('/test'); + await ajax.fetch('/test', { cacheOptions: { maxResponseSize: 100000 } }); + + // Then + expect(fetchStub.callCount).to.equal(2); + }); + + it('does not read from the cache when `maxResponseSize` is specified and a larger content-length is specified in the response', async () => { + // Given + newCacheId(); + mockResponse.headers.set('content-length', '80000'); + addCacheInterceptors(ajax, { + useCache: true, + maxResponseSize: 100000, + }); + + // When + await ajax.fetch('/test'); + await ajax.fetch('/test'); + + // Then + expect(fetchStub.callCount).to.equal(1); + + // When + await ajax.fetch('/test', { cacheOptions: { maxResponseSize: 50000 } }); + + // Then + expect(fetchStub.callCount).to.equal(2); + }); }); describe('Cache invalidation', () => { diff --git a/packages/ajax/types/types.d.ts b/packages/ajax/types/types.d.ts index 3dcac0b6c..ee1125839 100644 --- a/packages/ajax/types/types.d.ts +++ b/packages/ajax/types/types.d.ts @@ -41,6 +41,7 @@ export interface CacheOptions { invalidateUrlsRegex?: RegExp; requestIdFunction?: RequestIdFunction; contentTypes?: string[]; + maxResponseSize?: number; } export interface CacheOptionsWithIdentifier extends CacheOptions {