Skip to content

Commit

Permalink
feat(request): CHP-6060 adds optional cache functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
deini committed Oct 7, 2019
1 parent 10e9357 commit 5f9eb73
Show file tree
Hide file tree
Showing 7 changed files with 205 additions and 3 deletions.
53 changes: 53 additions & 0 deletions src/cache.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
37 changes: 37 additions & 0 deletions src/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import * as queryString from 'query-string';

import RequestOptions from './request-options';
import Response from './response';

export default interface Cache {
read<T>(url: string, options: RequestOptions): Response<T> | null;
write<T>(url: string, options: RequestOptions, response: Response<T>): void;
}

interface CacheMap {
[key: string]: Response<any>;
}

export class DefaultCache implements Cache {
private readonly _cache: CacheMap = {};

read<T>(url: string, options: RequestOptions): Response<T> | null {
const cacheKey = this.getKey(url, options.params);

return this._cache[cacheKey] || null;
}

write<T>(url: string, options: RequestOptions, response: Response<T>) {
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)}`;
}
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
1 change: 1 addition & 0 deletions src/request-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Timeout from './timeout';

export default interface RequestOptions {
body?: any;
cache?: boolean;
credentials?: boolean;
headers?: Headers;
method?: string;
Expand Down
3 changes: 3 additions & 0 deletions src/request-sender-options.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import Cache from './cache';

export default interface RequestSenderOptions {
cache?: Cache;
host?: string;
}
75 changes: 75 additions & 0 deletions src/request-sender.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()', () => {
Expand Down
38 changes: 35 additions & 3 deletions src/request-sender.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -10,22 +11,33 @@ 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<T = any>(url: string, options?: RequestOptions): Promise<Response<T>> {
const requestOptions = this._mergeDefaultOptions(options);
const cachedRequest = this._getCachedRequest<T>(url, requestOptions);

if (cachedRequest) {
return Promise.resolve(cachedRequest);
}

const request = this._requestFactory.createRequest(this._prependHost(url), requestOptions);

return new Promise((resolve, reject) => {
const requestHandler = () => {
const response = this._payloadTransformer.toResponse(request);

if (response.status >= 200 && response.status < 300) {
this._cacheRequest(url, requestOptions, response);
resolve(response);
} else {
reject(response);
Expand Down Expand Up @@ -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<T>(url: string, options: RequestOptions): Response<T> | null {
if (this._shouldCacheRequest(options)) {
return this._cache.read<T>(url, options);
}

return null;
}

private _cacheRequest<T>(url: string, options: RequestOptions, response: Response<T>): void {
if (this._shouldCacheRequest(options)) {
this._cache.write(url, options, response);
}
}
}

0 comments on commit 5f9eb73

Please sign in to comment.