feat: content-type filter for ajax cache

This commit is contained in:
Martin Pool 2022-04-25 17:58:45 +02:00 committed by Thijs Louisse
parent 56af96f1da
commit efcdf653a4
6 changed files with 235 additions and 125 deletions

View file

@ -0,0 +1,5 @@
---
'@lion/ajax': minor
---
Add cache filter for content types

View file

@ -82,6 +82,7 @@ export const extendCacheOptions = ({
requestIdFunction = DEFAULT_GET_REQUEST_ID, requestIdFunction = DEFAULT_GET_REQUEST_ID,
invalidateUrls, invalidateUrls,
invalidateUrlsRegex, invalidateUrlsRegex,
contentTypes,
}) => ({ }) => ({
useCache, useCache,
methods, methods,
@ -89,6 +90,7 @@ export const extendCacheOptions = ({
requestIdFunction, requestIdFunction,
invalidateUrls, invalidateUrls,
invalidateUrlsRegex, invalidateUrlsRegex,
contentTypes,
}); });
/** /**
@ -101,6 +103,7 @@ export const validateCacheOptions = ({
requestIdFunction, requestIdFunction,
invalidateUrls, invalidateUrls,
invalidateUrlsRegex, invalidateUrlsRegex,
contentTypes,
} = {}) => { } = {}) => {
if (useCache !== undefined && typeof useCache !== 'boolean') { if (useCache !== undefined && typeof useCache !== 'boolean') {
throw new Error('Property `useCache` must be a `boolean`'); throw new Error('Property `useCache` must be a `boolean`');
@ -112,14 +115,17 @@ export const validateCacheOptions = ({
throw new Error('Property `maxAge` must be a finite `number`'); throw new Error('Property `maxAge` must be a finite `number`');
} }
if (invalidateUrls !== undefined && !Array.isArray(invalidateUrls)) { if (invalidateUrls !== undefined && !Array.isArray(invalidateUrls)) {
throw new Error('Property `invalidateUrls` must be an `Array` or `falsy`'); throw new Error('Property `invalidateUrls` must be an `Array` or `undefined`');
} }
if (invalidateUrlsRegex !== undefined && !(invalidateUrlsRegex instanceof RegExp)) { if (invalidateUrlsRegex !== undefined && !(invalidateUrlsRegex instanceof RegExp)) {
throw new Error('Property `invalidateUrlsRegex` must be a `RegExp` or `falsy`'); throw new Error('Property `invalidateUrlsRegex` must be a `RegExp` or `undefined`');
} }
if (requestIdFunction !== undefined && typeof requestIdFunction !== 'function') { if (requestIdFunction !== undefined && typeof requestIdFunction !== 'function') {
throw new Error('Property `requestIdFunction` must be a `function`'); throw new Error('Property `requestIdFunction` must be a `function`');
} }
if (contentTypes !== undefined && !Array.isArray(contentTypes)) {
throw new Error('Property `contentTypes` must be an `Array` or `undefined`');
}
}; };
/** /**

View file

@ -19,6 +19,18 @@ import {
const isMethodSupported = (cacheOptions, method) => const isMethodSupported = (cacheOptions, method) =>
cacheOptions.methods.includes(method.toLowerCase()); cacheOptions.methods.includes(method.toLowerCase());
/**
* Tests whether the response content type is supported by the `contentTypes` whitelist
* @param {Response} response
* @param {CacheOptions} cacheOptions
* @returns {boolean} `true` if the contentTypes property is not an array, or if the value of the Content-Type header is in the array
*/
const isResponseContentTypeSupported = (response, { contentTypes } = {}) => {
if (!Array.isArray(contentTypes)) return true;
return contentTypes.includes(String(response.headers.get('Content-Type')));
};
/** /**
* Request interceptor to return relevant cached requests * Request interceptor to return relevant cached requests
* @param {function(): string} getCacheId used to invalidate cache if identifier is changed * @param {function(): string} getCacheId used to invalidate cache if identifier is changed
@ -58,7 +70,7 @@ const createCacheRequestInterceptor =
} }
const cachedResponse = ajaxCache.get(requestId, cacheOptions.maxAge); const cachedResponse = ajaxCache.get(requestId, cacheOptions.maxAge);
if (cachedResponse) { if (cachedResponse && isResponseContentTypeSupported(cachedResponse, cacheOptions)) {
// Return the response from cache // Return the response from cache
request.cacheOptions = request.cacheOptions ?? { useCache: false }; request.cacheOptions = request.cacheOptions ?? { useCache: false };
/** @type {CacheResponse} */ /** @type {CacheResponse} */
@ -92,7 +104,10 @@ const createCacheResponseInterceptor =
if (!response.fromCache && isMethodSupported(cacheOptions, response.request.method)) { if (!response.fromCache && isMethodSupported(cacheOptions, response.request.method)) {
const requestId = cacheOptions.requestIdFunction(response.request); const requestId = cacheOptions.requestIdFunction(response.request);
if (isCurrentSessionId(response.request.cacheSessionId)) { if (
isCurrentSessionId(response.request.cacheSessionId) &&
isResponseContentTypeSupported(response, cacheOptions)
) {
// Cache the response // Cache the response
ajaxCache.set(requestId, response.clone()); ajaxCache.set(requestId, response.clone());
} }

View file

@ -74,6 +74,7 @@ describe('cacheManager', () => {
requestIdFunction, requestIdFunction,
invalidateUrls: invalidateUrlsResult, invalidateUrls: invalidateUrlsResult,
invalidateUrlsRegex: invalidateUrlsRegexResult, invalidateUrlsRegex: invalidateUrlsRegexResult,
contentTypes,
} = extendCacheOptions({ invalidateUrls, invalidateUrlsRegex }); } = extendCacheOptions({ invalidateUrls, invalidateUrlsRegex });
// Assert // Assert
expect(useCache).to.be.false; expect(useCache).to.be.false;
@ -82,6 +83,7 @@ describe('cacheManager', () => {
expect(typeof requestIdFunction).to.eql('function'); expect(typeof requestIdFunction).to.eql('function');
expect(invalidateUrlsResult).to.equal(invalidateUrls); expect(invalidateUrlsResult).to.equal(invalidateUrls);
expect(invalidateUrlsRegexResult).to.equal(invalidateUrlsRegex); expect(invalidateUrlsRegexResult).to.equal(invalidateUrlsRegex);
expect(contentTypes).to.be.undefined;
}); });
it('the DEFAULT_GET_REQUEST_ID function throws when called with no arguments', () => { it('the DEFAULT_GET_REQUEST_ID function throws when called with no arguments', () => {
@ -197,7 +199,7 @@ describe('cacheManager', () => {
it('does not accept anything else', () => { it('does not accept anything else', () => {
// @ts-ignore // @ts-ignore
expect(() => validateCacheOptions({ invalidateUrls: 'not-an-array' })).to.throw( expect(() => validateCacheOptions({ invalidateUrls: 'not-an-array' })).to.throw(
'Property `invalidateUrls` must be an `Array` or `falsy`', 'Property `invalidateUrls` must be an `Array` or `undefined`',
); );
}); });
}); });
@ -213,7 +215,7 @@ describe('cacheManager', () => {
// @ts-ignore // @ts-ignore
expect(() => expect(() =>
validateCacheOptions({ invalidateUrlsRegex: 'a string is not a regex' }), validateCacheOptions({ invalidateUrlsRegex: 'a string is not a regex' }),
).to.throw('Property `invalidateUrlsRegex` must be a `RegExp` or `falsy`'); ).to.throw('Property `invalidateUrlsRegex` must be a `RegExp` or `undefined`');
}); });
}); });
describe('the requestIdFunction property', () => { describe('the requestIdFunction property', () => {
@ -233,6 +235,22 @@ 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(
'Property `contentTypes` must be an `Array` or `undefined`',
);
});
});
}); });
describe('invalidateMatchingCache', () => { describe('invalidateMatchingCache', () => {

View file

@ -22,6 +22,8 @@ describe('cache interceptors', () => {
let cacheId; let cacheId;
/** @type {sinon.SinonStub} */ /** @type {sinon.SinonStub} */
let fetchStub; let fetchStub;
/** @type {Response} */
let mockResponse;
const getCacheIdentifier = () => String(cacheId); const getCacheIdentifier = () => String(cacheId);
/** @type {sinon.SinonSpy} */ /** @type {sinon.SinonSpy} */
let ajaxRequestSpy; let ajaxRequestSpy;
@ -51,8 +53,9 @@ describe('cache interceptors', () => {
beforeEach(() => { beforeEach(() => {
ajax = new Ajax(); ajax = new Ajax();
mockResponse = new Response('mock response');
fetchStub = sinon.stub(window, 'fetch'); fetchStub = sinon.stub(window, 'fetch');
fetchStub.returns(Promise.resolve(new Response('mock response'))); fetchStub.resolves(mockResponse);
ajaxRequestSpy = sinon.spy(ajax, 'fetch'); ajaxRequestSpy = sinon.spy(ajax, 'fetch');
}); });
@ -291,7 +294,7 @@ describe('cache interceptors', () => {
); );
// @ts-ignore not an actual valid CacheResponse object // @ts-ignore not an actual valid CacheResponse object
await cacheResponseInterceptor({ request: { method: 'get' } }) await cacheResponseInterceptor({ request: { method: 'get' }, headers: new Headers() })
.then(() => expect('everything').to.be.ok) .then(() => expect('everything').to.be.ok)
.catch(err => .catch(err =>
expect.fail( expect.fail(
@ -299,6 +302,185 @@ describe('cache interceptors', () => {
), ),
); );
}); });
it('caches concurrent requests', async () => {
newCacheId();
const clock = sinon.useFakeTimers();
fetchStub.onFirstCall().returns(returnResponseOnTick(900, 1));
fetchStub.onSecondCall().returns(returnResponseOnTick(1900, 2));
addCacheInterceptors(ajax, {
useCache: true,
maxAge: 750,
});
const firstRequest = ajax.fetch('/test').then(r => r.text());
const concurrentFirstRequest1 = ajax.fetch('/test').then(r => r.text());
const concurrentFirstRequest2 = ajax.fetch('/test').then(r => r.text());
clock.tick(1000);
// firstRequest is cached at tick 1000 in the next line!
const firstResponses = await Promise.all([
firstRequest,
concurrentFirstRequest1,
concurrentFirstRequest2,
]);
expect(fetchStub.callCount).to.equal(1);
const cachedFirstRequest = ajax.fetch('/test').then(r => r.text());
clock.tick(500);
const cachedFirstResponse = await cachedFirstRequest;
expect(fetchStub.callCount).to.equal(1);
const secondRequest = ajax.fetch('/test').then(r => r.text());
const secondConcurrentRequest = ajax.fetch('/test').then(r => r.text());
clock.tick(1000);
const secondResponses = await Promise.all([secondRequest, secondConcurrentRequest]);
expect(fetchStub.callCount).to.equal(2);
expect(firstResponses).to.eql(['mock response 1', 'mock response 1', 'mock response 1']);
expect(cachedFirstResponse).to.equal('mock response 1');
expect(secondResponses).to.eql(['mock response 2', 'mock response 2']);
clock.restore();
});
it('preserves status and headers when returning cached response', async () => {
newCacheId();
fetchStub.returns(
Promise.resolve(
new Response('mock response', { status: 206, headers: { 'x-foo': 'x-bar' } }),
),
);
addCacheInterceptors(ajax, {
useCache: true,
maxAge: 100,
});
const response1 = await ajax.fetch('/test');
const response2 = await ajax.fetch('/test');
expect(fetchStub.callCount).to.equal(1);
expect(response1.status).to.equal(206);
expect(response1.headers.get('x-foo')).to.equal('x-bar');
expect(response2.status).to.equal(206);
expect(response2.headers.get('x-foo')).to.equal('x-bar');
});
it('does save to the cache when `contentTypes` is specified and a supported content type is returned', async () => {
// Given
newCacheId();
mockResponse.headers.set('content-type', 'application/xml');
addCacheInterceptors(ajax, {
useCache: true,
contentTypes: ['application/json', 'application/xml'],
});
// When
await ajax.fetch('/test');
await ajax.fetch('/test');
// Then
expect(fetchStub.callCount).to.equal(1);
});
});
describe('Bypassing the cache', () => {
it('caches response but does not return it when expiration time is 0', async () => {
newCacheId();
addCacheInterceptors(ajax, {
useCache: true,
maxAge: 0,
});
const clock = sinon.useFakeTimers();
await ajax.fetch('/test');
expect(ajaxRequestSpy.calledOnce).to.be.true;
expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
clock.tick(1);
await ajax.fetch('/test');
clock.restore();
expect(fetchStub.callCount).to.equal(2);
});
it('does not use cache when cacheOption `useCache: false` is passed to fetch method', async () => {
// Given
newCacheId();
addCacheInterceptors(ajax, { useCache: true });
// When
await ajax.fetch('/test');
await ajax.fetch('/test');
// Then
expect(fetchStub.callCount).to.equal(1);
// When
await ajax.fetch('/test', { cacheOptions: { useCache: false } });
// Then
expect(fetchStub.callCount).to.equal(2);
});
it('does not save to the cache when `contentTypes` is specified and an unsupported content type is returned', async () => {
// Given
newCacheId();
mockResponse.headers.set('content-type', 'text/html');
addCacheInterceptors(ajax, {
useCache: true,
contentTypes: ['application/json', 'application/xml'],
});
// When
await ajax.fetch('/test');
await ajax.fetch('/test', { cacheOptions: { contentTypes: ['text/html'] } });
// Then
expect(fetchStub.callCount).to.equal(2);
});
it('does not read from the cache when `contentTypes` is specified and an unsupported content type is returned', async () => {
// Given
newCacheId();
mockResponse.headers.set('content-type', 'application/json');
addCacheInterceptors(ajax, {
useCache: true,
contentTypes: ['application/json', 'application/xml'],
});
// When
await ajax.fetch('/test');
await ajax.fetch('/test');
// Then
expect(fetchStub.callCount).to.equal(1);
// When
await ajax.fetch('/test', { cacheOptions: { contentTypes: [] } });
// Then
expect(fetchStub.callCount).to.equal(2);
});
}); });
describe('Cache invalidation', () => { describe('Cache invalidation', () => {
@ -443,101 +625,6 @@ describe('cache interceptors', () => {
expect(fetchStub.callCount).to.equal(3); expect(fetchStub.callCount).to.equal(3);
}); });
it('caches response but does not return it when expiration time is 0', async () => {
newCacheId();
addCacheInterceptors(ajax, {
useCache: true,
maxAge: 0,
});
const clock = sinon.useFakeTimers();
await ajax.fetch('/test');
expect(ajaxRequestSpy.calledOnce).to.be.true;
expect(ajaxRequestSpy.calledWith('/test')).to.be.true;
clock.tick(1);
await ajax.fetch('/test');
clock.restore();
expect(fetchStub.callCount).to.equal(2);
});
it('does not use cache when cacheOption `useCache: false` is passed to fetch method', async () => {
// Given
addCacheInterceptors(ajax, { useCache: true });
// When
await ajax.fetch('/test');
await ajax.fetch('/test');
// Then
expect(fetchStub.callCount).to.equal(1);
// When
await ajax.fetch('/test', { cacheOptions: { useCache: false } });
// Then
expect(fetchStub.callCount).to.equal(2);
});
it('caches concurrent requests', async () => {
newCacheId();
const clock = sinon.useFakeTimers();
fetchStub.onFirstCall().returns(returnResponseOnTick(900, 1));
fetchStub.onSecondCall().returns(returnResponseOnTick(1900, 2));
addCacheInterceptors(ajax, {
useCache: true,
maxAge: 750,
});
const firstRequest = ajax.fetch('/test').then(r => r.text());
const concurrentFirstRequest1 = ajax.fetch('/test').then(r => r.text());
const concurrentFirstRequest2 = ajax.fetch('/test').then(r => r.text());
clock.tick(1000);
// firstRequest is cached at tick 1000 in the next line!
const firstResponses = await Promise.all([
firstRequest,
concurrentFirstRequest1,
concurrentFirstRequest2,
]);
expect(fetchStub.callCount).to.equal(1);
const cachedFirstRequest = ajax.fetch('/test').then(r => r.text());
clock.tick(500);
const cachedFirstResponse = await cachedFirstRequest;
expect(fetchStub.callCount).to.equal(1);
const secondRequest = ajax.fetch('/test').then(r => r.text());
const secondConcurrentRequest = ajax.fetch('/test').then(r => r.text());
clock.tick(1000);
const secondResponses = await Promise.all([secondRequest, secondConcurrentRequest]);
expect(fetchStub.callCount).to.equal(2);
expect(firstResponses).to.eql(['mock response 1', 'mock response 1', 'mock response 1']);
expect(cachedFirstResponse).to.equal('mock response 1');
expect(secondResponses).to.eql(['mock response 2', 'mock response 2']);
});
it('discards responses that are requested in a different cache session', async () => { it('discards responses that are requested in a different cache session', async () => {
newCacheId(); newCacheId();
@ -563,27 +650,5 @@ describe('cache interceptors', () => {
expect(ajaxCache._cachedRequests).to.deep.equal({}); expect(ajaxCache._cachedRequests).to.deep.equal({});
expect(fetchStub.callCount).to.equal(1); expect(fetchStub.callCount).to.equal(1);
}); });
it('preserves status and headers when returning cached response', async () => {
newCacheId();
fetchStub.returns(
Promise.resolve(
new Response('mock response', { status: 206, headers: { 'x-foo': 'x-bar' } }),
),
);
addCacheInterceptors(ajax, {
useCache: true,
maxAge: 100,
});
const response1 = await ajax.fetch('/test');
const response2 = await ajax.fetch('/test');
expect(fetchStub.callCount).to.equal(1);
expect(response1.status).to.equal(206);
expect(response1.headers.get('x-foo')).to.equal('x-bar');
expect(response2.status).to.equal(206);
expect(response2.headers.get('x-foo')).to.equal('x-bar');
});
}); });
}); });

View file

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