Skip to content

Commit

Permalink
New Feature: Added Rate Limit Management Support in Redis (#18)
Browse files Browse the repository at this point in the history
* install prettier & settings

* Add support cache redis

- New fields have been added to the ISettings interface (strategyCache, redis).
- A strategy to support storage in Redis has been implemented. Deletion is done automatically.
  • Loading branch information
JeffersonGibin committed Apr 28, 2024
1 parent 1dd7d07 commit 22582f0
Show file tree
Hide file tree
Showing 13 changed files with 416 additions and 93 deletions.
8 changes: 8 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": false,
"printWidth": 120,
"tabWidth": 2,
"useTabs": false
}
21 changes: 13 additions & 8 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll": true
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"eslint.enable": true,
"eslint.validate": ["javascript", "typescript", "json"],
"editor.tabSize": 2
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll": "explicit"
},
"eslint.enable": true,
"eslint.validate": ["javascript", "typescript", "json"],
"editor.tabSize": 2,
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
102 changes: 82 additions & 20 deletions README.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,8 @@
],
"license": "MIT",
"dependencies": {
"express": "^4.18.2"
"express": "^4.18.2",
"redis": "^4.6.13"
},
"devDependencies": {
"@types/express": "^4.17.17",
Expand All @@ -49,6 +50,7 @@
"eslint-plugin-n": "^15.6.1",
"eslint-plugin-promise": "^6.1.1",
"jest": "^29.4.3",
"prettier": "^3.2.5",
"ts-jest": "^29.0.5",
"typescript": "^4.9.5"
},
Expand Down
2 changes: 1 addition & 1 deletion src/core/policies/policies.factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export class PoliciesFactory {
this.responseRateLimitCache = responseRateLimitCache;
this.repositoryCache = repositoryCache;
}
s;

/**
* Create an instance of super type RateLimitPolicy
* @returns {RateLimitPolicy} instance
Expand Down
8 changes: 3 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import * as Redis from "redis";
import { MemoryCacheRepository } from "./shared/repositories/memory-cache.repository";
import {
ICache as CustomCache,
IRateLimitCache as RateLimitCache,
} from "./shared/interfaces/cache";
import { ICache as CustomCache, IRateLimitCache as RateLimitCache } from "./shared/interfaces/cache";

import { ISettings as Settings } from "./shared/interfaces/settings";
import { middleware } from "./shared/middleware";
Expand All @@ -20,4 +18,4 @@ export const MemoryCache = MemoryCacheRepository;
/**
* Interfaces Types
*/
export { CustomCache, RateLimitCache, Settings };
export { CustomCache, RateLimitCache, Settings, Redis };
86 changes: 86 additions & 0 deletions src/shared/get-strategy-cache.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { ISettings } from "./interfaces/settings";
import { MemoryCacheRepository } from "./repositories/memory-cache.repository";
import { RedisCacheRepository } from "./repositories/redis-cache.repository";
import { getStrategyCache } from "./get-strategy-cache";
import { RedisCache } from "./interfaces/cache";

jest.mock("./repositories/memory-cache.repository");
jest.mock("./repositories/redis-cache.repository");

const settingsBase: ISettings = {
policy: {
type: "REQUEST_PER_SECONDS",
periodWindow: 10,
maxRequests: 10,
},
};

describe("getStrategyCache unit test", () => {
beforeEach(() => {
(RedisCacheRepository.getInstance as jest.Mock).mockReturnValue(
new RedisCacheRepository({} as unknown as RedisCache)
);
(MemoryCacheRepository.getInstance as jest.Mock).mockReturnValue(
new MemoryCacheRepository()
);
});

it("should return an instance of MemoryCacheRepository when no strategy is specified", () => {
const settings = settingsBase;
expect(getStrategyCache(settings)).toBeInstanceOf(MemoryCacheRepository);
});

it("should return an instance of MemoryCacheRepository when strategy is IN_MEMORY", () => {
const settings: ISettings = { ...settingsBase, strategyCache: "IN_MEMORY" };
expect(getStrategyCache(settings)).toBeInstanceOf(MemoryCacheRepository);
});

it("should throw an error when strategy is CUSTOM without a cache implementation", () => {
const settings: ISettings = {
...settingsBase,
strategyCache: "CUSTOM",
cache: undefined,
} as unknown as ISettings;
expect(() => getStrategyCache(settings)).toThrow(
"When the property 'strategyCache' is 'CUSTOM', the property 'cache' is required."
);
});

it("should return a custom cache implementation when strategy is CUSTOM with a cache", () => {
const customCache = { set: jest.fn(), get: jest.fn() };
const settings: ISettings = {
...settingsBase,
strategyCache: "CUSTOM",
cache: customCache,
} as unknown as ISettings;

expect(getStrategyCache(settings)).toEqual(customCache);
});

it("should throw an error when strategy is REDIS without Redis configuration", () => {
const settings: ISettings = {
...settingsBase,
strategyCache: "REDIS",
} as unknown as ISettings;

expect(() => getStrategyCache(settings)).toThrow(
"When the property 'strategyCache' is 'REDIS', the property 'redis' is required."
);
});

it("should return an instance of RedisCacheRepository when strategy is REDIS with Redis configuration", () => {
const redisClient = {} as RedisCache;

const settings: ISettings = {
strategyCache: "REDIS",
redis: redisClient,
policy: {
type: "REQUEST_PER_SECONDS",
periodWindow: 10,
maxRequests: 10,
},
};

expect(getStrategyCache(settings)).toBeInstanceOf(RedisCacheRepository);
});
});
31 changes: 31 additions & 0 deletions src/shared/get-strategy-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { ICache } from "./interfaces/cache";
import { ISettings } from "./interfaces/settings";
import { MemoryCacheRepository } from "./repositories/memory-cache.repository";
import { RedisCacheRepository } from "./repositories/redis-cache.repository";

