feat(ajax): add a maxResponseSize cache option to specify a max size for responses to be cached

This commit is contained in:
Martin Pool 2022-05-09 13:10:41 +02:00 committed by Thijs Louisse
parent 2107cfb0c3
commit 558edcb686
7 changed files with 153 additions and 3 deletions

View file

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

View file

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

View file

@ -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`');
}
};
/**

View file

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

View file

@ -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', () => {

View file

@ -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', () => {

View file

@ -41,6 +41,7 @@ export interface CacheOptions {
invalidateUrlsRegex?: RegExp;
requestIdFunction?: RequestIdFunction;
contentTypes?: string[];
maxResponseSize?: number;
}
export interface CacheOptionsWithIdentifier extends CacheOptions {