diff --git a/src/cache.spec.ts b/src/cache.spec.ts new file mode 100644 index 0000000..50efbd5 --- /dev/null +++ b/src/cache.spec.ts @@ -0,0 +1,53 @@ +import { DefaultCache } from './cache'; +import { getResponse } from './responses.mock'; + +describe('DefaultCache', () => { + let cache: DefaultCache; + + beforeEach(() => { + cache = new DefaultCache(); + }); + + it('returns null when a cache key does not exist', () => { + expect(cache.read('https://example.com', {})).toBe(null); + }); + + it('returns a stored response', () => { + const response = getResponse('Test Body'); + const url = 'https://example.com'; + + cache.write(url, {}, response); + + expect(cache.read(url, {})).toBe(response); + }); + + it('reads a specific stored response', () => { + const response = getResponse('Test Body'); + const url = 'https://example.com'; + + cache.write('/test/1', {}, getResponse('Test Body 1')); + cache.write(url, {}, response); + cache.write('/test/2', {}, getResponse('Test Body 2')); + + expect(cache.read(url, {})).toBe(response); + }); + + it('stores cache in different keys when giving the same url with different params', () => { + const url = 'https://example.com'; + + const firstTestResponse = getResponse('Test Body 1'); + const firstRequestOptions = { params: { testParam: 'first' } }; + + const secondTestResponse = getResponse('Test Body 2'); + const secondRequestOptions = { params: { testParam: 'second' } }; + + cache.write(url, firstRequestOptions, firstTestResponse); + cache.write(url, secondRequestOptions, secondTestResponse); + + const firstCachedResponse = cache.read(url, firstRequestOptions); + const secondCachedResponse = cache.read(url, secondRequestOptions); + + expect(firstCachedResponse).toBe(firstTestResponse); + expect(secondCachedResponse).toBe(secondTestResponse); + }); +}); diff --git a/src/cache.ts b/src/cache.ts new file mode 100644 index 0000000..dcb9752 --- /dev/null +++ b/src/cache.ts @@ -0,0 +1,37 @@ +import * as queryString from 'query-string'; + +import RequestOptions from './request-options'; +import Response from './response'; + +export default interface Cache { + read(url: string, options: RequestOptions): Response | null; + write(url: string, options: RequestOptions, response: Response): void; +} + +interface CacheMap { + [key: string]: Response; +} + +export class DefaultCache implements Cache { + private readonly _cache: CacheMap = {}; + + read(url: string, options: RequestOptions): Response | null { + const cacheKey = this.getKey(url, options.params); + + return this._cache[cacheKey] || null; + } + + write(url: string, options: RequestOptions, response: Response) { + const cacheKey = this.getKey(url, options.params); + + this._cache[cacheKey] = response; + } + + private getKey(url: string, params: object = {}) { + if (Object.keys(params).length === 0) { + return url; + } + + return `${url}?${queryString.stringify(params)}`; + } +} diff --git a/src/index.ts b/src/index.ts index f009eda..d85e123 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +export { default as Cache } from './cache'; export { default as createRequestSender } from './create-request-sender'; export { default as createTimeout } from './create-timeout'; export { default as RequestSender } from './request-sender'; diff --git a/src/request-options.ts b/src/request-options.ts index a934845..398b6bb 100644 --- a/src/request-options.ts +++ b/src/request-options.ts @@ -3,6 +3,7 @@ import Timeout from './timeout'; export default interface RequestOptions { body?: any; + cache?: boolean; credentials?: boolean; headers?: Headers; method?: string; diff --git a/src/request-sender-options.ts b/src/request-sender-options.ts index b070dc8..1fa27ed 100644 --- a/src/request-sender-options.ts +++ b/src/request-sender-options.ts @@ -1,3 +1,6 @@ +import Cache from './cache'; + export default interface RequestSenderOptions { + cache?: Cache; host?: string; } diff --git a/src/request-sender.spec.ts b/src/request-sender.spec.ts index 9bc11ce..8b777af 100644 --- a/src/request-sender.spec.ts +++ b/src/request-sender.spec.ts @@ -241,6 +241,81 @@ describe('RequestSender', () => { method: 'GET', }); }); + + it('caches GET requests when cache option is set', () => { + const response = getResponse({ message: 'foobar' }); + const event = new Event('load'); + + jest.spyOn(payloadTransformer, 'toResponse').mockReturnValue(response); + + requestSender = new RequestSender(requestFactory, payloadTransformer, cookie); + + const firstPromise = requestSender.sendRequest(url, { cache: true }); + + if (request.onload) { + request.onload(event); + } + + const secondPromise = requestSender.sendRequest(url, { cache: true }); + + if (request.onload) { + request.onload(event); + } + + expect(firstPromise).resolves.toEqual(response); + expect(secondPromise).resolves.toEqual(response); + expect(requestFactory.createRequest).toHaveBeenCalledTimes(1); + }); + + it('does not cache requests when method is not GET', () => { + const response = getResponse({ message: 'foobar' }); + const event = new Event('load'); + + jest.spyOn(payloadTransformer, 'toResponse').mockReturnValue(response); + + requestSender = new RequestSender(requestFactory, payloadTransformer, cookie); + + const firstPromise = requestSender.post(url, { cache: true }); + + if (request.onload) { + request.onload(event); + } + + const secondPromise = requestSender.post(url, { cache: true }); + + if (request.onload) { + request.onload(event); + } + + expect(firstPromise).resolves.toEqual(response); + expect(secondPromise).resolves.toEqual(response); + expect(requestFactory.createRequest).toHaveBeenCalledTimes(2); + }); + + it('uses custom Cache instance if provided', () => { + const customCache = { + read: jest.fn(), + write: jest.fn(), + }; + + const options = { cache: customCache }; + const response = getResponse({ message: 'foobar' }); + const event = new Event('load'); + + jest.spyOn(payloadTransformer, 'toResponse').mockReturnValue(response); + + requestSender = new RequestSender(requestFactory, payloadTransformer, cookie, options); + + const promise = requestSender.sendRequest(url, { cache: true }); + + if (request.onload) { + request.onload(event); + } + + expect(promise).resolves.toEqual(response); + expect(customCache.read).toHaveBeenCalled(); + expect(customCache.write).toHaveBeenCalled(); + }); }); describe('#get()', () => { diff --git a/src/request-sender.ts b/src/request-sender.ts index 8f3e729..c37c8d5 100644 --- a/src/request-sender.ts +++ b/src/request-sender.ts @@ -1,6 +1,7 @@ import { CookiesStatic } from 'js-cookie'; import merge from 'lodash/merge'; +import Cache, { DefaultCache } from './cache'; import isPromise from './is-promise'; import PayloadTransformer from './payload-transformer'; import RequestFactory from './request-factory'; @@ -10,15 +11,25 @@ import Response from './response'; import Timeout from './timeout'; export default class RequestSender { + private _cache: Cache; + constructor( private _requestFactory: RequestFactory, private _payloadTransformer: PayloadTransformer, private _cookie: CookiesStatic, - private _options?: RequestSenderOptions - ) {} + private _options: RequestSenderOptions = {} + ) { + this._cache = this._options.cache || new DefaultCache(); + } sendRequest(url: string, options?: RequestOptions): Promise> { const requestOptions = this._mergeDefaultOptions(options); + const cachedRequest = this._getCachedRequest(url, requestOptions); + + if (cachedRequest) { + return Promise.resolve(cachedRequest); + } + const request = this._requestFactory.createRequest(this._prependHost(url), requestOptions); return new Promise((resolve, reject) => { @@ -26,6 +37,7 @@ export default class RequestSender { const response = this._payloadTransformer.toResponse(request); if (response.status >= 200 && response.status < 300) { + this._cacheRequest(url, requestOptions, response); resolve(response); } else { reject(response); @@ -93,10 +105,30 @@ export default class RequestSender { } private _prependHost(url: string): string { - if (!this._options || !this._options.host || /^https?:\/\//.test(url)) { + if (!this._options.host || /^https?:\/\//.test(url)) { return url; } return `${this._options.host.replace(/\/$/, '')}/${url.replace(/^\//, '')}`; } + + private _shouldCacheRequest(options: RequestOptions): boolean { + const method = options.method || 'GET'; + + return method.toUpperCase() === 'GET' && Boolean(options.cache); + } + + private _getCachedRequest(url: string, options: RequestOptions): Response | null { + if (this._shouldCacheRequest(options)) { + return this._cache.read(url, options); + } + + return null; + } + + private _cacheRequest(url: string, options: RequestOptions, response: Response): void { + if (this._shouldCacheRequest(options)) { + this._cache.write(url, options, response); + } + } }