Skip to content

Commit

Permalink
feat: adds ability to cache requests
Browse files Browse the repository at this point in the history
Closes #28
  • Loading branch information
s-r-x authored Apr 7, 2022
1 parent df471b2 commit 09eefd2
Show file tree
Hide file tree
Showing 11 changed files with 291 additions and 4 deletions.
2 changes: 2 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ const [value, setValue] = useState();
| customInput | Нет | Element or string | Кастомный компонент поля ввода, например от Styled Components |
| selectOnBlur | Нет | boolean | Если `true`, то при потере фокуса будет выбрана первая подсказка из списка |
| uid | Нет | string | Уникальный ID который используется внутри компонента для связывания элементов при помощи aria атрибутов |
| httpCache | Нет | boolean | Необходимо ли кешировать HTTP-запросы |
| httpCacheTtl | Нет | boolean | Время жизни кеша HTTP-запросов (в миллисекундах). Значение по умолчанию - 10 минут |

## Методы

Expand Down
17 changes: 17 additions & 0 deletions src/BaseSuggestions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { debounce } from 'debounce';
import { nanoid } from 'nanoid';
import { CommonProps, DaDataSuggestion } from './types';
import { makeRequest } from './request';
import { DefaultHttpCache, HttpCache } from './http-cache';

export type BaseProps<SuggestionType> = CommonProps<SuggestionType>;

Expand Down Expand Up @@ -109,6 +110,21 @@ export abstract class BaseSuggestions<SuggestionType, OwnProps> extends React.Pu
return this._uid!;
}

get httpCache(): HttpCache | null {
const { httpCache: cacheProp, httpCacheTtl: ttl } = this.props;
if (!cacheProp) {
return null;
}
if (cacheProp instanceof HttpCache) {
return cacheProp;
}
const cache = DefaultHttpCache.shared;
if (typeof ttl === 'number') {
cache.ttl = ttl;
}
return cache;
}