export const getStrategyCache = (settings: ISettings): ICache => {
// Default to IN_MEMORY if no strategy is specified or if IN_MEMORY is explicitly specified
if (!settings.strategyCache || settings.strategyCache === "IN_MEMORY") {
return MemoryCacheRepository.getInstance();
}

// Ensure that a custom cache is provided if the strategy is CUSTOM
if (settings.strategyCache === "CUSTOM") {
if (!settings.cache) {
throw new Error("When the property 'strategyCache' is 'CUSTOM', the property 'cache' is required.");
}
return settings.cache;
}

// Ensure that Redis configuration is provided if the strategy is REDIS
if (settings.strategyCache === "REDIS") {
if (!settings.redis) {
throw new Error("When the property 'strategyCache' is 'REDIS', the property 'redis' is required.");
}

return RedisCacheRepository.getInstance(settings.redis);
}

// Return MemoryCacheRepository by default if no valid strategy is matched (fallback safety)
return MemoryCacheRepository.getInstance();
};
6 changes: 6 additions & 0 deletions src/shared/interfaces/cache.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { RedisClientType, RedisFunctions, RedisModules, RedisScripts } from "redis";

/* eslint-disable no-unused-vars */
export interface IRateLimitCache {
/**
Expand All @@ -16,6 +18,10 @@ export interface IRateLimitCache {
created_at: number;
}

export type RedisCache = RedisClientType<RedisModules, RedisFunctions, RedisScripts>;

export type CacheStrategy = "REDIS" | "IN_MEMORY" | "CUSTOM";

export interface ICache {
/**
* Save a HIT to a cache using the parameter key.
Expand Down
49 changes: 41 additions & 8 deletions src/shared/interfaces/settings.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,60 @@
/* eslint-disable no-unused-vars */
import { RequestExpress } from "../../core/interfaces/express";
import { PolicieRateLimit } from "../../core/interfaces/policies";
import { ICache } from "./cache";
import { CacheStrategy, ICache, RedisCache } from "./cache";

export type BlockRequestRule = (req: RequestExpress) => boolean;

export interface ISettings {
interface ISettingsBase {
strategyCache?: CacheStrategy;

/**
* This atributte is opcional and needs to receive an classe to type ICache.
* You can implement a custom Cache if you want as long as the interface is respected
* @default MemoryCache
* The object with settings to policy rate-limit.
*/
cache?: ICache;
policy: PolicieRateLimit;

/**
* This function can to be implemented to forbidden a request.
* @param {IExpressRequest} req
* @returns {boolean}
*/
blockRequestRule?: BlockRequestRule;
}

interface ISettingsMemoryCache extends ISettingsBase {
/**
* The object with settings to policy rate-limit.
* Specifies the type of cache to use.
* @default IN_MEMORY
*/
policy: PolicieRateLimit;
strategyCache?: "IN_MEMORY";
}

interface ISettingsRedisCache extends ISettingsBase {
redis: RedisCache;

/**
* Specifies the type of cache to use.
* @default IN_MEMORY
*/
strategyCache?: "REDIS";
}

interface ISettingsCustomCache extends ISettingsBase {
/**
* Specifies the type of cache to use.
* @default IN_MEMORY
*/
strategyCache?: "CUSTOM";

/**
* This atributte is opcional and needs to receive an classe to type ICache.
* You can implement a custom Cache if you want as long as the interface is respected
* @default MemoryCache
*/
cache: ICache;
}

export type ISettings =
| ISettingsMemoryCache
| ISettingsRedisCache
| ISettingsCustomCache;

0 comments on commit 22582f0

Please sign in to comment.