From f7f25e1d734fa97ab09a365d93189f05dee788ab Mon Sep 17 00:00:00 2001 From: Lex Alexander Date: Sun, 29 Mar 2020 05:48:57 -0700 Subject: [PATCH] feat(caching): add caching for GET, POST, DELETE, and PUT requests --- package.json | 1 + src/__tests__/client.test.ts | 85 +++++++++++++++++++++++++++++++----- src/cache/index.ts | 24 ++++++++++ src/client.ts | 27 ++++++++++-- src/helpers/hashUrl.ts | 16 +++++++ yarn.lock | 48 ++++---------------- 6 files changed, 147 insertions(+), 54 deletions(-) create mode 100644 src/cache/index.ts create mode 100644 src/helpers/hashUrl.ts diff --git a/package.json b/package.json index 922855b4..27fd112b 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "@types/form-data": "^2.2.1", "@types/node-fetch": "^2.3.2", "form-data": "^2.3.3", + "lru-cache": "^5.1.1", "node-fetch": "^2.3.0" }, "husky": { diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index e4887c01..7e0ceea6 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -1,5 +1,6 @@ // We use the real `Response` constructor from the actual node-fetch module const { Response } = require.requireActual('node-fetch'); + // And then mock `fetch` going forward using `Response` with mock data jest.mock('node-fetch', () => jest.fn(() => @@ -14,21 +15,23 @@ const fetch = require.requireMock('node-fetch'); import { Client } from '../client'; import FormData from 'form-data'; import createForm from '../helpers/createForm'; +import * as cache from '../cache'; beforeEach(() => { fetch.mockReset(); + jest.clearAllMocks(); }); test('Client instantiates with access token', () => { - const client = new Client({ access_token: 'abc123' }); - expect(client.access_token).toBe('abc123'); + const client = new Client({ access_token: 'abc1234' }); + expect(client.access_token).toBe('abc1234'); expect(client.client_id).toBeUndefined(); expect(client.anonymous).toBe(false); }); test('Client instantiates with client ID', () => { - const client = new Client({ client_id: 'abc123' }); - expect(client.client_id).toBe('abc123'); + const client = new Client({ client_id: 'abc12345' }); + expect(client.client_id).toBe('abc12345'); expect(client.access_token).toBeUndefined(); expect(client.anonymous).toBe(true); }); @@ -57,11 +60,11 @@ test('requests are decorated with an access token', async () => { test('requests are decorated with a client ID', async () => { fetch.mockReturnValue(Promise.resolve(new Response('{"success": true}'))); const client = new Client({ client_id: 'abc123' }); - await client.request('https://www.blah.com', { + await client.request('https://www.blahs.com', { method: 'GET', }); expect(fetch).toHaveBeenCalledTimes(1); - expect(fetch).toHaveBeenCalledWith('https://www.blah.com', { + expect(fetch).toHaveBeenCalledWith('https://www.blahs.com', { headers: { Authorization: 'Client-ID abc123', }, @@ -119,12 +122,74 @@ test('post() has correct headers and passed in form data reference', async () => }); }); +test('calls cacheGet when calling request', async () => { + fetch.mockReturnValue(Promise.resolve(new Response('{"success": true}'))); + const client = new Client({ access_token: 'abc123' }); + const params = { + image: 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', + type: 'base64', + }; + + const cacheGetSpy = jest.spyOn(cache, 'cacheGet'); + + const form = createForm(params); + + await client.post('https://www.abc.com', form); + expect(cacheGetSpy).toHaveBeenCalledTimes(1); +}); + +test('calls cacheSet when calling request', async () => { + fetch.mockReturnValue(Promise.resolve(new Response('{"success": true}'))); + const client = new Client({ access_token: 'abc123' }); + const params = { + image: 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', + type: 'base64', + }; + + const cacheSetSpy = jest.spyOn(cache, 'cacheSet'); + + const form = createForm(params); + + await client.post('https://www.abc.com', form); + expect(cacheSetSpy).toHaveBeenCalledTimes(1); +}); + +test('fetches from the cache on subsequent post requests', async () => { + fetch.mockReturnValue(Promise.resolve(new Response('{"success": true}'))); + const client = new Client({ access_token: 'abc123' }); + const params = { + image: 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', + type: 'base64', + }; + + const cacheSetSpy = jest.spyOn(cache, 'cacheSet'); + + const form = createForm(params); + + await client.post('https://www.abc.com', form); + await client.post('https://www.abc.com', form); + await client.post('https://www.abc.com', form); + + expect(cacheSetSpy).toHaveBeenCalledTimes(1); +}); + +test('fetches from the cache on subsequent get requests', async () => { + fetch.mockReturnValue(Promise.resolve(new Response('{"success": true}'))); + const client = new Client({ access_token: 'abc123' }); + const cacheSetSpy = jest.spyOn(cache, 'cacheSet'); + await client.get('https://www.txyz.com'); + await client.get('https://www.txyz.com'); + await client.get('https://www.txyz.com'); + + expect(cacheSetSpy).toHaveBeenCalledTimes(1); +}); + test('get() has correct headers', async () => { fetch.mockReturnValue(Promise.resolve(new Response('{"success": true}'))); const client = new Client({ access_token: 'abc123' }); - await client.get('https://www.blah.com'); + await client.get('https://www.xyz.com'); expect(fetch).toHaveBeenCalledTimes(1); - expect(fetch).toHaveBeenCalledWith('https://www.blah.com', { + expect(fetch).toHaveBeenCalledWith('https://www.xyz.com', { headers: { Authorization: 'Bearer abc123', }, @@ -135,9 +200,9 @@ test('get() has correct headers', async () => { test('delete() has correct headers', async () => { fetch.mockReturnValue(Promise.resolve(new Response('{"success": true}'))); const client = new Client({ access_token: 'abc123' }); - await client.delete('https://www.blah.com'); + await client.delete('https://www.mlop.com'); expect(fetch).toHaveBeenCalledTimes(1); - expect(fetch).toHaveBeenCalledWith('https://www.blah.com', { + expect(fetch).toHaveBeenCalledWith('https://www.mlop.com', { headers: { Authorization: 'Bearer abc123', }, diff --git a/src/cache/index.ts b/src/cache/index.ts new file mode 100644 index 00000000..cb8f509d --- /dev/null +++ b/src/cache/index.ts @@ -0,0 +1,24 @@ +import { ImgurApiResponse, AuthenticationRequiredResponse } from '../responses'; + +const LRU = require('lru-cache'); + +const options = { + length: function(n: number, key: any) { + return n * 2 + key.length; + }, + maxAge: 1000 * 30 * 30, +}; + +export const cache = new LRU(options); + +export const cacheGet = (key: string) => { + return cache.get(key) || false; +}; + +export const cacheSet = ( + key: string, + res: Promise, +) => { + cache.set(key, JSON.stringify(res)); + return res; +}; diff --git a/src/client.ts b/src/client.ts index 57f413c0..5eaa1beb 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,7 +1,8 @@ import fetch, { RequestInit } from 'node-fetch'; import FormData from 'form-data'; import createForm from './helpers/createForm'; - +import { hashUrl } from './helpers/hashUrl'; +import { cacheGet, cacheSet } from './cache'; export interface ClientOptions { access_token?: string; client_id?: string; @@ -13,6 +14,8 @@ export type PostOrPutData = } | FormData; +export type FormDataString = string; + export class Client { public readonly access_token: string | undefined; public readonly client_id: string | undefined; @@ -50,8 +53,24 @@ export class Client { }; try { + const params = Object.entries(headers) + .map((key: any, val: any) => { + return `${encodeURIComponent(key)}=${encodeURIComponent(val)}`; + }) + .join('&'); + const hashKey = hashUrl(`${endpoint}${params}`); + const cacheData = cacheGet(hashKey); + + if (cacheData) { + return cacheData; + } + const response = await fetch(endpoint, { ...options, headers }); - return response.json(); + const json = await response.json(); + + cacheSet(hashKey, json); + + return json; } catch (err) { return Promise.reject(new Error(err.message)); } @@ -93,7 +112,6 @@ export class Client { } else { form = createForm(params); } - return this.request(endpoint, { method: 'POST', headers: form.getHeaders(), @@ -108,9 +126,10 @@ export class Client { * @returns A JSON response object */ get(endpoint: string): Promise { - return this.request(endpoint, { + const res = this.request(endpoint, { method: 'GET', }); + return res; } /** diff --git a/src/helpers/hashUrl.ts b/src/helpers/hashUrl.ts new file mode 100644 index 00000000..d65a8e8a --- /dev/null +++ b/src/helpers/hashUrl.ts @@ -0,0 +1,16 @@ +export const hashUrl = function(str: string, seed: number = 0) { + let h1 = 0xdeadbeef ^ seed; + let h2 = 0x41c6ce57 ^ seed; + for (let i = 0, ch; i < str.length; i++) { + ch = str.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = + Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ + Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = + Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ + Math.imul(h1 ^ (h1 >>> 13), 3266489909); + return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(); +}; diff --git a/yarn.lock b/yarn.lock index a8c7249c..52ab7082 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2025,7 +2025,7 @@ debug@^4.0.0, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1: dependencies: ms "^2.1.1" -debuglog@*, debuglog@^1.0.1: +debuglog@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492" integrity sha1-qiT/uaw9+aI1GDfPstJ5NgzXhJI= @@ -3369,7 +3369,7 @@ import-local@^2.0.0: pkg-dir "^3.0.0" resolve-cwd "^2.0.0" -imurmurhash@*, imurmurhash@^0.1.4: +imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= @@ -4466,7 +4466,7 @@ libnpm@^2.0.1: read-package-json "^2.0.13" stringify-package "^1.0.0" -libnpmaccess@*, libnpmaccess@^3.0.1: +libnpmaccess@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/libnpmaccess/-/libnpmaccess-3.0.1.tgz#5b3a9de621f293d425191aa2e779102f84167fa8" integrity sha512-RlZ7PNarCBt+XbnP7R6PoVgOq9t+kou5rvhaInoNibhPO7eMlRfS0B8yjatgn2yaHIwWNyoJDolC/6Lc5L/IQA== @@ -4495,7 +4495,7 @@ libnpmhook@^5.0.2: get-stream "^4.0.0" npm-registry-fetch "^3.8.0" -libnpmorg@*, libnpmorg@^1.0.0: +libnpmorg@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/libnpmorg/-/libnpmorg-1.0.0.tgz#979b868c48ba28c5820e3bb9d9e73c883c16a232" integrity sha512-o+4eVJBoDGMgRwh2lJY0a8pRV2c/tQM/SxlqXezjcAg26Qe9jigYVs+Xk0vvlYDWCDhP0g74J8UwWeAgsB7gGw== @@ -4520,7 +4520,7 @@ libnpmpublish@^1.1.0: semver "^5.5.1" ssri "^6.0.1" -libnpmsearch@*, libnpmsearch@^2.0.0: +libnpmsearch@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/libnpmsearch/-/libnpmsearch-2.0.1.tgz#eccc73a8fbf267d765d18082b85daa2512501f96" integrity sha512-K0yXyut9MHHCAH+DOiglQCpmBKPZXSUu76+BE2maSEfQN15OwNaA/Aiioe9lRFlVFOr7WcuJCY+VSl+gLi9NTA== @@ -4529,7 +4529,7 @@ libnpmsearch@*, libnpmsearch@^2.0.0: get-stream "^4.0.0" npm-registry-fetch "^3.8.0" -libnpmteam@*, libnpmteam@^1.0.1: +libnpmteam@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/libnpmteam/-/libnpmteam-1.0.1.tgz#ff704b1b6c06ea674b3b1101ac3e305f5114f213" integrity sha512-gDdrflKFCX7TNwOMX1snWojCoDE5LoRWcfOC0C/fqF7mBq8Uz9zWAX4B2RllYETNO7pBupBaSyBDkTAC15cAMg== @@ -4675,11 +4675,6 @@ lockfile@^1.0.4: dependencies: signal-exit "^3.0.2" -lodash._baseindexof@*: - version "3.1.0" - resolved "https://registry.yarnpkg.com/lodash._baseindexof/-/lodash._baseindexof-3.1.0.tgz#fe52b53a1c6761e42618d654e4a25789ed61822c" - integrity sha1-/lK1OhxnYeQmGNZU5KJXie1hgiw= - lodash._baseuniq@~4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/lodash._baseuniq/-/lodash._baseuniq-4.6.0.tgz#0ebb44e456814af7905c6212fa2c9b2d51b841e8" @@ -4688,33 +4683,11 @@ lodash._baseuniq@~4.6.0: lodash._createset "~4.0.0" lodash._root "~3.0.0" -lodash._bindcallback@*: - version "3.0.1" - resolved "https://registry.yarnpkg.com/lodash._bindcallback/-/lodash._bindcallback-3.0.1.tgz#e531c27644cf8b57a99e17ed95b35c748789392e" - integrity sha1-5THCdkTPi1epnhftlbNcdIeJOS4= - -lodash._cacheindexof@*: - version "3.0.2" - resolved "https://registry.yarnpkg.com/lodash._cacheindexof/-/lodash._cacheindexof-3.0.2.tgz#3dc69ac82498d2ee5e3ce56091bafd2adc7bde92" - integrity sha1-PcaayCSY0u5ePOVgkbr9Ktx73pI= - -lodash._createcache@*: - version "3.1.2" - resolved "https://registry.yarnpkg.com/lodash._createcache/-/lodash._createcache-3.1.2.tgz#56d6a064017625e79ebca6b8018e17440bdcf093" - integrity sha1-VtagZAF2JeeevKa4AY4XRAvc8JM= - dependencies: - lodash._getnative "^3.0.0" - lodash._createset@~4.0.0: version "4.0.3" resolved "https://registry.yarnpkg.com/lodash._createset/-/lodash._createset-4.0.3.tgz#0f4659fbb09d75194fa9e2b88a6644d363c9fe26" integrity sha1-D0ZZ+7CddRlPqeK4imZE02PJ/iY= -lodash._getnative@*, lodash._getnative@^3.0.0: - version "3.9.1" - resolved "https://registry.yarnpkg.com/lodash._getnative/-/lodash._getnative-3.9.1.tgz#570bc7dede46d61cdcde687d65d3eecbaa3aaff5" - integrity sha1-VwvH3t5G1hzc3mh9ZdPuy6o6r/U= - lodash._reinterpolate@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d" @@ -4760,11 +4733,6 @@ lodash.map@^4.5.1: resolved "https://registry.yarnpkg.com/lodash.map/-/lodash.map-4.6.0.tgz#771ec7839e3473d9c4cde28b19394c3562f4f6d3" integrity sha1-dx7Hg540c9nEzeKLGTlMNWL09tM= -lodash.restparam@*: - version "3.6.1" - resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805" - integrity sha1-k2pOMJ7zMKdkXtQUWYbIWuWyCAU= - lodash.set@^4.3.2: version "4.3.2" resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23" @@ -5476,7 +5444,7 @@ npm-pick-manifest@^2.2.3: npm-package-arg "^6.0.0" semver "^5.4.1" -npm-profile@*, npm-profile@^4.0.1: +npm-profile@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/npm-profile/-/npm-profile-4.0.1.tgz#d350f7a5e6b60691c7168fbb8392c3603583f5aa" integrity sha512-NQ1I/1Q7YRtHZXkcuU1/IyHeLy6pd+ScKg4+DQHdfsm769TGq6HPrkbuNJVJS4zwE+0mvvmeULzQdWn2L2EsVA== @@ -6420,7 +6388,7 @@ readable-stream@~1.1.10: isarray "0.0.1" string_decoder "~0.10.x" -readdir-scoped-modules@*, readdir-scoped-modules@^1.0.0: +readdir-scoped-modules@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/readdir-scoped-modules/-/readdir-scoped-modules-1.0.2.tgz#9fafa37d286be5d92cbaebdee030dc9b5f406747" integrity sha1-n6+jfShr5dksuuve4DDcm19AZ0c=