Skip to content

Commit

Permalink
Merge pull request #4 from tictactrip/feat/cacheManagement
Browse files Browse the repository at this point in the history
feat(global): disable/customize cache (for one request)
  • Loading branch information
rimiti authored Jan 24, 2020
2 parents 2d6fcfb + 94adbbf commit 45c7ca1
Show file tree
Hide file tree
Showing 7 changed files with 233 additions and 23 deletions.
34 changes: 28 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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:
Expand All @@ -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}
Expand All @@ -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?

Expand All @@ -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() });

Expand All @@ -90,6 +111,7 @@ describe('Example', () => {

// ...
});
});
```

## Scripts
Expand Down
173 changes: 173 additions & 0 deletions __tests__/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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&param2=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&param2=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&param2=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&param2=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&param2=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);

Expand Down Expand Up @@ -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')
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 16 additions & 6 deletions src/classes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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<string>;
public redisSetAsync: (key: string, value: string, flag?: ERedisFlag, expirationInMS?: number) => Promise<string>;
public redisGetAsync: (key: string) => Promise<string | null>;
public keysToNotEncode: string[] = ['method'];
public methodsToCache: EHttpMethod[] = [EHttpMethod.GET, EHttpMethod.POST];
Expand Down Expand Up @@ -42,10 +42,17 @@ export class AxiosRedis {
* @description Caches data.
* @param {string} key
* @param {AxiosResponse} data
* @returns Promise<string>
* @param {undefined | number | null} durationInMS
* @returns Promise<string | void>
*/
setCache(key: string, data: AxiosResponse): Promise<string> {
return this.redisSetAsync(key, flatted.stringify(data), ERedisFlag.EXPIRATION, this.config.expirationInMS);
async setCache(key: string, data: AxiosResponse, durationInMS: undefined | number | null): Promise<string | void> {
if (durationInMS === 0 || typeof durationInMS === 'object') {
return;
}

const duration = durationInMS || this.config.expirationInMS;

return this.redisSetAsync(key, flatted.stringify(data), ERedisFlag.EXPIRATION, duration);
}

/**
Expand All @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion src/classes/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
enum EAxiosCacheHeaders {
CacheDuration = 'Axios-Redis-Cache-Duration',
}

enum ERedisFlag {
EXPIRATION = 'EX',
}
Expand All @@ -14,4 +18,4 @@ enum EHttpMethod {
UNLINK = 'unlink',
}

export { ERedisFlag, EHttpMethod };
export { ERedisFlag, EHttpMethod, EAxiosCacheHeaders };
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { AxiosRedis } from './classes';
export { EAxiosCacheHeaders } from './classes/types';
Loading

0 comments on commit 45c7ca1

Please sign in to comment.