diff --git a/README.md b/README.md index c012d86..32f7945 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ This repository provides an Axios Redis cache adapter. yarn add @tictactrip/axios-redis ``` -## How to use it? +## Example ```ts import * as redis from 'redis'; @@ -47,7 +47,7 @@ await axiosInstance.get('/user?ID=12345'); await axiosInstance.get('/user?ID=12345'); ``` -### Which http methods are cached? +### HTTP methods cached As default, all **GET** and **POST** responses are cached. If you want to customize them, you can also do: @@ -56,9 +56,9 @@ If you want to customize them, you can also do: axiosRedis.methodsToCache = [EHttpMethod.GET]; ``` -### What's the key structure? +### Key structure -As default, keys have bellow pattern: +By default, redis keys follow this pattern ```ts {prefix}___{http_method}___{axios_config_url}___base64{axios_config_params}___base64{axios_config_data} @@ -72,7 +72,29 @@ Example: If you want to customize the keys, you just need to customize your `AxiosRedis` instance. -## Tests +### Disable cache for one request + +```ts +// This request won't be cached +await axiosInstance.get('/user?ID=12345', { + headers: { + 'Axios-Redis-Cache-Duration': null, + }, +}); +``` + +### Customize cache duration for one request + +```ts +// This request will be cached during 90000ms +await axiosInstance.get('/user?ID=12345', { + headers: { + 'Axios-Redis-Cache-Duration': 90000, + }, +}); +``` + +### Tests How can I mock Redis connection with Jest in my unit tests? @@ -81,7 +103,6 @@ import * as redis from 'redis'; import { AxiosRedis } from '@tictactrip/axios-redis'; describe('Example', () => { - it('should send the request without a redis connection', () => { const redisClient = redis.createClient({ retry_strategy: jest.fn() }); @@ -90,6 +111,7 @@ describe('Example', () => { // ... }); +}); ``` ## Scripts diff --git a/__tests__/index.ts b/__tests__/index.ts index dc024e8..023c403 100644 --- a/__tests__/index.ts +++ b/__tests__/index.ts @@ -83,6 +83,55 @@ describe('index.ts', () => { expect(responseFromCache.data).toStrictEqual({ success: true }); }); + it('should cache the response on the first call (with custom cache expiration value)', async () => { + const axiosResponseSetCache = + '[{"status":200,"statusText":null,"headers":"1","config":"2","request":"3","data":"4"},{"content-type":"5"},{"url":"6","method":"7","headers":"8","baseURL":"9","transformRequest":"10","transformResponse":"11","timeout":0,"xsrfCookieName":"12","xsrfHeaderName":"13","maxContentLength":-1,"httpsAgent":"14"},{"_events":"15","_eventsCount":5,"outputData":"16","outputSize":0,"writable":true,"_last":false,"chunkedEncoding":false,"shouldKeepAlive":true,"useChunkedEncodingByDefault":true,"sendDate":false,"_removedConnection":false,"_removedContLen":false,"_removedTE":false,"_contentLength":null,"_hasBody":true,"_trailer":"17","finished":false,"_headerSent":false,"socket":"18","connection":"18","_header":null,"path":"6","method":"19","req":"3","options":"20","interceptors":"21","response":"22","playbackStarted":false,"requestBodyBuffers":"23","_redirectable":"24","headers":"25"},{"success":true},"application/json","/example2?param1=true¶m2=123","get",{"Accept":"26","User-Agent":"27","Api-Key":"28"},"https://api.example.com",[null],[null],"XSRF-TOKEN","X-XSRF-TOKEN",{"_events":"29","_eventsCount":1,"defaultPort":443,"protocol":"30","options":"31","requests":"32","sockets":"33","freeSockets":"34","keepAliveMsecs":1000,"keepAlive":false,"maxSockets":null,"maxFreeSockets":256,"maxCachedSessions":100,"_sessionCache":"35"},{},[],"",{"_events":"36","_eventsCount":2,"authorized":true,"bufferSize":0,"writable":true,"readable":true,"pending":false,"destroyed":false,"connecting":false,"totalDelayMs":0,"timeoutMs":null,"remoteFamily":"37","remoteAddress":"38","localAddress":"38","remotePort":443,"localPort":443},"GET",{"protocol":"30","maxRedirects":21,"maxBodyLength":10485760,"path":"6","method":"19","headers":"39","agent":"14","agents":"40","hostname":"41","port":443,"nativeProtocols":"42","pathname":"43","search":"44","proto":"45","host":"46"},[],{"_readableState":"47","readable":false,"_events":"48","_eventsCount":3,"socket":"18","connection":"18","httpVersionMajor":null,"httpVersionMinor":null,"httpVersion":null,"complete":true,"headers":"1","rawHeaders":"49","trailers":"50","rawTrailers":"51","aborted":false,"upgrade":null,"url":"17","method":null,"statusCode":200,"statusMessage":null,"client":"18","_consuming":true,"_dumped":false,"req":"3","responseUrl":"52","redirects":"53"},[],{"_writableState":"54","writable":true,"_events":"55","_eventsCount":2,"_options":"56","_redirectCount":0,"_redirects":"53","_requestBodyLength":0,"_requestBodyBuffers":"57","_currentRequest":"3","_currentUrl":"52"},{"accept":"26","user-agent":"27","api-key":"28","host":"41"},"application/json, text/plain, */*","@scope/package","3b48b9fd18ecca20ed5b0accbfeb6b70",{},"https:",{"rejectUnauthorized":false,"path":null},{},{},{},{"map":"58","list":"59"},{},"IPv4","127.0.0.1",{"accept":"26","user-agent":"27","api-key":"28"},{"https":"14"},"api.example.com",{"http:":"60","https:":"61"},"/example2","?param1=true¶m2=123","https","api.example.com:443",{"objectMode":false,"highWaterMark":16384,"buffer":"62","length":0,"pipes":null,"pipesCount":0,"flowing":true,"ended":true,"endEmitted":true,"reading":false,"sync":false,"needReadable":false,"emittedReadable":false,"readableListening":false,"resumeScheduled":false,"paused":false,"emitClose":true,"autoDestroy":false,"destroyed":false,"defaultEncoding":"63","awaitDrain":0,"readingMore":false,"decoder":null,"encoding":null},{},["64","5"],{},[],"https://api.example.com/example2?param1=true¶m2=123",[],{"objectMode":false,"highWaterMark":16384,"finalCalled":false,"needDrain":false,"ending":false,"ended":false,"finished":false,"destroyed":false,"decodeStrings":true,"defaultEncoding":"63","length":0,"writing":false,"corked":0,"sync":true,"bufferProcessing":false,"writecb":null,"writelen":0,"afterWriteTickInfo":null,"bufferedRequest":null,"lastBufferedRequest":null,"pendingcb":0,"prefinished":false,"errorEmitted":false,"emitClose":true,"autoDestroy":false,"bufferedRequestCount":0,"corkedRequestsFree":"65"},{},{"protocol":"30","maxRedirects":21,"maxBodyLength":10485760,"path":"6","method":"19","headers":"8","agent":"14","agents":"40","hostname":"41","port":null,"nativeProtocols":"42","pathname":"43","search":"44"},[],{},[],{"METHODS":"66","STATUS_CODES":"67","maxHeaderSize":8192,"globalAgent":"68"},{"globalAgent":"69"},{"head":null,"tail":null,"length":0},"utf8","Content-Type",{"next":null,"entry":null},["70","71","72","73","74","75","19","76","77","78","79","80","81","82","83","84","85","86","87","88","89","90","91","92","93","94","95","96","97","98","99","100","101","102"],{"100":"103","101":"104","102":"105","103":"106","200":"107","201":"108","202":"109","203":"110","204":"111","205":"112","206":"113","207":"114","208":"115","226":"116","300":"117","301":"118","302":"119","303":"120","304":"121","305":"122","307":"123","308":"124","400":"125","401":"126","402":"127","403":"128","404":"129","405":"130","406":"131","407":"132","408":"133","409":"134","410":"135","411":"136","412":"137","413":"138","414":"139","415":"140","416":"141","417":"142","418":"143","421":"144","422":"145","423":"146","424":"147","425":"148","426":"149","428":"150","429":"151","431":"152","451":"153","500":"154","501":"155","502":"156","503":"157","504":"158","505":"159","506":"160","507":"161","508":"162","509":"163","510":"164","511":"165"},{"_events":"166","_eventsCount":1,"defaultPort":80,"protocol":"167","options":"168","requests":"169","sockets":"170","freeSockets":"171","keepAliveMsecs":1000,"keepAlive":false,"maxSockets":null,"maxFreeSockets":256},{"_events":"172","_eventsCount":1,"defaultPort":443,"protocol":"30","options":"173","requests":"174","sockets":"175","freeSockets":"176","keepAliveMsecs":1000,"keepAlive":false,"maxSockets":null,"maxFreeSockets":256,"maxCachedSessions":100,"_sessionCache":"177"},"ACL","BIND","CHECKOUT","CONNECT","COPY","DELETE","HEAD","LINK","LOCK","M-SEARCH","MERGE","MKACTIVITY","MKCALENDAR","MKCOL","MOVE","NOTIFY","OPTIONS","PATCH","POST","PROPFIND","PROPPATCH","PURGE","PUT","REBIND","REPORT","SEARCH","SOURCE","SUBSCRIBE","TRACE","UNBIND","UNLINK","UNLOCK","UNSUBSCRIBE","Continue","Switching Protocols","Processing","Early Hints","OK","Created","Accepted","Non-Authoritative Information","No Content","Reset Content","Partial Content","Multi-Status","Already Reported","IM Used","Multiple Choices","Moved Permanently","Found","See Other","Not Modified","Use Proxy","Temporary Redirect","Permanent Redirect","Bad Request","Unauthorized","Payment Required","Forbidden","Not Found","Method Not Allowed","Not Acceptable","Proxy Authentication Required","Request Timeout","Conflict","Gone","Length Required","Precondition Failed","Payload Too Large","URI Too Long","Unsupported Media Type","Range Not Satisfiable","Expectation Failed","I\'m a Teapot","Misdirected Request","Unprocessable Entity","Locked","Failed Dependency","Unordered Collection","Upgrade Required","Precondition Required","Too Many Requests","Request Header Fields Too Large","Unavailable For Legal Reasons","Internal Server Error","Not Implemented","Bad Gateway","Service Unavailable","Gateway Timeout","HTTP Version Not Supported","Variant Also Negotiates","Insufficient Storage","Loop Detected","Bandwidth Limit Exceeded","Not Extended","Network Authentication Required",{},"http:",{"path":null},{},{},{},{},{"path":null},{},{},{},{"map":"178","list":"179"},{},[]]'; + + // tslint:disable-next-line:no-backbone-get-set-outside-model + const apiNock = nock('https://api.example.com') + .get('/example2') + .query({ param1: true, param2: 123 }) + .matchHeader('User-Agent', '@scope/package') + .matchHeader('Api-Key', '3b48b9fd18ecca20ed5b0accbfeb6b70') + .reply(200, { + success: true, + }); + + const redisSetAsyncSpy = jest.spyOn(axiosRedis, 'redisSetAsync'); + const redisGetAsyncSpy = jest.spyOn(axiosRedis, 'redisGetAsync'); + + // tslint:disable-next-line:no-backbone-get-set-outside-model + const response = await axiosInstance.get('/example2?param1=true¶m2=123', { + headers: { 'Axios-Redis-Cache-Duration': 90000 }, + }); + + // tslint:disable-next-line:no-backbone-get-set-outside-model + const responseFromCache = await axiosInstance.get('/example2?param1=true¶m2=123'); + + apiNock.done(); + expect(redisSetAsyncSpy).toBeCalledTimes(1); + expect(redisSetAsyncSpy).nthCalledWith( + 1, + '@scope/package@1.0.1___["get"]___WyIvZXhhbXBsZTI/cGFyYW0xPXRydWUmcGFyYW0yPTEyMyJd___W10=___W10=', + axiosResponseSetCache, + 'EX', + 90000, + ); + expect(redisGetAsyncSpy).toBeCalledTimes(2); + expect(redisGetAsyncSpy).nthCalledWith( + 1, + '@scope/package@1.0.1___["get"]___WyIvZXhhbXBsZTI/cGFyYW0xPXRydWUmcGFyYW0yPTEyMyJd___W10=___W10=', + ); + expect(redisGetAsyncSpy).nthCalledWith( + 2, + '@scope/package@1.0.1___["get"]___WyIvZXhhbXBsZTI/cGFyYW0xPXRydWUmcGFyYW0yPTEyMyJd___W10=___W10=', + ); + expect(response.status).toEqual(200); + expect(response.data).toStrictEqual({ success: true }); + expect(responseFromCache.status).toEqual(200); + expect(responseFromCache.data).toStrictEqual({ success: true }); + }); + it('should cache the response on the first call (with default configuration)', async () => { const axiosRedisRaw = new AxiosRedis(redis); @@ -253,6 +302,130 @@ describe('index.ts', () => { }); describe('Not cache', () => { + describe('POST', () => { + it('should not cache (if "Axios-Redis-Cache-Duration" header = null)', async () => { + const apiNock = nock('https://api.example.com') + .post('/example/1', { hello: 'world' }) + .query({ sort: 'desc' }) + .matchHeader('User-Agent', '@scope/package') + .matchHeader('Api-Key', '3b48b9fd18ecca20ed5b0accbfeb6b70') + .reply(200, { + success: true, + }); + + const redisSetAsyncSpy = jest.spyOn(axiosRedis, 'redisSetAsync'); + const redisGetAsyncSpy = jest.spyOn(axiosRedis, 'redisGetAsync'); + + const response = await axiosInstance.post( + '/example/1?sort=desc', + { + hello: 'world', + }, + { headers: { 'Axios-Redis-Cache-Duration': null } }, + ); + + apiNock.done(); + expect(redisSetAsyncSpy).toBeCalledTimes(0); + expect(redisGetAsyncSpy).toBeCalledTimes(1); + expect(redisGetAsyncSpy).nthCalledWith( + 1, + '@scope/package@1.0.1___["post"]___WyIvZXhhbXBsZS8xP3NvcnQ9ZGVzYyJd___W10=___WyJ7XCJoZWxsb1wiOlwid29ybGRcIn0iXQ==', + ); + expect(response.status).toEqual(200); + expect(response.data).toStrictEqual({ success: true }); + }); + + it('should not cache (if "Axios-Redis-Cache-Duration" header = 0)', async () => { + const apiNock = nock('https://api.example.com') + .post('/example/1', { hello: 'world' }) + .query({ sort: 'desc' }) + .matchHeader('User-Agent', '@scope/package') + .matchHeader('Api-Key', '3b48b9fd18ecca20ed5b0accbfeb6b70') + .reply(200, { + success: true, + }); + + const redisSetAsyncSpy = jest.spyOn(axiosRedis, 'redisSetAsync'); + const redisGetAsyncSpy = jest.spyOn(axiosRedis, 'redisGetAsync'); + + const response = await axiosInstance.post( + '/example/1?sort=desc', + { + hello: 'world', + }, + { headers: { 'Axios-Redis-Cache-Duration': 0 } }, + ); + + apiNock.done(); + expect(redisSetAsyncSpy).toBeCalledTimes(0); + expect(redisGetAsyncSpy).toBeCalledTimes(1); + expect(redisGetAsyncSpy).nthCalledWith( + 1, + '@scope/package@1.0.1___["post"]___WyIvZXhhbXBsZS8xP3NvcnQ9ZGVzYyJd___W10=___WyJ7XCJoZWxsb1wiOlwid29ybGRcIn0iXQ==', + ); + expect(response.status).toEqual(200); + expect(response.data).toStrictEqual({ success: true }); + }); + }); + + describe('GET', () => { + it('should not cache (if "Axios-Redis-Cache-Duration" header = null)', async () => { + const apiNock = nock('https://api.example.com') + .get('/example/1') + .query({ sort: 'desc' }) + .matchHeader('User-Agent', '@scope/package') + .matchHeader('Api-Key', '3b48b9fd18ecca20ed5b0accbfeb6b70') + .reply(200, { + success: true, + }); + + const redisSetAsyncSpy = jest.spyOn(axiosRedis, 'redisSetAsync'); + const redisGetAsyncSpy = jest.spyOn(axiosRedis, 'redisGetAsync'); + + const response = await axiosInstance.get('/example/1?sort=desc', { + headers: { 'Axios-Redis-Cache-Duration': null }, + }); + + apiNock.done(); + expect(redisSetAsyncSpy).toBeCalledTimes(0); + expect(redisGetAsyncSpy).toBeCalledTimes(1); + expect(redisGetAsyncSpy).nthCalledWith( + 1, + '@scope/package@1.0.1___["get"]___WyIvZXhhbXBsZS8xP3NvcnQ9ZGVzYyJd___W10=___W10=', + ); + expect(response.status).toEqual(200); + expect(response.data).toStrictEqual({ success: true }); + }); + + it('should not cache (if "Axios-Redis-Cache-Duration" header = 0)', async () => { + const apiNock = nock('https://api.example.com') + .get('/example/1') + .query({ sort: 'desc' }) + .matchHeader('User-Agent', '@scope/package') + .matchHeader('Api-Key', '3b48b9fd18ecca20ed5b0accbfeb6b70') + .reply(200, { + success: true, + }); + + const redisSetAsyncSpy = jest.spyOn(axiosRedis, 'redisSetAsync'); + const redisGetAsyncSpy = jest.spyOn(axiosRedis, 'redisGetAsync'); + + const response = await axiosInstance.get('/example/1?sort=desc', { + headers: { 'Axios-Redis-Cache-Duration': 0 }, + }); + + apiNock.done(); + expect(redisSetAsyncSpy).toBeCalledTimes(0); + expect(redisGetAsyncSpy).toBeCalledTimes(1); + expect(redisGetAsyncSpy).nthCalledWith( + 1, + '@scope/package@1.0.1___["get"]___WyIvZXhhbXBsZS8xP3NvcnQ9ZGVzYyJd___W10=___W10=', + ); + expect(response.status).toEqual(200); + expect(response.data).toStrictEqual({ success: true }); + }); + }); + describe('PUT', () => { it('should not cache', async () => { const apiNock = nock('https://api.example.com') diff --git a/package.json b/package.json index 5b5f7dc..a87d896 100644 --- a/package.json +++ b/package.json @@ -12,13 +12,13 @@ "redis": "2.8.0" }, "devDependencies": { - "@types/jest": "24.9.0", + "@types/jest": "24.9.1", "@types/node": "12.12.6", "@types/redis": "2.8.14", "jest": "25.1.0", "nock": "11.7.2", "prettier": "1.19.1", - "ts-jest": "24.3.0", + "ts-jest": "25.0.0", "tslint": "6.0.0", "tslint-config-prettier": "1.18.0", "tslint-microsoft-contrib": "6.2.0", diff --git a/src/classes/index.ts b/src/classes/index.ts index f2295d8..73420b6 100644 --- a/src/classes/index.ts +++ b/src/classes/index.ts @@ -3,7 +3,7 @@ import axios, { AxiosRequestConfig, AxiosResponse, AxiosPromise } from 'axios'; import * as _ from 'lodash'; import * as flatted from 'flatted'; import { promisify } from 'util'; -import { EHttpMethod, ERedisFlag } from './types'; +import { EHttpMethod, ERedisFlag, EAxiosCacheHeaders } from './types'; import { ICacheConfiguration, defaultConfiguration } from './config'; /** @@ -12,7 +12,7 @@ import { ICacheConfiguration, defaultConfiguration } from './config'; export class AxiosRedis { private readonly redis: RedisClient; private config: ICacheConfiguration; - public redisSetAsync: (key: string, value: string, flag: ERedisFlag, expirationInMS: number) => Promise; + public redisSetAsync: (key: string, value: string, flag?: ERedisFlag, expirationInMS?: number) => Promise; public redisGetAsync: (key: string) => Promise; public keysToNotEncode: string[] = ['method']; public methodsToCache: EHttpMethod[] = [EHttpMethod.GET, EHttpMethod.POST]; @@ -42,10 +42,17 @@ export class AxiosRedis { * @description Caches data. * @param {string} key * @param {AxiosResponse} data - * @returns Promise + * @param {undefined | number | null} durationInMS + * @returns Promise */ - setCache(key: string, data: AxiosResponse): Promise { - return this.redisSetAsync(key, flatted.stringify(data), ERedisFlag.EXPIRATION, this.config.expirationInMS); + async setCache(key: string, data: AxiosResponse, durationInMS: undefined | number | null): Promise { + if (durationInMS === 0 || typeof durationInMS === 'object') { + return; + } + + const duration = durationInMS || this.config.expirationInMS; + + return this.redisSetAsync(key, flatted.stringify(data), ERedisFlag.EXPIRATION, duration); } /** @@ -68,8 +75,9 @@ export class AxiosRedis { } // Send the request and store the result in case of success + const cacheDuration = config.headers[EAxiosCacheHeaders.CacheDuration]; response = await axiosRedis.fetch(config); - await axiosRedis.setCache(key, response); + await axiosRedis.setCache(key, response, cacheDuration); return response; } @@ -109,6 +117,8 @@ export class AxiosRedis { * @returns {AxiosPromise} */ private fetch(config: AxiosRequestConfig): AxiosPromise { + delete config.headers[EAxiosCacheHeaders.CacheDuration]; + const axiosDefaultAdapter = axios.create(Object.assign(config, { adapter: axios.defaults.adapter })); return axiosDefaultAdapter.request(config); diff --git a/src/classes/types.ts b/src/classes/types.ts index 05507cd..a35a934 100644 --- a/src/classes/types.ts +++ b/src/classes/types.ts @@ -1,3 +1,7 @@ +enum EAxiosCacheHeaders { + CacheDuration = 'Axios-Redis-Cache-Duration', +} + enum ERedisFlag { EXPIRATION = 'EX', } @@ -14,4 +18,4 @@ enum EHttpMethod { UNLINK = 'unlink', } -export { ERedisFlag, EHttpMethod }; +export { ERedisFlag, EHttpMethod, EAxiosCacheHeaders }; diff --git a/src/index.ts b/src/index.ts index ffe2358..f13fae1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,2 @@ export { AxiosRedis } from './classes'; +export { EAxiosCacheHeaders } from './classes/types'; diff --git a/yarn.lock b/yarn.lock index 859fc2e..6f1476a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -399,10 +399,10 @@ "@types/istanbul-lib-coverage" "*" "@types/istanbul-lib-report" "*" -"@types/jest@24.9.0": - version "24.9.0" - resolved "https://registry.yarnpkg.com/@types/jest/-/jest-24.9.0.tgz#78c6991cd1734cf0d390be24875e310bb0a9fb74" - integrity sha512-dXvuABY9nM1xgsXlOtLQXJKdacxZJd7AtvLsKZ/0b57ruMXDKCOXAC/M75GbllQX6o1pcZ5hAG4JzYy7Z/wM2w== +"@types/jest@24.9.1": + version "24.9.1" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-24.9.1.tgz#02baf9573c78f1b9974a5f36778b366aa77bd534" + integrity sha512-Fb38HkXSVA4L8fGKEZ6le5bB8r6MRWlOCZbVuWZcmOMSCd2wCYOwN1ibj8daIoV9naq7aaOZjrLCoCMptKU/4Q== dependencies: jest-diff "^24.3.0" @@ -3321,10 +3321,10 @@ tr46@^1.0.1: dependencies: punycode "^2.1.0" -ts-jest@24.3.0: - version "24.3.0" - resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-24.3.0.tgz#b97814e3eab359ea840a1ac112deae68aa440869" - integrity sha512-Hb94C/+QRIgjVZlJyiWwouYUF+siNJHJHknyspaOcZ+OQAIdFG/UrdQVXw/0B8Z3No34xkUXZJpOTy9alOWdVQ== +ts-jest@25.0.0: + version "25.0.0" + resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-25.0.0.tgz#d83b266e6ffda0c458a129951b3fe3567f8ce8df" + integrity sha512-F+hZg3j7XYOFpXJteXb4lnqy7vQzTmpTmX7AJT6pvSGeZejyXj1Lk0ArpnrEPOpv6Zu/NugHc5W7FINngC9WZQ== dependencies: bs-logger "0.x" buffer-from "1.x"