protected getSuggestionsUrl = (): string => {
const { url } = this.props;

Expand Down Expand Up @@ -255,6 +271,7 @@ export abstract class BaseSuggestions<SuggestionType, OwnProps> extends React.Pu
},
json: this.getLoadSuggestionsData() || {},
},
this.httpCache,
(suggestions) => {
if (this.didMount) {
this.setState({ suggestions, suggestionIndex: -1 });
Expand Down
82 changes: 82 additions & 0 deletions src/__tests__/default-http-cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { DefaultHttpCache as Cache } from '../http-cache';

describe('DefaultHttpCache', () => {
const createCacheWithInfinityTtl = () => {
const cache = new Cache();
cache.ttl = Infinity;
return cache;
};

it('should return the same singleton instance on every call', () => {
expect(Cache.shared).toBeInstanceOf(Cache);
expect(Cache.shared).toBe(Cache.shared);
expect(new Cache()).not.toBe(Cache.shared);
});
it('should serialize http payload to a string', () => {
const cache = createCacheWithInfinityTtl();
const payload = {
method: 'GET',
headers: { hello: 'world' },
body: { hi: 'there' },
url: 'https://example.com',
};
const key = cache.serializeCacheKey(payload);
expect(typeof key).toBe('string');
expect(cache.serializeCacheKey(payload)).toBe(key);
expect(
cache.serializeCacheKey({
...payload,
url: 'https://example2.com',
}),
).not.toBe(key);
expect(cache.serializeCacheKey({ ...payload })).toBe(key);
});
it('should update ttl only if one is valid', () => {
const cache = new Cache();
cache.ttl = 0;
expect(cache.ttl).toBe(0);
cache.ttl = Infinity;
expect(cache.ttl).toBe(Infinity);
cache.ttl = 10;
expect(cache.ttl).toBe(10);
cache.ttl = -1;
expect(cache.ttl).toBe(10);
cache.ttl = true as any;
expect(cache.ttl).toBe(10);
});
it('should insert new cache entries', () => {
const cache = createCacheWithInfinityTtl();
expect(cache.set('key', 1).get('key')).toBe(1);
expect(cache.set('key2', { hello: 'world' }).get('key2')).toStrictEqual({ hello: 'world' });
});
it('should delete cache entries', () => {
const cache = createCacheWithInfinityTtl();
cache.set('key2', 2);
expect(cache.set('key', 1).delete('key').get('key')).toBeNull();
expect(cache.get('key2')).toBe(2);
});
it('should clear cache', () => {
const cache = createCacheWithInfinityTtl();
cache.set('key', 1).set('key2', 2);
expect(cache.size).toBe(2);
cache.reset();
expect(cache.size).toBe(0);
});
it('should delete cache entries after their expiration', (done) => {
const cache = createCacheWithInfinityTtl();
cache.set('key', 1);
cache.ttl = 0;
cache.set('key2', 2);
cache.ttl = 25;
cache.set('key3', 3);
cache.ttl = 100;
cache.set('key4', 4);
setTimeout(() => {
expect(cache.get('key')).toBe(1);
expect(cache.get('key2')).toBeNull();
expect(cache.get('key3')).toBeNull();
expect(cache.get('key4')).toBe(4);
done();
}, 50);
});
});
2 changes: 1 addition & 1 deletion src/__tests__/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2018,7 +2018,7 @@ export const mockedRequestCalls: any[] = [];

export const createAddressMock =
(wait?: number) =>
(method: string, endpoint: string, data: RequestOptions, onReceiveData: (response: any) => void): void => {
(method: string, endpoint: string, data: RequestOptions, cache: any, onReceiveData: (response: any) => void): void => {
mockedRequestCalls.push({ method, endpoint, data });

if (data.json.query) {
Expand Down
59 changes: 59 additions & 0 deletions src/http-cache/abstract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { SerializeCacheKeyPayload } from './types';

export abstract class HttpCache {
/**
* Получить данные из кеша
* @param key - Уникальный ключ кеша
* @example
* ```ts
* cache.get('key');
* ```
*/
public abstract get<T = any>(key: string): T | null;

/**
* Добавить данные в кеш
* @param key - Уникальный ключ кеша
* @param data - Данные для добавления
* @example
* ```ts
* cache.set('key', { ok: true });
* ```
*/
public abstract set(key: string, data: any, ...rest: any): any;

/**
* Удалить закешированные данные по ключу
* @param key - Уникальный ключ кеша
* @xample
* ```ts
* cache.delete('key');
* ```
*/
public abstract delete(key: string): any;

/**
* Полностью очистить кеш
*/
public abstract reset(): any;

/**
* Сгенерировать уникальный ключ кеша из параметров http-запроса
* @example
* ```ts
* cache.serializeCacheKey({
* url: 'https://example.com',
* body: { key: "value" },
* method: "POST"
* })
* ```
*/
public serializeCacheKey(payload: SerializeCacheKeyPayload): string {
try {
return JSON.stringify(payload);
} catch (_e) {
// на случай попытки сериализации объекта с циклическими зависимостями внутри
return payload.url + String(Math.random());
}
}
}
84 changes: 84 additions & 0 deletions src/http-cache/default-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { HttpCache } from './abstract';
import type { HttpCacheEntry } from './types';

const minute = 60000;

export class DefaultHttpCache extends HttpCache {
private static sharedInstance: DefaultHttpCache;

private _map = new Map<string, HttpCacheEntry>();

private _ttl = 10 * minute;

/**
* Синглтон
* @example
* ```ts
* cache.shared.get('key');
* ```
*/
public static get shared(): DefaultHttpCache {
if (!DefaultHttpCache.sharedInstance) {
DefaultHttpCache.sharedInstance = new DefaultHttpCache();
}
return DefaultHttpCache.sharedInstance;
}

/**
* Время жизни кеша в миллисекундах
* @example
* ```ts
* cache.ttl = 60000;
* cache.ttl = Infinity;
* cache.tll = 0;
*
* // негативные значения игнорируются
* cache.ttl = -1;
* cache.ttl = Number.NEGATIVE_INFINITY;
* ```
*/
public get ttl(): number {
return this._ttl;
}

public set ttl(ttl: number) {
if (typeof ttl === 'number' && ttl >= 0) {
this._ttl = ttl;
}
}

/**
* Количество элементов в кеше
*/
public get size(): number {
return this._map.size;
}

public get<T = any>(key: string) {
const data = this._map.get(key);
if (!data) return null;
if (data.expires <= Date.now()) {
this.delete(key);
return null;
}
return data.data as T;
}

public set(key: string, data: any): this {
this._map.set(key, {
data,
expires: Date.now() + this.ttl,
});
return this;
}

public delete(key: string): this {
this._map.delete(key);
return this;
}

public reset(): this {
this._map.clear();
return this;
}
}
2 changes: 2 additions & 0 deletions src/http-cache/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { HttpCache } from './abstract';
export { DefaultHttpCache } from './default-cache';
11 changes: 11 additions & 0 deletions src/http-cache/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export interface HttpCacheEntry {
data: any;
expires: number;
}

export interface SerializeCacheKeyPayload {
headers?: Record<string, string>;
method?: string;
url: string;
body?: Record<string, any>;
}
1 change: 1 addition & 0 deletions src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,4 @@ export {
DaDataFioSuggestion,
DaDataGender,
};
export { HttpCache } from './http-cache';
24 changes: 21 additions & 3 deletions src/request.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { HttpCache } from './http-cache';

export interface RequestOptions {
headers: { [header: string]: string };
json: any;
Expand All @@ -9,12 +11,27 @@ export const makeRequest = (
method: string,
endpoint: string,
data: RequestOptions,
cache: HttpCache | null,
onReceiveData: (response: any) => void,
): void => {
if (xhr) {
xhr.abort();
}

let cacheKey: string;
if (cache) {
cacheKey = cache.serializeCacheKey({
headers: data.headers,
body: data.json,
url: endpoint,
method,
});
const cachedData = cache.get(cacheKey);
if (cachedData) {
onReceiveData(cachedData);
return;
}
}
xhr = new XMLHttpRequest();
xhr.open(method, endpoint);
if (data.headers) {
Expand All @@ -30,9 +47,10 @@ export const makeRequest = (
}

if (xhr.status === 200) {
const responseJson = JSON.parse(xhr.response);
if (responseJson && responseJson.suggestions) {
onReceiveData(responseJson.suggestions);
const payload = JSON.parse(xhr.response)?.suggestions;
if (payload) {
cache?.set(cacheKey, payload);
onReceiveData(payload);
}
}
};
Expand Down
11 changes: 11 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable camelcase */
import { ElementType, HTMLProps, ReactNode } from 'react';
import type { HttpCache } from './http-cache';

type Nullable<T> = T | null;

Expand Down Expand Up @@ -34,6 +35,16 @@ export interface CommonProps<SuggestionType> {
customInput?: ElementType;
selectOnBlur?: boolean;
uid?: string;
/**
* Необходимо ли кешировать HTTP-запросы?
* Возможно передать собственный кеш наследующий {@link HttpCache}.
*/
httpCache?: boolean | HttpCache;
/**
* Время жизни кеша в миллисекундах.
* Игнорируется если был передан собственный {@link HttpCache}.
*/
httpCacheTtl?: number;
}

export interface DaDataAddressMetro {
Expand Down

0 comments on commit 09eefd2

Please sign in to comment.