diff --git a/apps/api/package.json b/apps/api/package.json index 9a65bb9cc7e..6fbadc3a1b2 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -50,6 +50,7 @@ "@sentry/node": "^7.40.0", "@sentry/tracing": "^7.40.0", "@types/newrelic": "^9.14.0", + "@upstash/ratelimit": "^0.4.4", "axios": "^1.3.3", "bcrypt": "^5.0.0", "body-parser": "^1.20.0", diff --git a/apps/api/src/.env.development b/apps/api/src/.env.development index a714a764ded..eab69380a3c 100644 --- a/apps/api/src/.env.development +++ b/apps/api/src/.env.development @@ -25,7 +25,6 @@ REDIS_CACHE_FAMILY= REDIS_CACHE_KEY_PREFIX= REDIS_CACHE_ENABLE_AUTOPIPELINING=true -IS_API_RATE_LIMITING_ENABLED=false IS_IN_MEMORY_CLUSTER_MODE_ENABLED=false ELASTICACHE_CLUSTER_SERVICE_HOST= ELASTICACHE_CLUSTER_SERVICE_PORT= @@ -68,3 +67,18 @@ LAUNCH_DARKLY_SDK_KEY= IS_API_IDEMPOTENCY_ENABLED=false AUTO_CREATE_INDEXES=true + +IS_API_RATE_LIMITING_ENABLED=false +API_RATE_LIMIT_COST_SINGLE= +API_RATE_LIMIT_COST_BULK= +API_RATE_LIMIT_ALGORITHM_BURST_ALLOWANCE= +API_RATE_LIMIT_ALGORITHM_WINDOW_DURATION= +API_RATE_LIMIT_MAXIMUM_BUSINESS_TRIGGER= +API_RATE_LIMIT_MAXIMUM_BUSINESS_CONFIGURATION= +API_RATE_LIMIT_MAXIMUM_BUSINESS_GLOBAL= +API_RATE_LIMIT_MAXIMUM_FREE_TRIGGER= +API_RATE_LIMIT_MAXIMUM_FREE_CONFIGURATION= +API_RATE_LIMIT_MAXIMUM_FREE_GLOBAL= +API_RATE_LIMIT_MAXIMUM_UNLIMITED_TRIGGER= +API_RATE_LIMIT_MAXIMUM_UNLIMITED_CONFIGURATION= +API_RATE_LIMIT_MAXIMUM_UNLIMITED_GLOBAL= diff --git a/apps/api/src/.env.production b/apps/api/src/.env.production index 93b7d61eb5b..1962c1e104d 100644 --- a/apps/api/src/.env.production +++ b/apps/api/src/.env.production @@ -13,7 +13,6 @@ REDIS_PORT=6379 REDIS_PREFIX= REDIS_DB_INDEX=2 -IS_API_RATE_LIMITING_ENABLED=false IS_IN_MEMORY_CLUSTER_MODE_ENABLED=false ELASTICACHE_CLUSTER_SERVICE_HOST= ELASTICACHE_CLUSTER_SERVICE_PORT= @@ -58,3 +57,18 @@ LAUNCH_DARKLY_SDK_KEY= IS_API_IDEMPOTENCY_ENABLED=false ## This value should be set to true if it is the first time you are running the with the database AUTO_CREATE_INDEXES=false + +IS_API_RATE_LIMITING_ENABLED=false +API_RATE_LIMIT_COST_SINGLE= +API_RATE_LIMIT_COST_BULK= +API_RATE_LIMIT_ALGORITHM_BURST_ALLOWANCE= +API_RATE_LIMIT_ALGORITHM_WINDOW_DURATION= +API_RATE_LIMIT_MAXIMUM_BUSINESS_TRIGGER= +API_RATE_LIMIT_MAXIMUM_BUSINESS_CONFIGURATION= +API_RATE_LIMIT_MAXIMUM_BUSINESS_GLOBAL= +API_RATE_LIMIT_MAXIMUM_FREE_TRIGGER= +API_RATE_LIMIT_MAXIMUM_FREE_CONFIGURATION= +API_RATE_LIMIT_MAXIMUM_FREE_GLOBAL= +API_RATE_LIMIT_MAXIMUM_UNLIMITED_TRIGGER= +API_RATE_LIMIT_MAXIMUM_UNLIMITED_CONFIGURATION= +API_RATE_LIMIT_MAXIMUM_UNLIMITED_GLOBAL= diff --git a/apps/api/src/.env.test b/apps/api/src/.env.test index 0f469e0c054..537e3774084 100644 --- a/apps/api/src/.env.test +++ b/apps/api/src/.env.test @@ -23,7 +23,6 @@ REDIS_CACHE_FAMILY= REDIS_CACHE_KEY_PREFIX= REDIS_CACHE_ENABLE_AUTOPIPELINING=false -IS_API_RATE_LIMITING_ENABLED=false IS_IN_MEMORY_CLUSTER_MODE_ENABLED=false ELASTICACHE_CLUSTER_SERVICE_HOST= ELASTICACHE_CLUSTER_SERVICE_PORT= @@ -93,3 +92,18 @@ NOVU_SMS_INTEGRATION_SENDER=1234567890 IS_API_IDEMPOTENCY_ENABLED=true AUTO_CREATE_INDEXES=true + +IS_API_RATE_LIMITING_ENABLED=false +API_RATE_LIMIT_COST_SINGLE= +API_RATE_LIMIT_COST_BULK= +API_RATE_LIMIT_ALGORITHM_BURST_ALLOWANCE= +API_RATE_LIMIT_ALGORITHM_WINDOW_DURATION= +API_RATE_LIMIT_MAXIMUM_BUSINESS_TRIGGER= +API_RATE_LIMIT_MAXIMUM_BUSINESS_CONFIGURATION= +API_RATE_LIMIT_MAXIMUM_BUSINESS_GLOBAL= +API_RATE_LIMIT_MAXIMUM_FREE_TRIGGER= +API_RATE_LIMIT_MAXIMUM_FREE_CONFIGURATION= +API_RATE_LIMIT_MAXIMUM_FREE_GLOBAL= +API_RATE_LIMIT_MAXIMUM_UNLIMITED_TRIGGER= +API_RATE_LIMIT_MAXIMUM_UNLIMITED_CONFIGURATION= +API_RATE_LIMIT_MAXIMUM_UNLIMITED_GLOBAL= diff --git a/apps/api/src/.example.env b/apps/api/src/.example.env index 486452b6788..87e2e3e7979 100644 --- a/apps/api/src/.example.env +++ b/apps/api/src/.example.env @@ -23,7 +23,6 @@ REDIS_CACHE_FAMILY= REDIS_CACHE_KEY_PREFIX= REDIS_CACHE_ENABLE_AUTOPIPELINING= -IS_API_RATE_LIMITING_ENABLED=false IS_IN_MEMORY_CLUSTER_MODE_ENABLED=false REDIS_CLUSTER_SERVICE_HOST= REDIS_CLUSTER_SERVICE_PORT= @@ -63,3 +62,18 @@ INTERCOM_IDENTITY_VERIFICATION_SECRET_KEY= LOGGING_LEVEL=info LAUNCH_DARKLY_SDK_KEY= + +IS_API_RATE_LIMITING_ENABLED=false +API_RATE_LIMIT_COST_SINGLE= +API_RATE_LIMIT_COST_BULK= +API_RATE_LIMIT_ALGORITHM_BURST_ALLOWANCE= +API_RATE_LIMIT_ALGORITHM_WINDOW_DURATION= +API_RATE_LIMIT_MAXIMUM_BUSINESS_TRIGGER= +API_RATE_LIMIT_MAXIMUM_BUSINESS_CONFIGURATION= +API_RATE_LIMIT_MAXIMUM_BUSINESS_GLOBAL= +API_RATE_LIMIT_MAXIMUM_FREE_TRIGGER= +API_RATE_LIMIT_MAXIMUM_FREE_CONFIGURATION= +API_RATE_LIMIT_MAXIMUM_FREE_GLOBAL= +API_RATE_LIMIT_MAXIMUM_UNLIMITED_TRIGGER= +API_RATE_LIMIT_MAXIMUM_UNLIMITED_CONFIGURATION= +API_RATE_LIMIT_MAXIMUM_UNLIMITED_GLOBAL= diff --git a/apps/api/src/app/organization/e2e/create-organization.e2e.ts b/apps/api/src/app/organization/e2e/create-organization.e2e.ts index c21d9f34b27..d2076961671 100644 --- a/apps/api/src/app/organization/e2e/create-organization.e2e.ts +++ b/apps/api/src/app/organization/e2e/create-organization.e2e.ts @@ -1,6 +1,6 @@ import { MemberRepository, OrganizationRepository } from '@novu/dal'; import { UserSession } from '@novu/testing'; -import { ApiServiceLevelTypeEnum, MemberRoleEnum } from '@novu/shared'; +import { ApiServiceLevelEnum, MemberRoleEnum } from '@novu/shared'; import { expect } from 'chai'; describe('Create Organization - /organizations (POST)', async () => { @@ -53,7 +53,7 @@ describe('Create Organization - /organizations (POST)', async () => { const { body } = await session.testAgent.post('/v1/organizations').send(testOrganization).expect(201); const dbOrganization = await organizationRepository.findById(body.data._id); - expect(dbOrganization?.apiServiceLevel).to.eq(ApiServiceLevelTypeEnum.FREE); + expect(dbOrganization?.apiServiceLevel).to.eq(ApiServiceLevelEnum.FREE); }); }); }); diff --git a/apps/api/src/app/organization/usecases/create-organization/create-organization.usecase.ts b/apps/api/src/app/organization/usecases/create-organization/create-organization.usecase.ts index d559b9e7a3e..622b85e783f 100644 --- a/apps/api/src/app/organization/usecases/create-organization/create-organization.usecase.ts +++ b/apps/api/src/app/organization/usecases/create-organization/create-organization.usecase.ts @@ -1,6 +1,6 @@ import { Inject, Injectable, Scope } from '@nestjs/common'; import { OrganizationEntity, OrganizationRepository, UserRepository } from '@novu/dal'; -import { ApiServiceLevelTypeEnum, MemberRoleEnum } from '@novu/shared'; +import { ApiServiceLevelEnum, MemberRoleEnum } from '@novu/shared'; import { AnalyticsService } from '@novu/application-generic'; import { CreateEnvironmentCommand } from '../../../environments/usecases/create-environment/create-environment.command'; @@ -36,7 +36,7 @@ export class CreateOrganization { const createdOrganization = await this.organizationRepository.create({ logo: command.logo, name: command.name, - apiServiceLevel: ApiServiceLevelTypeEnum.FREE, + apiServiceLevel: ApiServiceLevelEnum.FREE, }); await this.addMemberUsecase.execute( diff --git a/apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.command.ts b/apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.command.ts new file mode 100644 index 00000000000..3479e065409 --- /dev/null +++ b/apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.command.ts @@ -0,0 +1,13 @@ +import { IsDefined, IsEnum } from 'class-validator'; +import { ApiRateLimitCategoryEnum, ApiRateLimitCostEnum } from '@novu/shared'; +import { EnvironmentCommand } from '../../../shared/commands/project.command'; + +export class EvaluateApiRateLimitCommand extends EnvironmentCommand { + @IsDefined() + @IsEnum(ApiRateLimitCategoryEnum) + apiRateLimitCategory: ApiRateLimitCategoryEnum; + + @IsDefined() + @IsEnum(ApiRateLimitCostEnum) + apiRateLimitCost: ApiRateLimitCostEnum; +} diff --git a/apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.spec.ts b/apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.spec.ts new file mode 100644 index 00000000000..0bd3b75f0af --- /dev/null +++ b/apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.spec.ts @@ -0,0 +1,209 @@ +import { Test } from '@nestjs/testing'; +import { EvaluateApiRateLimit, EvaluateApiRateLimitCommand } from './index'; +import { UserSession } from '@novu/testing'; +import { + ApiRateLimitAlgorithmEnum, + ApiRateLimitCategoryEnum, + ApiRateLimitCostEnum, + IApiRateLimitAlgorithm, + IApiRateLimitCost, +} from '@novu/shared'; +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { GetApiRateLimitMaximum } from '../get-api-rate-limit-maximum'; +import { GetApiRateLimitAlgorithmConfig } from '../get-api-rate-limit-algorithm-config'; +import { SharedModule } from '../../../shared/shared.module'; +import { RateLimitingModule } from '../../rate-limiting.module'; +import { GetApiRateLimitCostConfig } from '../get-api-rate-limit-cost-config'; +import { EvaluateTokenBucketRateLimit } from '../evaluate-token-bucket-rate-limit'; + +const mockApiRateLimitAlgorithm: IApiRateLimitAlgorithm = { + [ApiRateLimitAlgorithmEnum.BURST_ALLOWANCE]: 0.2, + [ApiRateLimitAlgorithmEnum.WINDOW_DURATION]: 2, +}; +const mockApiRateLimitCost = ApiRateLimitCostEnum.SINGLE; +const mockCost = 1; +const mockApiRateLimitCostConfig: Partial = { + [mockApiRateLimitCost]: mockCost, +}; + +const mockMaxLimit = 10; +const mockRemaining = 9; +const mockReset = 1; +const mockApiRateLimitCategory = ApiRateLimitCategoryEnum.GLOBAL; + +describe('EvaluateApiRateLimit', async () => { + let useCase: EvaluateApiRateLimit; + let session: UserSession; + let getApiRateLimitMaximum: GetApiRateLimitMaximum; + let getApiRateLimitAlgorithmConfig: GetApiRateLimitAlgorithmConfig; + let getApiRateLimitCostConfig: GetApiRateLimitCostConfig; + let evaluateTokenBucketRateLimit: EvaluateTokenBucketRateLimit; + + let getApiRateLimitMaximumStub: sinon.SinonStub; + let getApiRateLimitAlgorithmConfigStub: sinon.SinonStub; + let getApiRateLimitCostConfigStub: sinon.SinonStub; + let evaluateTokenBucketRateLimitStub: sinon.SinonStub; + + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [SharedModule, RateLimitingModule], + }).compile(); + + session = new UserSession(); + await session.initialize(); + + useCase = moduleRef.get(EvaluateApiRateLimit); + getApiRateLimitMaximum = moduleRef.get(GetApiRateLimitMaximum); + getApiRateLimitAlgorithmConfig = moduleRef.get(GetApiRateLimitAlgorithmConfig); + getApiRateLimitCostConfig = moduleRef.get(GetApiRateLimitCostConfig); + evaluateTokenBucketRateLimit = moduleRef.get(EvaluateTokenBucketRateLimit); + + getApiRateLimitMaximumStub = sinon.stub(getApiRateLimitMaximum, 'execute').resolves(mockMaxLimit); + getApiRateLimitAlgorithmConfigStub = sinon + .stub(getApiRateLimitAlgorithmConfig, 'default') + .value(mockApiRateLimitAlgorithm); + getApiRateLimitCostConfigStub = sinon.stub(getApiRateLimitCostConfig, 'default').value(mockApiRateLimitCostConfig); + evaluateTokenBucketRateLimitStub = sinon.stub(evaluateTokenBucketRateLimit, 'execute').resolves({ + success: true, + limit: mockMaxLimit, + remaining: mockRemaining, + reset: mockReset, + }); + }); + + afterEach(() => { + getApiRateLimitMaximumStub.restore(); + getApiRateLimitAlgorithmConfigStub.restore(); + getApiRateLimitCostConfigStub.restore(); + }); + + describe('Evaluation Values', () => { + it('should return a boolean success value', async () => { + const result = await useCase.execute( + EvaluateApiRateLimitCommand.create({ + organizationId: session.organization._id, + environmentId: session.environment._id, + apiRateLimitCategory: mockApiRateLimitCategory, + apiRateLimitCost: mockApiRateLimitCost, + }) + ); + + expect(typeof result.success).to.equal('boolean'); + }); + + it('should return a positive limit', async () => { + const result = await useCase.execute( + EvaluateApiRateLimitCommand.create({ + organizationId: session.organization._id, + environmentId: session.environment._id, + apiRateLimitCategory: mockApiRateLimitCategory, + apiRateLimitCost: mockApiRateLimitCost, + }) + ); + + expect(result.limit).to.be.greaterThan(0); + }); + + it('should return a positive remaining tokens ', async () => { + const result = await useCase.execute( + EvaluateApiRateLimitCommand.create({ + organizationId: session.organization._id, + environmentId: session.environment._id, + apiRateLimitCategory: mockApiRateLimitCategory, + apiRateLimitCost: mockApiRateLimitCost, + }) + ); + + expect(result.remaining).to.be.greaterThan(0); + }); + + it('should return a positive reset', async () => { + const result = await useCase.execute( + EvaluateApiRateLimitCommand.create({ + organizationId: session.organization._id, + environmentId: session.environment._id, + apiRateLimitCategory: mockApiRateLimitCategory, + apiRateLimitCost: mockApiRateLimitCost, + }) + ); + + expect(result.reset).to.be.greaterThan(0); + }); + }); + + describe('Static Values', () => { + it('should return a string type algorithm value', async () => { + const result = await useCase.execute( + EvaluateApiRateLimitCommand.create({ + organizationId: session.organization._id, + environmentId: session.environment._id, + apiRateLimitCategory: mockApiRateLimitCategory, + apiRateLimitCost: mockApiRateLimitCost, + }) + ); + + expect(typeof result.algorithm).to.equal('string'); + }); + + it('should return the correct window duration', async () => { + const result = await useCase.execute( + EvaluateApiRateLimitCommand.create({ + organizationId: session.organization._id, + environmentId: session.environment._id, + apiRateLimitCategory: mockApiRateLimitCategory, + apiRateLimitCost: mockApiRateLimitCost, + }) + ); + + expect(result.windowDuration).to.equal(mockApiRateLimitAlgorithm[ApiRateLimitAlgorithmEnum.WINDOW_DURATION]); + }); + }); + + describe('Computed Values', () => { + it('should return the correct cost', async () => { + const result = await useCase.execute( + EvaluateApiRateLimitCommand.create({ + organizationId: session.organization._id, + environmentId: session.environment._id, + apiRateLimitCategory: mockApiRateLimitCategory, + apiRateLimitCost: mockApiRateLimitCost, + }) + ); + + expect(result.cost).to.equal(mockApiRateLimitCostConfig[mockApiRateLimitCost]); + }); + + it('should return the correct refill rate', async () => { + const result = await useCase.execute( + EvaluateApiRateLimitCommand.create({ + organizationId: session.organization._id, + environmentId: session.environment._id, + apiRateLimitCategory: mockApiRateLimitCategory, + apiRateLimitCost: mockApiRateLimitCost, + }) + ); + + expect(result.refillRate).to.equal( + mockMaxLimit * mockApiRateLimitAlgorithm[ApiRateLimitAlgorithmEnum.WINDOW_DURATION] + ); + }); + + it('should return the correct burst limit', async () => { + const result = await useCase.execute( + EvaluateApiRateLimitCommand.create({ + organizationId: session.organization._id, + environmentId: session.environment._id, + apiRateLimitCategory: mockApiRateLimitCategory, + apiRateLimitCost: mockApiRateLimitCost, + }) + ); + + expect(result.burstLimit).to.equal( + mockMaxLimit * + mockApiRateLimitAlgorithm[ApiRateLimitAlgorithmEnum.WINDOW_DURATION] * + (1 + mockApiRateLimitAlgorithm[ApiRateLimitAlgorithmEnum.BURST_ALLOWANCE]) + ); + }); + }); +}); diff --git a/apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.types.ts b/apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.types.ts new file mode 100644 index 00000000000..66107393cad --- /dev/null +++ b/apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.types.ts @@ -0,0 +1,38 @@ +export type EvaluateApiRateLimitResponseDto = { + /** + * Whether the request may pass(true) or exceeded the limit(false) + */ + success: boolean; + /** + * Maximum number of requests allowed within a window. + */ + limit: number; + /** + * How many requests the client has left within the current window. + */ + remaining: number; + /** + * Unix timestamp in milliseconds when the limits are reset. + */ + reset: number; + /** + * The duration of the window in seconds. + */ + windowDuration: number; + /** + * The maximum number of requests allowed within a window, including the burst allowance. + */ + burstLimit: number; + /** + * The number of requests that will be refilled per window. + */ + refillRate: number; + /** + * The name of the algorithm used to calculate the rate limit. + */ + algorithm: string; + /** + * The cost of the request. + */ + cost: number; +}; diff --git a/apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.usecase.ts b/apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.usecase.ts new file mode 100644 index 00000000000..2a870ba3ae6 --- /dev/null +++ b/apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/evaluate-api-rate-limit.usecase.ts @@ -0,0 +1,81 @@ +import { Injectable } from '@nestjs/common'; +import { ApiRateLimitAlgorithmEnum } from '@novu/shared'; +import { EvaluateApiRateLimitCommand } from './evaluate-api-rate-limit.command'; +import { GetApiRateLimitMaximum, GetApiRateLimitMaximumCommand } from '../get-api-rate-limit-maximum'; +import { InstrumentUsecase, buildEvaluateApiRateLimitKey } from '@novu/application-generic'; +import { GetApiRateLimitAlgorithmConfig } from '../get-api-rate-limit-algorithm-config'; +import { EvaluateApiRateLimitResponseDto } from './evaluate-api-rate-limit.types'; +import { EvaluateTokenBucketRateLimit } from '../evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.usecase'; +import { GetApiRateLimitCostConfig } from '../get-api-rate-limit-cost-config'; +import { EvaluateTokenBucketRateLimitCommand } from '../evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.command'; + +@Injectable() +export class EvaluateApiRateLimit { + constructor( + private getApiRateLimitMaximum: GetApiRateLimitMaximum, + private getApiRateLimitAlgorithmConfig: GetApiRateLimitAlgorithmConfig, + private getApiRateLimitCostConfig: GetApiRateLimitCostConfig, + private evaluateTokenBucketRateLimit: EvaluateTokenBucketRateLimit + ) {} + + @InstrumentUsecase() + async execute(command: EvaluateApiRateLimitCommand): Promise { + const maxLimitPerSecond = await this.getApiRateLimitMaximum.execute( + GetApiRateLimitMaximumCommand.create({ + apiRateLimitCategory: command.apiRateLimitCategory, + environmentId: command.environmentId, + organizationId: command.organizationId, + }) + ); + + const windowDuration = this.getApiRateLimitAlgorithmConfig.default[ApiRateLimitAlgorithmEnum.WINDOW_DURATION]; + const burstAllowance = this.getApiRateLimitAlgorithmConfig.default[ApiRateLimitAlgorithmEnum.BURST_ALLOWANCE]; + const cost = this.getApiRateLimitCostConfig.default[command.apiRateLimitCost]; + const maxTokensPerWindow = this.getMaxTokensPerWindow(maxLimitPerSecond, windowDuration); + const refillRate = this.getRefillRate(maxLimitPerSecond, windowDuration); + const burstLimit = this.getBurstLimit(maxTokensPerWindow, burstAllowance); + + const identifier = buildEvaluateApiRateLimitKey({ + _environmentId: command.environmentId, + apiRateLimitCategory: command.apiRateLimitCategory, + }); + + const { success, remaining, reset } = await this.evaluateTokenBucketRateLimit.execute( + EvaluateTokenBucketRateLimitCommand.create({ + identifier, + maxTokens: burstLimit, + windowDuration, + cost, + refillRate, + }) + ); + + return { + success, + limit: maxTokensPerWindow, + remaining, + reset, + windowDuration, + burstLimit, + refillRate, + algorithm: this.evaluateTokenBucketRateLimit.algorithm, + cost, + }; + } + + private getMaxTokensPerWindow(maxLimit: number, windowDuration: number): number { + return maxLimit * windowDuration; + } + + private getRefillRate(maxLimit: number, windowDuration: number): number { + /* + * Refill rate is currently set to the max tokens per window. + * This can be changed to a different value to implement adaptive rate limiting. + */ + return this.getMaxTokensPerWindow(maxLimit, windowDuration); + } + + private getBurstLimit(maxTokensPerWindow: number, burstAllowance: number): number { + return Math.floor(maxTokensPerWindow * (1 + burstAllowance)); + } +} diff --git a/apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/index.ts b/apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/index.ts new file mode 100644 index 00000000000..a7e80f96d03 --- /dev/null +++ b/apps/api/src/app/rate-limiting/usecases/evaluate-api-rate-limit/index.ts @@ -0,0 +1,3 @@ +export * from './evaluate-api-rate-limit.command'; +export * from './evaluate-api-rate-limit.usecase'; +export * from './evaluate-api-rate-limit.types'; diff --git a/apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.command.ts b/apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.command.ts new file mode 100644 index 00000000000..c88170e42c0 --- /dev/null +++ b/apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.command.ts @@ -0,0 +1,24 @@ +import { IsDefined, IsNumber, IsString } from 'class-validator'; +import { BaseCommand } from '../../../shared/commands/base.command'; + +export class EvaluateTokenBucketRateLimitCommand extends BaseCommand { + @IsDefined() + @IsString() + identifier: string; + + @IsDefined() + @IsNumber() + maxTokens: number; + + @IsDefined() + @IsNumber() + windowDuration: number; + + @IsDefined() + @IsNumber() + cost: number; + + @IsDefined() + @IsNumber() + refillRate: number; +} diff --git a/apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.spec.ts b/apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.spec.ts new file mode 100644 index 00000000000..cc2569308b8 --- /dev/null +++ b/apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.spec.ts @@ -0,0 +1,101 @@ +import { expect } from 'chai'; +import { EvaluateTokenBucketRateLimit } from './evaluate-token-bucket-rate-limit.usecase'; +import { CacheService } from '@novu/application-generic'; +import { SharedModule } from '../../../shared/shared.module'; +import { RateLimitingModule } from '../../rate-limiting.module'; +import { Test } from '@nestjs/testing'; +import * as sinon from 'sinon'; +import { EvaluateTokenBucketRateLimitCommand } from './evaluate-token-bucket-rate-limit.command'; + +describe('EvaluateTokenBucketRateLimit', () => { + let useCase: EvaluateTokenBucketRateLimit; + let cacheService: CacheService; + + const mockCommand = EvaluateTokenBucketRateLimitCommand.create({ + identifier: 'test', + maxTokens: 10, + windowDuration: 1, + cost: 1, + refillRate: 1, + }); + + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [SharedModule, RateLimitingModule], + }).compile(); + + useCase = moduleRef.get(EvaluateTokenBucketRateLimit); + cacheService = moduleRef.get(CacheService); + }); + + describe('Static values', () => { + it('should have a static algorithm value', () => { + expect(useCase.algorithm).to.equal('token bucket'); + }); + }); + + describe('Cache invocation', () => { + let cacheServiceEvalStub: sinon.SinonStub; + let cacheServiceSaddStub: sinon.SinonStub; + let cacheServiceIsEnabledStub: sinon.SinonStub; + + beforeEach(async () => { + cacheServiceEvalStub = sinon.stub(cacheService, 'eval'); + cacheServiceSaddStub = sinon.stub(cacheService, 'sadd'); + cacheServiceIsEnabledStub = sinon.stub(cacheService, 'cacheEnabled').returns(true); + }); + + afterEach(() => { + cacheServiceEvalStub.restore(); + cacheServiceSaddStub.restore(); + cacheServiceIsEnabledStub.restore(); + }); + + describe('Cache Errors', () => { + it('should throw error when a cache operation fails', async () => { + cacheServiceEvalStub.resolves(new Error()); + + try { + await useCase.execute(mockCommand); + throw new Error('Should not reach here'); + } catch (e) { + expect(e.message).to.equal('Failed to evaluate rate limit'); + } + }); + + it('should throw error when cache is not enabled', async () => { + cacheServiceIsEnabledStub.returns(false); + + try { + await useCase.execute(mockCommand); + throw new Error('Should not reach here'); + } catch (e) { + expect(e.message).to.equal('Rate limiting cache service is not available'); + } + }); + }); + + describe('Cache Service Adapter', () => { + it('should invoke the SADD method with members casted to string', async () => { + const cacheClient = EvaluateTokenBucketRateLimit.getCacheClient(cacheService); + const key = 'testKey'; + const members = [1, 2]; + + await cacheClient.sadd(key, ...members); + + expect(cacheServiceSaddStub.calledWith(key, ...['1', '2'])).to.equal(true); + }); + + it('should invoke the EVAL function with args casted to string', async () => { + const cacheClient = EvaluateTokenBucketRateLimit.getCacheClient(cacheService); + const script = 'return 1'; + const keys = ['key1', 'key2']; + const args = [1, 2]; + + await cacheClient.eval(script, keys, args); + + expect(cacheServiceEvalStub.calledWith(script, keys, ['1', '2'])).to.equal(true); + }); + }); + }); +}); diff --git a/apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.types.ts b/apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.types.ts new file mode 100644 index 00000000000..650a986db34 --- /dev/null +++ b/apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.types.ts @@ -0,0 +1,63 @@ +import { Ratelimit } from '@upstash/ratelimit'; + +export type UpstashRedisClient = ConstructorParameters[0]['redis']; + +export type EvaluateTokenBucketRateLimitResponseDto = { + /** + * Whether the request may pass(true) or exceeded the limit(false) + */ + success: boolean; + /** + * Maximum number of requests allowed within a window. + */ + limit: number; + /** + * How many requests the client has left within the current window. + */ + remaining: number; + /** + * Unix timestamp in milliseconds when the limits are reset. + */ + reset: number; +}; + +export type RegionLimiter = ReturnType; + +/** + * You have a bucket filled with `{maxTokens}` tokens that refills constantly + * at `{refillRate}` per `{interval}`. + * Every request will remove `{cost}` token(s) from the bucket and if there is no + * token to take, the request is rejected. + * + * **Pro:** + * + * - Bursts of requests are smoothed out and you can process them at a constant + * rate. + * - Allows to set a higher initial burst limit by setting `maxTokens` higher + * than `refillRate` + * + * Adapted from the Krakend tokenBucket algorithm to include a variable cost: + * @see https://github.com/krakend/krakend-ratelimit/blob/369f0be9b51a4fb8ab7d43e4833d076b461a4374/rate.go#L85 + */ +export type CostLimiter = ( + /** + * How many tokens are refilled per `interval` + * + * An interval of `10s` and refillRate of 5 will cause a new token to be added every 2 seconds. + */ + refillRate: number, + /** + * The interval in seconds for the `refillRate` + */ + interval: number, + /** + * Maximum number of tokens. + * A newly created bucket starts with this many tokens. + * Useful to allow higher burst limits. + */ + maxTokens: number, + /** + * The number of tokens used in the request. + */ + cost: number +) => RegionLimiter; diff --git a/apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.usecase.ts b/apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.usecase.ts new file mode 100644 index 00000000000..df6c20e64a4 --- /dev/null +++ b/apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/evaluate-token-bucket-rate-limit.usecase.ts @@ -0,0 +1,79 @@ +import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; +import { Ratelimit } from '@upstash/ratelimit'; +import { EvaluateTokenBucketRateLimitCommand } from './evaluate-token-bucket-rate-limit.command'; +import { CacheService, InstrumentUsecase } from '@novu/application-generic'; +import { + EvaluateTokenBucketRateLimitResponseDto, + RegionLimiter, + UpstashRedisClient, +} from './evaluate-token-bucket-rate-limit.types'; + +const LOG_CONTEXT = 'EvaluateTokenBucketRateLimit'; + +@Injectable() +export class EvaluateTokenBucketRateLimit { + private ephemeralCache = new Map(); + public algorithm = 'token bucket'; + + constructor(private cacheService: CacheService) {} + + @InstrumentUsecase() + async execute(command: EvaluateTokenBucketRateLimitCommand): Promise { + if (!this.cacheService.cacheEnabled()) { + const message = 'Rate limiting cache service is not available'; + Logger.error(message, LOG_CONTEXT); + throw new ServiceUnavailableException(message); + } + + const cacheClient = EvaluateTokenBucketRateLimit.getCacheClient(this.cacheService); + + const ratelimit = new Ratelimit({ + redis: cacheClient, + limiter: EvaluateTokenBucketRateLimit.tokenBucketLimiter( + command.refillRate, + command.windowDuration, + command.maxTokens, + command.cost + ), + prefix: '', // Empty cache key prefix to give us full control over the key format + ephemeralCache: this.ephemeralCache, + }); + try { + const { success, limit, remaining, reset } = await ratelimit.limit(command.identifier); + + return { + success, + limit, + remaining, + reset, + }; + } catch (error) { + const apiMessage = 'Failed to evaluate rate limit'; + const logMessage = `${apiMessage} for identifier: "${command.identifier}". Error: "${error}"`; + Logger.error(logMessage, LOG_CONTEXT); + throw new ServiceUnavailableException(apiMessage); + } + } + + public static getCacheClient(cacheService: CacheService): UpstashRedisClient { + // Adapter for the @upstash/redis client -> cache client + return { + sadd: async (key, ...members) => cacheService.sadd(key, ...members.map((member) => String(member))), + eval: async (script, keys, args) => + cacheService.eval( + script, + keys, + args.map((arg) => String(arg)) + ), + }; + } + + public static tokenBucketLimiter( + refillRate: number, + interval: number, + maxTokens: number, + cost: number + ): RegionLimiter { + return Ratelimit.tokenBucket(refillRate, `${interval} s`, maxTokens); + } +} diff --git a/apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/index.ts b/apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/index.ts new file mode 100644 index 00000000000..a4c20ef693e --- /dev/null +++ b/apps/api/src/app/rate-limiting/usecases/evaluate-token-bucket-rate-limit/index.ts @@ -0,0 +1,3 @@ +export * from './evaluate-token-bucket-rate-limit.command'; +export * from './evaluate-token-bucket-rate-limit.usecase'; +export * from './evaluate-token-bucket-rate-limit.types'; diff --git a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-algorithm-config/get-api-rate-limit-algorithm-config.spec.ts b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-algorithm-config/get-api-rate-limit-algorithm-config.spec.ts new file mode 100644 index 00000000000..ccbf77fb37a --- /dev/null +++ b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-algorithm-config/get-api-rate-limit-algorithm-config.spec.ts @@ -0,0 +1,41 @@ +import { Test } from '@nestjs/testing'; +import { GetApiRateLimitAlgorithmConfig } from './get-api-rate-limit-algorithm-config.usecase'; +import { + ApiRateLimitAlgorithmEnum, + ApiRateLimitAlgorithmEnvVarFormat, + DEFAULT_API_RATE_LIMIT_ALGORITHM_CONFIG, +} from '@novu/shared'; +import { expect } from 'chai'; + +describe('GetApiRateLimitAlgorithmConfig', () => { + let useCase: GetApiRateLimitAlgorithmConfig; + + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [GetApiRateLimitAlgorithmConfig], + }).compile(); + + useCase = moduleRef.get(GetApiRateLimitAlgorithmConfig); + }); + + it('should use the default rate limit algorithm config when no environment variables are set', () => { + expect(useCase.default).to.deep.equal(DEFAULT_API_RATE_LIMIT_ALGORITHM_CONFIG); + }); + + it('should override default rate limit algorithm config with environment variables', () => { + const mockOverrideBurstAllowance = 0.2; + const mockApiRateLimitConfigurationKey = ApiRateLimitAlgorithmEnum.BURST_ALLOWANCE; + + const envVarName: ApiRateLimitAlgorithmEnvVarFormat = `API_RATE_LIMIT_ALGORITHM_${ + mockApiRateLimitConfigurationKey.toUpperCase() as Uppercase + }`; + process.env[envVarName] = `${mockOverrideBurstAllowance}`; + + // Re-initialize the defaultApiRateLimits after setting the environment variable + useCase.loadDefault(); + const result = useCase.default; + + expect(result[mockApiRateLimitConfigurationKey]).to.equal(mockOverrideBurstAllowance); + delete process.env[envVarName]; // cleanup + }); +}); diff --git a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-algorithm-config/get-api-rate-limit-algorithm-config.usecase.ts b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-algorithm-config/get-api-rate-limit-algorithm-config.usecase.ts new file mode 100644 index 00000000000..24b84247405 --- /dev/null +++ b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-algorithm-config/get-api-rate-limit-algorithm-config.usecase.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common'; +import { + ApiRateLimitAlgorithmEnum, + ApiRateLimitAlgorithmEnvVarFormat, + DEFAULT_API_RATE_LIMIT_ALGORITHM_CONFIG, + IApiRateLimitAlgorithm, +} from '@novu/shared'; + +@Injectable() +export class GetApiRateLimitAlgorithmConfig { + public default: IApiRateLimitAlgorithm; + + constructor() { + this.loadDefault(); + } + + public loadDefault(): void { + this.default = this.createDefault(); + } + + private createDefault(): IApiRateLimitAlgorithm { + const mergedConfig: IApiRateLimitAlgorithm = { ...DEFAULT_API_RATE_LIMIT_ALGORITHM_CONFIG }; + + // Read process environment only once for performance + const processEnv = process.env; + + Object.values(ApiRateLimitAlgorithmEnum).forEach((algorithmOption) => { + const envVarName = this.getEnvVarName(algorithmOption); + const envVarValue = processEnv[envVarName]; + + if (envVarValue) { + mergedConfig[algorithmOption] = Number(envVarValue); + } + }); + + return mergedConfig; + } + + private getEnvVarName(algorithmOption: ApiRateLimitAlgorithmEnum): ApiRateLimitAlgorithmEnvVarFormat { + return `API_RATE_LIMIT_ALGORITHM_${algorithmOption.toUpperCase() as Uppercase}`; + } +} diff --git a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-algorithm-config/index.ts b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-algorithm-config/index.ts new file mode 100644 index 00000000000..89843dfaced --- /dev/null +++ b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-algorithm-config/index.ts @@ -0,0 +1 @@ +export * from './get-api-rate-limit-algorithm-config.usecase'; diff --git a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-cost-config/get-api-rate-limit-cost-config.spec.ts b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-cost-config/get-api-rate-limit-cost-config.spec.ts new file mode 100644 index 00000000000..7bdfc359b59 --- /dev/null +++ b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-cost-config/get-api-rate-limit-cost-config.spec.ts @@ -0,0 +1,37 @@ +import { Test } from '@nestjs/testing'; +import { GetApiRateLimitCostConfig } from './get-api-rate-limit-cost-config.usecase'; +import { ApiRateLimitCostEnum, ApiRateLimitCostEnvVarFormat, DEFAULT_API_RATE_LIMIT_COST_CONFIG } from '@novu/shared'; +import { expect } from 'chai'; + +describe('GetApiRateLimitCostConfig', () => { + let useCase: GetApiRateLimitCostConfig; + + beforeEach(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [GetApiRateLimitCostConfig], + }).compile(); + + useCase = moduleRef.get(GetApiRateLimitCostConfig); + }); + + it('should use the default rate limit cost configuration when no environment variables are set', () => { + expect(useCase.default).to.deep.equal(DEFAULT_API_RATE_LIMIT_COST_CONFIG); + }); + + it('should override default rate limit cost configuration with environment variables', () => { + const mockOverrideBulkCost = 15; + const mockApiRateLimitConfigurationKey = ApiRateLimitCostEnum.BULK; + + const envVarName: ApiRateLimitCostEnvVarFormat = `API_RATE_LIMIT_COST_${ + mockApiRateLimitConfigurationKey.toUpperCase() as Uppercase + }`; + process.env[envVarName] = `${mockOverrideBulkCost}`; + + // Re-initialize the defaultApiRateLimits after setting the environment variable + useCase.loadDefault(); + const result = useCase.default; + + expect(result[mockApiRateLimitConfigurationKey]).to.equal(mockOverrideBulkCost); + delete process.env[envVarName]; // cleanup + }); +}); diff --git a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-cost-config/get-api-rate-limit-cost-config.usecase.ts b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-cost-config/get-api-rate-limit-cost-config.usecase.ts new file mode 100644 index 00000000000..a3f525263a8 --- /dev/null +++ b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-cost-config/get-api-rate-limit-cost-config.usecase.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common'; +import { + ApiRateLimitCostEnum, + ApiRateLimitCostEnvVarFormat, + DEFAULT_API_RATE_LIMIT_COST_CONFIG, + IApiRateLimitCost, +} from '@novu/shared'; + +@Injectable() +export class GetApiRateLimitCostConfig { + public default: IApiRateLimitCost; + + constructor() { + this.loadDefault(); + } + + public loadDefault(): void { + this.default = this.createDefault(); + } + + private createDefault(): IApiRateLimitCost { + const mergedConfig: IApiRateLimitCost = { ...DEFAULT_API_RATE_LIMIT_COST_CONFIG }; + + // Read process environment only once for performance + const processEnv = process.env; + + Object.values(ApiRateLimitCostEnum).forEach((costOption) => { + const envVarName = this.getEnvVarName(costOption); + const envVarValue = processEnv[envVarName]; + + if (envVarValue) { + mergedConfig[costOption] = Number(envVarValue); + } + }); + + return mergedConfig; + } + + private getEnvVarName(costOption: ApiRateLimitCostEnum): ApiRateLimitCostEnvVarFormat { + return `API_RATE_LIMIT_COST_${costOption.toUpperCase() as Uppercase}`; + } +} diff --git a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-cost-config/index.ts b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-cost-config/index.ts new file mode 100644 index 00000000000..43a8fddce66 --- /dev/null +++ b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-cost-config/index.ts @@ -0,0 +1 @@ +export * from './get-api-rate-limit-cost-config.usecase'; diff --git a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/get-api-rate-limit-maximum.command.ts b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/get-api-rate-limit-maximum.command.ts new file mode 100644 index 00000000000..a16b36ef59e --- /dev/null +++ b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/get-api-rate-limit-maximum.command.ts @@ -0,0 +1,9 @@ +import { IsDefined, IsEnum } from 'class-validator'; +import { ApiRateLimitCategoryEnum } from '@novu/shared'; +import { EnvironmentCommand } from '../../../shared/commands/project.command'; + +export class GetApiRateLimitMaximumCommand extends EnvironmentCommand { + @IsDefined() + @IsEnum(ApiRateLimitCategoryEnum) + apiRateLimitCategory: ApiRateLimitCategoryEnum; +} diff --git a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit/get-api-rate-limit.spec.ts b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/get-api-rate-limit-maximum.spec.ts similarity index 73% rename from apps/api/src/app/rate-limiting/usecases/get-api-rate-limit/get-api-rate-limit.spec.ts rename to apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/get-api-rate-limit-maximum.spec.ts index e10048a064b..c2fb580dbdd 100644 --- a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit/get-api-rate-limit.spec.ts +++ b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/get-api-rate-limit-maximum.spec.ts @@ -1,34 +1,34 @@ import { EnvironmentRepository, OrganizationRepository } from '@novu/dal'; import { UserSession } from '@novu/testing'; -import { ApiRateLimitCategoryTypeEnum, ApiServiceLevelTypeEnum } from '@novu/shared'; +import { ApiRateLimitCategoryEnum, ApiServiceLevelEnum } from '@novu/shared'; import { expect } from 'chai'; import * as sinon from 'sinon'; import { Test } from '@nestjs/testing'; import { CacheService, MockCacheService } from '@novu/application-generic'; -import { GetApiRateLimit, GetApiRateLimitCommand } from './index'; +import { GetApiRateLimitMaximum, GetApiRateLimitMaximumCommand } from './index'; import { SharedModule } from '../../../shared/shared.module'; -import { GetDefaultApiRateLimits } from '../get-default-api-rate-limits'; +import { GetApiRateLimitServiceMaximumConfig } from '../get-api-rate-limit-service-maximum-config'; import { RateLimitingModule } from '../../rate-limiting.module'; const mockDefaultApiRateLimits = { - [ApiServiceLevelTypeEnum.FREE]: { - [ApiRateLimitCategoryTypeEnum.GLOBAL]: 60, - [ApiRateLimitCategoryTypeEnum.TRIGGER]: 60, - [ApiRateLimitCategoryTypeEnum.CONFIGURATION]: 60, + [ApiServiceLevelEnum.FREE]: { + [ApiRateLimitCategoryEnum.GLOBAL]: 60, + [ApiRateLimitCategoryEnum.TRIGGER]: 60, + [ApiRateLimitCategoryEnum.CONFIGURATION]: 60, }, - [ApiServiceLevelTypeEnum.UNLIMITED]: { - [ApiRateLimitCategoryTypeEnum.GLOBAL]: 600, - [ApiRateLimitCategoryTypeEnum.TRIGGER]: 600, - [ApiRateLimitCategoryTypeEnum.CONFIGURATION]: 600, + [ApiServiceLevelEnum.UNLIMITED]: { + [ApiRateLimitCategoryEnum.GLOBAL]: 600, + [ApiRateLimitCategoryEnum.TRIGGER]: 600, + [ApiRateLimitCategoryEnum.CONFIGURATION]: 600, }, }; -describe('GetApiRateLimit', async () => { - let useCase: GetApiRateLimit; +describe('GetApiRateLimitMaximum', async () => { + let useCase: GetApiRateLimitMaximum; let session: UserSession; let organizationRepository: OrganizationRepository; let environmentRepository: EnvironmentRepository; - let getDefaultApiRateLimits: GetDefaultApiRateLimits; + let getDefaultApiRateLimits: GetApiRateLimitServiceMaximumConfig; let findOneEnvironmentStub: sinon.SinonStub; let findOneOrganizationStub: sinon.SinonStub; @@ -46,16 +46,14 @@ describe('GetApiRateLimit', async () => { session = new UserSession(); await session.initialize(); - useCase = moduleRef.get(GetApiRateLimit); + useCase = moduleRef.get(GetApiRateLimitMaximum); organizationRepository = moduleRef.get(OrganizationRepository); environmentRepository = moduleRef.get(EnvironmentRepository); - getDefaultApiRateLimits = moduleRef.get(GetDefaultApiRateLimits); + getDefaultApiRateLimits = moduleRef.get(GetApiRateLimitServiceMaximumConfig); - findOneEnvironmentStub = sinon.stub(environmentRepository, 'findOne' as any); - findOneOrganizationStub = sinon.stub(organizationRepository, 'findOne' as any); - defaultApiRateLimits = sinon - .stub(getDefaultApiRateLimits, 'defaultApiRateLimits' as any) - .value(mockDefaultApiRateLimits); + findOneEnvironmentStub = sinon.stub(environmentRepository, 'findOne'); + findOneOrganizationStub = sinon.stub(organizationRepository, 'findOne'); + defaultApiRateLimits = sinon.stub(getDefaultApiRateLimits, 'default').value(mockDefaultApiRateLimits); }); afterEach(() => { @@ -68,10 +66,10 @@ describe('GetApiRateLimit', async () => { try { await useCase.execute( - GetApiRateLimitCommand.create({ + GetApiRateLimitMaximumCommand.create({ organizationId: session.organization._id, environmentId: session.environment._id, - apiRateLimitCategory: ApiRateLimitCategoryTypeEnum.GLOBAL, + apiRateLimitCategory: ApiRateLimitCategoryEnum.GLOBAL, }) ); throw new Error('Should not reach here'); @@ -82,7 +80,7 @@ describe('GetApiRateLimit', async () => { describe('Environment DOES have rate limits specified', () => { const mockGlobalLimit = 65; - const mockApiRateLimitCategory = ApiRateLimitCategoryTypeEnum.GLOBAL; + const mockApiRateLimitCategory = ApiRateLimitCategoryEnum.GLOBAL; beforeEach(() => { findOneEnvironmentStub.resolves({ @@ -94,7 +92,7 @@ describe('GetApiRateLimit', async () => { it('should return api rate limit for the category set on environment', async () => { const rateLimit = await useCase.execute( - GetApiRateLimitCommand.create({ + GetApiRateLimitMaximumCommand.create({ organizationId: session.organization._id, environmentId: session.environment._id, apiRateLimitCategory: mockApiRateLimitCategory, @@ -106,7 +104,7 @@ describe('GetApiRateLimit', async () => { }); describe('Environment DOES NOT have rate limits specified', () => { - const mockApiRateLimitCategory = ApiRateLimitCategoryTypeEnum.GLOBAL; + const mockApiRateLimitCategory = ApiRateLimitCategoryEnum.GLOBAL; beforeEach(() => { findOneEnvironmentStub.resolves({ @@ -115,14 +113,14 @@ describe('GetApiRateLimit', async () => { }); it('should return default api rate limit for the organizations apiServiceLevel when apiServiceLevel IS set on organization', async () => { - const mockApiServiceLevel = ApiServiceLevelTypeEnum.FREE; + const mockApiServiceLevel = ApiServiceLevelEnum.FREE; findOneOrganizationStub.resolves({ apiServiceLevel: mockApiServiceLevel, }); const defaultApiRateLimit = mockDefaultApiRateLimits[mockApiServiceLevel][mockApiRateLimitCategory]; const rateLimit = await useCase.execute( - GetApiRateLimitCommand.create({ + GetApiRateLimitMaximumCommand.create({ organizationId: session.organization._id, environmentId: session.environment._id, apiRateLimitCategory: mockApiRateLimitCategory, @@ -136,10 +134,10 @@ describe('GetApiRateLimit', async () => { findOneOrganizationStub.resolves({ apiServiceLevel: undefined, }); - const defaultApiRateLimit = mockDefaultApiRateLimits[ApiServiceLevelTypeEnum.UNLIMITED][mockApiRateLimitCategory]; + const defaultApiRateLimit = mockDefaultApiRateLimits[ApiServiceLevelEnum.UNLIMITED][mockApiRateLimitCategory]; const rateLimit = await useCase.execute( - GetApiRateLimitCommand.create({ + GetApiRateLimitMaximumCommand.create({ organizationId: session.organization._id, environmentId: session.environment._id, apiRateLimitCategory: mockApiRateLimitCategory, @@ -154,7 +152,7 @@ describe('GetApiRateLimit', async () => { try { await useCase.execute( - GetApiRateLimitCommand.create({ + GetApiRateLimitMaximumCommand.create({ organizationId: session.organization._id, environmentId: session.environment._id, apiRateLimitCategory: mockApiRateLimitCategory, diff --git a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit/get-api-rate-limit.usecase.ts b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/get-api-rate-limit-maximum.usecase.ts similarity index 62% rename from apps/api/src/app/rate-limiting/usecases/get-api-rate-limit/get-api-rate-limit.usecase.ts rename to apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/get-api-rate-limit-maximum.usecase.ts index 80c717dadc3..2d1fa007154 100644 --- a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit/get-api-rate-limit.usecase.ts +++ b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/get-api-rate-limit-maximum.usecase.ts @@ -1,21 +1,21 @@ import { Injectable, InternalServerErrorException, Logger } from '@nestjs/common'; import { EnvironmentRepository, OrganizationRepository } from '@novu/dal'; import { buildMaximumApiRateLimitKey, CachedEntity } from '@novu/application-generic'; -import { ApiRateLimitCategoryTypeEnum, ApiServiceLevelTypeEnum, IApiRateLimits } from '@novu/shared'; -import { GetApiRateLimitCommand } from './get-api-rate-limit.command'; -import { GetDefaultApiRateLimits } from '../get-default-api-rate-limits'; +import { ApiRateLimitCategoryEnum, ApiServiceLevelEnum, IApiRateLimitMaximum } from '@novu/shared'; +import { GetApiRateLimitMaximumCommand } from './get-api-rate-limit-maximum.command'; +import { GetApiRateLimitServiceMaximumConfig } from '../get-api-rate-limit-service-maximum-config'; const LOG_CONTEXT = 'GetApiRateLimit'; @Injectable() -export class GetApiRateLimit { +export class GetApiRateLimitMaximum { constructor( private environmentRepository: EnvironmentRepository, private organizationRepository: OrganizationRepository, - private getDefaultApiRateLimits: GetDefaultApiRateLimits + private getDefaultApiRateLimits: GetApiRateLimitServiceMaximumConfig ) {} - async execute(command: GetApiRateLimitCommand): Promise { + async execute(command: GetApiRateLimitMaximumCommand): Promise { return await this.getApiRateLimit({ apiRateLimitCategory: command.apiRateLimitCategory, _environmentId: command.environmentId, @@ -24,9 +24,9 @@ export class GetApiRateLimit { } @CachedEntity({ - builder: (command: GetApiRateLimitCommand) => + builder: (command: { apiRateLimitCategory: ApiRateLimitCategoryEnum; _environmentId: string }) => buildMaximumApiRateLimitKey({ - _environmentId: command.environmentId, + _environmentId: command._environmentId, apiRateLimitCategory: command.apiRateLimitCategory, }), }) @@ -35,7 +35,7 @@ export class GetApiRateLimit { _environmentId, _organizationId, }: { - apiRateLimitCategory: ApiRateLimitCategoryTypeEnum; + apiRateLimitCategory: ApiRateLimitCategoryEnum; _environmentId: string; _organizationId: string; }): Promise { @@ -47,11 +47,9 @@ export class GetApiRateLimit { throw new InternalServerErrorException(message); } - const { apiRateLimits } = environment; - - let environmentApiRateLimits: IApiRateLimits; - if (apiRateLimits) { - environmentApiRateLimits = apiRateLimits; + let apiRateLimits: IApiRateLimitMaximum; + if (environment.apiRateLimits) { + apiRateLimits = environment.apiRateLimits; } else { const organization = await this.organizationRepository.findOne({ _id: _organizationId }); @@ -62,14 +60,14 @@ export class GetApiRateLimit { } if (organization.apiServiceLevel) { - environmentApiRateLimits = this.getDefaultApiRateLimits.defaultApiRateLimits[organization.apiServiceLevel]; + apiRateLimits = this.getDefaultApiRateLimits.default[organization.apiServiceLevel]; } else { // TODO: NV-3067 - Remove this once all organizations have a service level - environmentApiRateLimits = this.getDefaultApiRateLimits.defaultApiRateLimits[ApiServiceLevelTypeEnum.UNLIMITED]; + apiRateLimits = this.getDefaultApiRateLimits.default[ApiServiceLevelEnum.UNLIMITED]; } } - const apiRateLimit = environmentApiRateLimits[apiRateLimitCategory]; + const apiRateLimit = apiRateLimits[apiRateLimitCategory]; return apiRateLimit; } diff --git a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/index.ts b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/index.ts new file mode 100644 index 00000000000..f71bcd4c7f9 --- /dev/null +++ b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-maximum/index.ts @@ -0,0 +1,2 @@ +export * from './get-api-rate-limit-maximum.command'; +export * from './get-api-rate-limit-maximum.usecase'; diff --git a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-service-maximum-config/get-api-rate-limit-service-maximum-config.spec.ts b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-service-maximum-config/get-api-rate-limit-service-maximum-config.spec.ts new file mode 100644 index 00000000000..7f6372c9b6d --- /dev/null +++ b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-service-maximum-config/get-api-rate-limit-service-maximum-config.spec.ts @@ -0,0 +1,87 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { GetApiRateLimitServiceMaximumConfig } from './get-api-rate-limit-service-maximum-config.usecase'; +import { + ApiRateLimitCategoryEnum, + ApiRateLimitServiceMaximumEnvVarFormat, + ApiServiceLevelEnum, + DEFAULT_API_RATE_LIMIT_SERVICE_MAXIMUM_CONFIG, +} from '@novu/shared'; +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { CacheService, InvalidateCacheService, cacheService as cacheServiceProvider } from '@novu/application-generic'; + +const mockRateLimitServiceLevel = ApiServiceLevelEnum.FREE; +const mockRateLimitCategory = ApiRateLimitCategoryEnum.GLOBAL; +const mockEnvVarName: ApiRateLimitServiceMaximumEnvVarFormat = `API_RATE_LIMIT_MAXIMUM_${ + mockRateLimitServiceLevel.toUpperCase() as Uppercase +}_${mockRateLimitCategory.toUpperCase() as Uppercase}`; +const mockOverrideRateLimit = 65; + +describe('GetApiRateLimitServiceMaximumConfig', () => { + let useCase: GetApiRateLimitServiceMaximumConfig; + let invalidateCacheService: InvalidateCacheService; + let cacheService: CacheService; + + let invalidateQueryStub: sinon.SinonStub; + let cacheServiceIsEnabledStub: sinon.SinonStub; + let moduleRef: TestingModule; + + beforeEach(async () => { + moduleRef = await Test.createTestingModule({ + providers: [cacheServiceProvider, InvalidateCacheService, GetApiRateLimitServiceMaximumConfig], + }).compile(); + + useCase = moduleRef.get(GetApiRateLimitServiceMaximumConfig); + invalidateCacheService = moduleRef.get(InvalidateCacheService); + cacheService = moduleRef.get(CacheService); + + invalidateQueryStub = sinon.stub(invalidateCacheService, 'invalidateQuery').resolves(); + cacheServiceIsEnabledStub = sinon.stub(cacheService, 'cacheEnabled').returns(true); + + await moduleRef.init(); + }); + + afterEach(() => { + invalidateQueryStub.reset(); + }); + + it('should load the default API rate limits on module init', () => { + expect(useCase.default).to.deep.equal(DEFAULT_API_RATE_LIMIT_SERVICE_MAXIMUM_CONFIG); + }); + + it('should override default API rate limits with environment variables', async () => { + process.env[mockEnvVarName] = `${mockOverrideRateLimit}`; + // Re-initialize the defaults after setting the environment variable + await useCase.loadDefault(); + delete process.env[mockEnvVarName]; // cleanup + + expect(useCase.default[mockRateLimitServiceLevel][mockRateLimitCategory]).to.equal(mockOverrideRateLimit); + }); + + it('should NOT invalidate the cache when loading defaults and the cache IS disabled', async () => { + cacheServiceIsEnabledStub.returns(false); + await useCase.loadDefault(); + + expect(invalidateQueryStub.callCount).to.equal(0); + }); + + it('should NOT invalidate the cache when loading defaults and the config HAS NOT changed between loads', async () => { + cacheServiceIsEnabledStub.returns(true); + await useCase.loadDefault(); + await useCase.loadDefault(); + + expect(invalidateQueryStub.callCount).to.equal(0); + }); + + it('should invalidate the cache when loading defaults and the config HAS changed between loads', async () => { + cacheServiceIsEnabledStub.returns(true); + await useCase.loadDefault(); + + process.env[mockEnvVarName] = `${mockOverrideRateLimit + 1}`; + // Re-initialize the defaults after setting the environment variable + await useCase.loadDefault(); + delete process.env[mockEnvVarName]; // cleanup + + expect(invalidateQueryStub.callCount).to.equal(1); + }); +}); diff --git a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-service-maximum-config/get-api-rate-limit-service-maximum-config.usecase.ts b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-service-maximum-config/get-api-rate-limit-service-maximum-config.usecase.ts new file mode 100644 index 00000000000..04c9c4baf1d --- /dev/null +++ b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-service-maximum-config/get-api-rate-limit-service-maximum-config.usecase.ts @@ -0,0 +1,86 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { + ApiRateLimitCategoryEnum, + ApiRateLimitServiceMaximumEnvVarFormat, + ApiServiceLevelEnum, + DEFAULT_API_RATE_LIMIT_SERVICE_MAXIMUM_CONFIG, + IApiRateLimitServiceMaximum, +} from '@novu/shared'; +import { createHash } from 'crypto'; +import { + buildMaximumApiRateLimitKey, + buildServiceConfigApiRateLimitMaximumKey, + CacheService, + InvalidateCacheService, +} from '@novu/application-generic'; + +@Injectable() +export class GetApiRateLimitServiceMaximumConfig implements OnModuleInit { + public default: IApiRateLimitServiceMaximum = DEFAULT_API_RATE_LIMIT_SERVICE_MAXIMUM_CONFIG; + + constructor(private invalidateCache: InvalidateCacheService, private cacheService: CacheService) {} + + async onModuleInit() { + await this.loadDefault(); + } + + public async loadDefault(): Promise { + const newDefault = this.createDefault(); + this.default = newDefault; + + if (!this.cacheService.cacheEnabled()) { + return; + } + + const cacheKey = buildServiceConfigApiRateLimitMaximumKey(); + const previousHash = await this.cacheService.get(cacheKey); + const newHash = this.getConfigHash(newDefault); + + if (previousHash !== newHash) { + await this.cacheService.set(cacheKey, newHash); + + this.invalidateCache.invalidateQuery({ + key: buildMaximumApiRateLimitKey({ + _environmentId: '*', + apiRateLimitCategory: '*', + }), + }); + } + } + + private getConfigHash(config: IApiRateLimitServiceMaximum): string { + const hash = createHash('sha256'); + hash.update(JSON.stringify(config)); + + return hash.digest('hex'); + } + + private createDefault(): IApiRateLimitServiceMaximum { + const mergedConfig: IApiRateLimitServiceMaximum = { ...DEFAULT_API_RATE_LIMIT_SERVICE_MAXIMUM_CONFIG }; + + // Read process environment only once for performance + const processEnv = process.env; + + Object.values(ApiServiceLevelEnum).forEach((apiServiceLevel) => { + Object.values(ApiRateLimitCategoryEnum).forEach((apiRateLimitCategory) => { + const envVarName = this.getEnvVarName(apiServiceLevel, apiRateLimitCategory); + const envVarValue = processEnv[envVarName]; + + if (envVarValue) { + mergedConfig[apiServiceLevel][apiRateLimitCategory] = Number(envVarValue); + } + }); + }); + + return mergedConfig; + } + + private getEnvVarName( + apiServiceLevel: ApiServiceLevelEnum, + apiRateLimitCategory: ApiRateLimitCategoryEnum + ): ApiRateLimitServiceMaximumEnvVarFormat { + return `API_RATE_LIMIT_MAXIMUM_${apiServiceLevel.toUpperCase() as Uppercase}_${ + apiRateLimitCategory.toUpperCase() as Uppercase + }`; + } +} diff --git a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-service-maximum-config/index.ts b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-service-maximum-config/index.ts new file mode 100644 index 00000000000..0a9a8b5431f --- /dev/null +++ b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit-service-maximum-config/index.ts @@ -0,0 +1 @@ +export * from './get-api-rate-limit-service-maximum-config.usecase'; diff --git a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit/get-api-rate-limit.command.ts b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit/get-api-rate-limit.command.ts deleted file mode 100644 index 36401a8cd4f..00000000000 --- a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit/get-api-rate-limit.command.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { IsDefined, IsEnum } from 'class-validator'; -import { ApiRateLimitCategoryTypeEnum } from '@novu/shared'; -import { EnvironmentCommand } from '../../../shared/commands/project.command'; - -export class GetApiRateLimitCommand extends EnvironmentCommand { - @IsDefined() - @IsEnum(ApiRateLimitCategoryTypeEnum) - apiRateLimitCategory: ApiRateLimitCategoryTypeEnum; -} diff --git a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit/index.ts b/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit/index.ts deleted file mode 100644 index 8f1e84fd2fb..00000000000 --- a/apps/api/src/app/rate-limiting/usecases/get-api-rate-limit/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './get-api-rate-limit.command'; -export * from './get-api-rate-limit.usecase'; diff --git a/apps/api/src/app/rate-limiting/usecases/get-default-api-rate-limits/get-default-api-rate-limits.spec.ts b/apps/api/src/app/rate-limiting/usecases/get-default-api-rate-limits/get-default-api-rate-limits.spec.ts deleted file mode 100644 index ee1e8b1335e..00000000000 --- a/apps/api/src/app/rate-limiting/usecases/get-default-api-rate-limits/get-default-api-rate-limits.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Test } from '@nestjs/testing'; -import { GetDefaultApiRateLimits } from './get-default-api-rate-limits.usecase'; -import { - ApiRateLimitCategoryTypeEnum, - ApiRateLimitEnvVarFormat, - ApiServiceLevelTypeEnum, - DEFAULT_API_RATE_LIMITS, -} from '@novu/shared'; -import { expect } from 'chai'; - -describe('GetDefaultApiRateLimits', () => { - let useCase: GetDefaultApiRateLimits; - - beforeEach(async () => { - const moduleRef = await Test.createTestingModule({ - providers: [GetDefaultApiRateLimits], - }).compile(); - - useCase = moduleRef.get(GetDefaultApiRateLimits); - }); - - it('should use the default API rate limits when no environment variables are set', () => { - expect(useCase.defaultApiRateLimits).to.deep.equal(DEFAULT_API_RATE_LIMITS); - }); - - it('should override default API rate limits with environment variables', () => { - const mockOverrideRateLimit = 65; - const mockRateLimitServiceLevel = ApiServiceLevelTypeEnum.FREE; - const mockRateLimitCategory = ApiRateLimitCategoryTypeEnum.GLOBAL; - - const envVarName: ApiRateLimitEnvVarFormat = `API_RATE_LIMIT_${ - ApiServiceLevelTypeEnum.FREE.toUpperCase() as Uppercase - }_${ApiRateLimitCategoryTypeEnum.GLOBAL.toUpperCase() as Uppercase}`; - process.env[envVarName] = mockOverrideRateLimit.toString(); - - // Re-initialize the defaultApiRateLimits after setting the environment variable - useCase.loadApiRateLimits(); - const result = useCase.defaultApiRateLimits; - - expect(result[mockRateLimitServiceLevel][mockRateLimitCategory]).to.equal(mockOverrideRateLimit); - delete process.env[envVarName]; // cleanup - }); -}); diff --git a/apps/api/src/app/rate-limiting/usecases/get-default-api-rate-limits/get-default-api-rate-limits.usecase.ts b/apps/api/src/app/rate-limiting/usecases/get-default-api-rate-limits/get-default-api-rate-limits.usecase.ts deleted file mode 100644 index 302534b36e0..00000000000 --- a/apps/api/src/app/rate-limiting/usecases/get-default-api-rate-limits/get-default-api-rate-limits.usecase.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { - ApiRateLimitCategoryTypeEnum, - ApiRateLimitEnvVarFormat, - ApiServiceLevelTypeEnum, - DEFAULT_API_RATE_LIMITS, - IServiceApiRateLimits, -} from '@novu/shared'; - -@Injectable() -export class GetDefaultApiRateLimits { - public defaultApiRateLimits: IServiceApiRateLimits; - - constructor() { - this.loadApiRateLimits(); - } - - public loadApiRateLimits(): void { - this.defaultApiRateLimits = this.createDefaultApiRateLimits(); - } - - private createDefaultApiRateLimits(): IServiceApiRateLimits { - const mergedApiRateLimits: IServiceApiRateLimits = { ...DEFAULT_API_RATE_LIMITS }; - - // Read process environment only once for performance - const processEnv = process.env; - - Object.values(ApiServiceLevelTypeEnum).forEach((apiServiceLevel) => { - Object.values(ApiRateLimitCategoryTypeEnum).forEach((apiRateLimitCategory) => { - const envVarName = this.getEnvVarName(apiServiceLevel, apiRateLimitCategory); - const envVarValue = processEnv[envVarName]; - - if (envVarValue) { - mergedApiRateLimits[apiServiceLevel][apiRateLimitCategory] = Number(envVarValue); - } - }); - }); - - return mergedApiRateLimits; - } - - private getEnvVarName( - apiServiceLevel: ApiServiceLevelTypeEnum, - apiRateLimitCategory: ApiRateLimitCategoryTypeEnum - ): ApiRateLimitEnvVarFormat { - return `API_RATE_LIMIT_${apiServiceLevel.toUpperCase() as Uppercase}_${ - apiRateLimitCategory.toUpperCase() as Uppercase - }`; - } -} diff --git a/apps/api/src/app/rate-limiting/usecases/get-default-api-rate-limits/index.ts b/apps/api/src/app/rate-limiting/usecases/get-default-api-rate-limits/index.ts deleted file mode 100644 index ade1af0d504..00000000000 --- a/apps/api/src/app/rate-limiting/usecases/get-default-api-rate-limits/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './get-default-api-rate-limits.usecase'; diff --git a/apps/api/src/app/rate-limiting/usecases/index.ts b/apps/api/src/app/rate-limiting/usecases/index.ts index 7362fd1adb5..7e98991017b 100644 --- a/apps/api/src/app/rate-limiting/usecases/index.ts +++ b/apps/api/src/app/rate-limiting/usecases/index.ts @@ -1,8 +1,16 @@ -import { GetApiRateLimit } from './get-api-rate-limit'; -import { GetDefaultApiRateLimits } from './get-default-api-rate-limits'; +import { GetApiRateLimitMaximum } from './get-api-rate-limit-maximum'; +import { GetApiRateLimitServiceMaximumConfig } from './get-api-rate-limit-service-maximum-config'; +import { EvaluateApiRateLimit } from './evaluate-api-rate-limit'; +import { GetApiRateLimitAlgorithmConfig } from './get-api-rate-limit-algorithm-config'; +import { GetApiRateLimitCostConfig } from './get-api-rate-limit-cost-config'; +import { EvaluateTokenBucketRateLimit } from './evaluate-token-bucket-rate-limit'; export const USE_CASES = [ // - GetDefaultApiRateLimits, - GetApiRateLimit, + GetApiRateLimitServiceMaximumConfig, + GetApiRateLimitMaximum, + GetApiRateLimitAlgorithmConfig, + GetApiRateLimitCostConfig, + EvaluateApiRateLimit, + EvaluateTokenBucketRateLimit, ]; diff --git a/libs/dal/src/repositories/environment/environment.entity.ts b/libs/dal/src/repositories/environment/environment.entity.ts index 50fce203ccc..76e35281381 100644 --- a/libs/dal/src/repositories/environment/environment.entity.ts +++ b/libs/dal/src/repositories/environment/environment.entity.ts @@ -2,7 +2,7 @@ import { Types } from 'mongoose'; import type { OrganizationId } from '../organization'; import type { ChangePropsValueType } from '../../types/helpers'; -import { IApiRateLimits } from '@novu/shared'; +import { IApiRateLimitMaximum } from '@novu/shared'; export interface IApiKey { key: string; @@ -29,7 +29,7 @@ export class EnvironmentEntity { apiKeys: IApiKey[]; - apiRateLimits?: IApiRateLimits; + apiRateLimits?: IApiRateLimitMaximum; widget: IWidgetSettings; diff --git a/libs/dal/src/repositories/environment/environment.schema.ts b/libs/dal/src/repositories/environment/environment.schema.ts index 99fdcd81e3a..9f7728308a7 100644 --- a/libs/dal/src/repositories/environment/environment.schema.ts +++ b/libs/dal/src/repositories/environment/environment.schema.ts @@ -1,6 +1,6 @@ import * as mongoose from 'mongoose'; import { Schema } from 'mongoose'; -import { IApiRateLimits, ApiRateLimitCategoryTypeEnum } from '@novu/shared'; +import { ApiRateLimitCategoryEnum } from '@novu/shared'; import { schemaOptions } from '../schema-default.options'; import { EnvironmentDBModel } from './environment.entity'; @@ -30,9 +30,9 @@ const environmentSchema = new Schema( }, ], apiRateLimits: { - [ApiRateLimitCategoryTypeEnum.TRIGGER]: Schema.Types.Number, - [ApiRateLimitCategoryTypeEnum.CONFIGURATION]: Schema.Types.Number, - [ApiRateLimitCategoryTypeEnum.GLOBAL]: Schema.Types.Number, + [ApiRateLimitCategoryEnum.TRIGGER]: Schema.Types.Number, + [ApiRateLimitCategoryEnum.CONFIGURATION]: Schema.Types.Number, + [ApiRateLimitCategoryEnum.GLOBAL]: Schema.Types.Number, }, widget: { notificationCenterEncryption: { diff --git a/libs/dal/src/repositories/organization/organization.entity.ts b/libs/dal/src/repositories/organization/organization.entity.ts index 2d0aeb80e4c..c0ca3dded50 100644 --- a/libs/dal/src/repositories/organization/organization.entity.ts +++ b/libs/dal/src/repositories/organization/organization.entity.ts @@ -1,4 +1,4 @@ -import { ApiServiceLevelTypeEnum } from '@novu/shared'; +import { ApiServiceLevelEnum } from '@novu/shared'; export class OrganizationEntity { _id: string; @@ -8,7 +8,7 @@ export class OrganizationEntity { logo?: string; // TODO: NV-3067 - Remove optional once all organizations have a service level - apiServiceLevel?: ApiServiceLevelTypeEnum; + apiServiceLevel?: ApiServiceLevelEnum; branding: { fontFamily?: string; diff --git a/libs/dal/src/repositories/organization/organization.schema.ts b/libs/dal/src/repositories/organization/organization.schema.ts index c389580c7cb..7f5292d3618 100644 --- a/libs/dal/src/repositories/organization/organization.schema.ts +++ b/libs/dal/src/repositories/organization/organization.schema.ts @@ -1,6 +1,6 @@ import * as mongoose from 'mongoose'; import { Schema } from 'mongoose'; -import { ApiServiceLevelTypeEnum } from '@novu/shared'; +import { ApiServiceLevelEnum } from '@novu/shared'; import { schemaOptions } from '../schema-default.options'; import { OrganizationDBModel, PartnerTypeEnum } from './organization.entity'; @@ -11,7 +11,7 @@ const organizationSchema = new Schema( logo: Schema.Types.String, apiServiceLevel: { type: Schema.Types.String, - enum: ApiServiceLevelTypeEnum, + enum: ApiServiceLevelEnum, }, branding: { fontColor: Schema.Types.String, diff --git a/libs/shared/src/consts/rate-limiting/apiRateLimits.ts b/libs/shared/src/consts/rate-limiting/apiRateLimits.ts index 56f62539bc9..2ceac23892d 100644 --- a/libs/shared/src/consts/rate-limiting/apiRateLimits.ts +++ b/libs/shared/src/consts/rate-limiting/apiRateLimits.ts @@ -1,23 +1,40 @@ -import { ApiServiceLevelTypeEnum, ApiRateLimitCategoryTypeEnum, IServiceApiRateLimits } from '../../types'; +import { + ApiRateLimitAlgorithmEnum, + ApiRateLimitCostEnum, + ApiServiceLevelEnum, + IApiRateLimitAlgorithm, + IApiRateLimitCost, +} from '../../types'; +import { ApiRateLimitCategoryEnum, IApiRateLimitServiceMaximum } from '../../types/rate-limiting/service.types'; /** * API Rate Limiting defaults applied to production environments. * Value units are requests per second. */ -export const DEFAULT_API_RATE_LIMITS: IServiceApiRateLimits = { - [ApiServiceLevelTypeEnum.FREE]: { - [ApiRateLimitCategoryTypeEnum.TRIGGER]: 60, - [ApiRateLimitCategoryTypeEnum.CONFIGURATION]: 15, - [ApiRateLimitCategoryTypeEnum.GLOBAL]: 30, +export const DEFAULT_API_RATE_LIMIT_SERVICE_MAXIMUM_CONFIG: IApiRateLimitServiceMaximum = { + [ApiServiceLevelEnum.FREE]: { + [ApiRateLimitCategoryEnum.TRIGGER]: 60, + [ApiRateLimitCategoryEnum.CONFIGURATION]: 15, + [ApiRateLimitCategoryEnum.GLOBAL]: 30, }, - [ApiServiceLevelTypeEnum.BUSINESS]: { - [ApiRateLimitCategoryTypeEnum.TRIGGER]: 600, - [ApiRateLimitCategoryTypeEnum.CONFIGURATION]: 150, - [ApiRateLimitCategoryTypeEnum.GLOBAL]: 300, + [ApiServiceLevelEnum.BUSINESS]: { + [ApiRateLimitCategoryEnum.TRIGGER]: 600, + [ApiRateLimitCategoryEnum.CONFIGURATION]: 150, + [ApiRateLimitCategoryEnum.GLOBAL]: 300, }, - [ApiServiceLevelTypeEnum.UNLIMITED]: { - [ApiRateLimitCategoryTypeEnum.TRIGGER]: 6000, - [ApiRateLimitCategoryTypeEnum.CONFIGURATION]: 1500, - [ApiRateLimitCategoryTypeEnum.GLOBAL]: 3000, + [ApiServiceLevelEnum.UNLIMITED]: { + [ApiRateLimitCategoryEnum.TRIGGER]: 6000, + [ApiRateLimitCategoryEnum.CONFIGURATION]: 1500, + [ApiRateLimitCategoryEnum.GLOBAL]: 3000, }, }; + +export const DEFAULT_API_RATE_LIMIT_ALGORITHM_CONFIG: IApiRateLimitAlgorithm = { + [ApiRateLimitAlgorithmEnum.BURST_ALLOWANCE]: 0.1, + [ApiRateLimitAlgorithmEnum.WINDOW_DURATION]: 1, +}; + +export const DEFAULT_API_RATE_LIMIT_COST_CONFIG: IApiRateLimitCost = { + [ApiRateLimitCostEnum.SINGLE]: 1, + [ApiRateLimitCostEnum.BULK]: 100, +}; diff --git a/libs/shared/src/entities/environment/environment.interface.ts b/libs/shared/src/entities/environment/environment.interface.ts index de2ec09c855..23579759224 100644 --- a/libs/shared/src/entities/environment/environment.interface.ts +++ b/libs/shared/src/entities/environment/environment.interface.ts @@ -1,4 +1,4 @@ -import { IApiRateLimits } from '../../types'; +import { IApiRateLimitMaximum } from '../../types'; export interface IEnvironment { _id?: string; @@ -8,7 +8,7 @@ export interface IEnvironment { identifier: string; widget: IWidgetSettings; dns?: IDnsSettings; - apiRateLimits?: IApiRateLimits; + apiRateLimits?: IApiRateLimitMaximum; branding?: { color: string; diff --git a/libs/shared/src/entities/organization/organization.interface.ts b/libs/shared/src/entities/organization/organization.interface.ts index 4a5e2c42242..735a38288ef 100644 --- a/libs/shared/src/entities/organization/organization.interface.ts +++ b/libs/shared/src/entities/organization/organization.interface.ts @@ -1,4 +1,4 @@ -import { ApiServiceLevelTypeEnum } from '../../types'; +import { ApiServiceLevelEnum } from '../../types'; import { IUserEntity } from '../user'; import { MemberRoleEnum } from './member.enum'; import { IMemberInvite, MemberStatusEnum } from './member.interface'; @@ -6,7 +6,7 @@ import { IMemberInvite, MemberStatusEnum } from './member.interface'; export interface IOrganizationEntity { _id: string; name: string; - apiServiceLevel?: ApiServiceLevelTypeEnum; + apiServiceLevel?: ApiServiceLevelEnum; members: { _id: string; _userId?: string; diff --git a/libs/shared/src/types/environment/index.ts b/libs/shared/src/types/environment/index.ts index 680881c4256..af1461c4811 100644 --- a/libs/shared/src/types/environment/index.ts +++ b/libs/shared/src/types/environment/index.ts @@ -1,9 +1 @@ export type EnvironmentId = string; - -export enum ApiRateLimitCategoryTypeEnum { - TRIGGER = 'trigger', - CONFIGURATION = 'configuration', - GLOBAL = 'global', -} - -export type IApiRateLimits = Record; diff --git a/libs/shared/src/types/organization/index.ts b/libs/shared/src/types/organization/index.ts index bbcde6190fb..9dbbd2889bf 100644 --- a/libs/shared/src/types/organization/index.ts +++ b/libs/shared/src/types/organization/index.ts @@ -1,6 +1,6 @@ export type OrganizationId = string; -export enum ApiServiceLevelTypeEnum { +export enum ApiServiceLevelEnum { FREE = 'free', BUSINESS = 'business', // TODO: NV-3067 - Remove unlimited tier once all organizations have a service level diff --git a/libs/shared/src/types/rate-limiting/algorithm.types.ts b/libs/shared/src/types/rate-limiting/algorithm.types.ts new file mode 100644 index 00000000000..1e007cf60a0 --- /dev/null +++ b/libs/shared/src/types/rate-limiting/algorithm.types.ts @@ -0,0 +1,30 @@ +import { ApiRateLimitConfigEnum, ApiRateLimitEnvVarNamespace } from './config.types'; + +export enum ApiRateLimitAlgorithmEnum { + BURST_ALLOWANCE = 'burst_allowance', + WINDOW_DURATION = 'window_duration', +} + +/** + * The configuration options for the rate limit algorithm. + */ +export class IApiRateLimitAlgorithm implements Record { + /** + * A decimal x >= 0 determining the proportion of base requests that are allowed in excess of the rate limit. + * + * For example an `x` of 0.1 would allow 10% of the base requests to exceed the rate limit. + */ + [ApiRateLimitAlgorithmEnum.BURST_ALLOWANCE]: number; + /** + * A number x >= 1 in seconds at which the rate limit allowance is refilled. + * + * For example a `windowDuration` of 1 would refill the rate limit allowance every second. + */ + [ApiRateLimitAlgorithmEnum.WINDOW_DURATION]: number; +} + +/** + * The format of the environment variables used to configure the rate limit algorithm. + */ +export type ApiRateLimitAlgorithmEnvVarFormat = + Uppercase<`${ApiRateLimitEnvVarNamespace}_${ApiRateLimitConfigEnum.ALGORITHM}_${ApiRateLimitAlgorithmEnum}`>; diff --git a/libs/shared/src/types/rate-limiting/config.types.ts b/libs/shared/src/types/rate-limiting/config.types.ts new file mode 100644 index 00000000000..689d1c3c45a --- /dev/null +++ b/libs/shared/src/types/rate-limiting/config.types.ts @@ -0,0 +1,13 @@ +/** + * The namespace for the environment variables used to configure rate limiting. + */ +export type ApiRateLimitEnvVarNamespace = 'API_RATE_LIMIT'; + +/** + * The configuration options for rate limiting. + */ +export enum ApiRateLimitConfigEnum { + ALGORITHM = 'algorithm', + COST = 'cost', + MAXIMUM = 'maximum', +} diff --git a/libs/shared/src/types/rate-limiting/cost.types.ts b/libs/shared/src/types/rate-limiting/cost.types.ts new file mode 100644 index 00000000000..569c8f17c88 --- /dev/null +++ b/libs/shared/src/types/rate-limiting/cost.types.ts @@ -0,0 +1,19 @@ +import { ApiRateLimitConfigEnum, ApiRateLimitEnvVarNamespace } from './config.types'; + +export enum ApiRateLimitCostEnum { + SINGLE = 'single', + BULK = 'bulk', +} + +/** + * A map of numbers x >= 1 determining the cost of a request. + * + * For example a `bulk` cost of 100 would count as 100 requests against the rate limit. + */ +export type IApiRateLimitCost = Record; + +/** + * The format of the environment variables used to configure the cost of a request. + */ +export type ApiRateLimitCostEnvVarFormat = + Uppercase<`${ApiRateLimitEnvVarNamespace}_${ApiRateLimitConfigEnum.COST}_${ApiRateLimitCostEnum}`>; diff --git a/libs/shared/src/types/rate-limiting/env.types.ts b/libs/shared/src/types/rate-limiting/env.types.ts new file mode 100644 index 00000000000..2b05fdaf0e7 --- /dev/null +++ b/libs/shared/src/types/rate-limiting/env.types.ts @@ -0,0 +1,11 @@ +import { ApiRateLimitServiceMaximumEnvVarFormat } from './service.types'; +import { ApiRateLimitAlgorithmEnvVarFormat } from './algorithm.types'; +import { ApiRateLimitCostEnvVarFormat } from './cost.types'; + +/** + * The format of all environment variables used to configure rate limiting. + */ +export type ApiRateLimitEnvVarFormat = + | ApiRateLimitCostEnvVarFormat + | ApiRateLimitAlgorithmEnvVarFormat + | ApiRateLimitServiceMaximumEnvVarFormat; diff --git a/libs/shared/src/types/rate-limiting/index.ts b/libs/shared/src/types/rate-limiting/index.ts index 06cf9f9cc1d..223f1119035 100644 --- a/libs/shared/src/types/rate-limiting/index.ts +++ b/libs/shared/src/types/rate-limiting/index.ts @@ -1,9 +1,5 @@ -import { ApiRateLimitCategoryTypeEnum, IApiRateLimits } from '../environment'; -import { ApiServiceLevelTypeEnum } from '../organization'; - -export type IServiceApiRateLimits = Record; - -type ApiRateLimitNamespace = 'API_RATE_LIMIT'; - -export type ApiRateLimitEnvVarFormat = - Uppercase<`${ApiRateLimitNamespace}_${ApiServiceLevelTypeEnum}_${ApiRateLimitCategoryTypeEnum}`>; +export * from './algorithm.types'; +export * from './config.types'; +export * from './cost.types'; +export * from './env.types'; +export * from './service.types'; diff --git a/libs/shared/src/types/rate-limiting/service.types.ts b/libs/shared/src/types/rate-limiting/service.types.ts new file mode 100644 index 00000000000..fc6e1e59e49 --- /dev/null +++ b/libs/shared/src/types/rate-limiting/service.types.ts @@ -0,0 +1,27 @@ +import { ApiServiceLevelEnum } from '../organization'; +import { ApiRateLimitConfigEnum, ApiRateLimitEnvVarNamespace } from './config.types'; + +/** + * The categories of rate limits. + */ +export enum ApiRateLimitCategoryEnum { + TRIGGER = 'trigger', + CONFIGURATION = 'configuration', + GLOBAL = 'global', +} + +/** + * A map of numbers x >= 1 determining the maximum number of requests allowed per category. + */ +export type IApiRateLimitMaximum = Record; + +/** + * A map of of the API Service level to the maximum number of requests allowed per category. + */ +export type IApiRateLimitServiceMaximum = Record; + +/** + * The format of the environment variables used to configure maximum number of requests allowed per category. + */ +export type ApiRateLimitServiceMaximumEnvVarFormat = + Uppercase<`${ApiRateLimitEnvVarNamespace}_${ApiRateLimitConfigEnum.MAXIMUM}_${ApiServiceLevelEnum}_${ApiRateLimitCategoryEnum}`>; diff --git a/packages/application-generic/src/services/cache/cache-service.mock.ts b/packages/application-generic/src/services/cache/cache-service.mock.ts index ff93ee8a515..fae100c7e03 100644 --- a/packages/application-generic/src/services/cache/cache-service.mock.ts +++ b/packages/application-generic/src/services/cache/cache-service.mock.ts @@ -1,8 +1,9 @@ +import { Redis } from 'ioredis'; import { CachingConfig, ICacheService } from './cache.service'; // eslint-disable-next-line @typescript-eslint/naming-convention export const MockCacheService = { - createClient(): ICacheService { + createClient(mockClient?: Partial): ICacheService { const data = {}; return { @@ -41,6 +42,34 @@ export const MockCacheService = { cacheEnabled() { return true; }, + async sadd(key, ...members) { + const dataVal = data[key]; + if (dataVal && !Array.isArray(dataVal)) { + throw new Error( + 'Wrong operation against a key holding the wrong kind of value' + ); + } + + const newVal = new Set(data[key]); + + let addCount = 0; + members.forEach((member) => { + if (!newVal.has(member)) { + newVal.add(member); + addCount++; + } + }); + data[key] = Array.from(newVal); + + return addCount; + }, + async eval( + script: string, + keys: string[], + args: (string | Buffer | number)[] + ): Promise { + return mockClient.eval(script, keys.length, ...keys, ...args) as TData; + }, }; }, }; diff --git a/packages/application-generic/src/services/cache/cache-service.spec.ts b/packages/application-generic/src/services/cache/cache-service.spec.ts index fdd6405badd..c1f3ff679de 100644 --- a/packages/application-generic/src/services/cache/cache-service.spec.ts +++ b/packages/application-generic/src/services/cache/cache-service.spec.ts @@ -4,6 +4,7 @@ import { ICacheService, splitKey, } from './cache.service'; +import * as sinon from 'sinon'; import { CacheInMemoryProviderService } from '../in-memory-provider'; import { MockCacheService } from './cache-service.mock'; @@ -173,6 +174,31 @@ describe('cache-service', function () { expect(res3).toEqual(undefined); }); + it('should invoke the SADD method correctly', async function () { + const key = '123:456'; + const data = [1, 2, 3]; + const res = await cacheService.sadd(key, ...data); + const res2 = await cacheService.sadd(key, ...data); + + expect(res).toEqual(3); + expect(res2).toEqual(0); + }); + + it('should invoke the EVAL function correctly', async function () { + const dataString = JSON.stringify({ array: [1, 2, 3] }); + const evalMock = sinon.mock().resolves(dataString); + cacheService = MockCacheService.createClient({ eval: evalMock }); + + const script = 'return redis.call("get", KEYS[1])'; + const key = '123:456'; + const args = ['arg1', 'arg2']; + cacheService.set(key, dataString); + const res = await cacheService.eval(script, [key], args); + + expect(res).toEqual(dataString); + expect(evalMock.calledWith(script, 1, key, 'arg1', 'arg2')).toEqual(true); + }); + describe('splitKey', () => { it('should split the key into credentials and query parts', () => { const key = diff --git a/packages/application-generic/src/services/cache/cache.service.ts b/packages/application-generic/src/services/cache/cache.service.ts index 2a2829a4659..9fee2d0e8ae 100644 --- a/packages/application-generic/src/services/cache/cache.service.ts +++ b/packages/application-generic/src/services/cache/cache.service.ts @@ -23,6 +23,12 @@ export interface ICacheService { keys(pattern?: string); getStatus(); cacheEnabled(); + sadd(key: string, ...members: (string | number | Buffer)[]): Promise; + eval( + script: string, + keys: string[], + args: (string | number | Buffer)[] + ): Promise; } export type CachingConfig = { @@ -219,6 +225,26 @@ export class CacheService implements ICacheService { return addJitter(seconds, this.TTL_VARIANT_PERCENTAGE); } + + public async sadd( + key: string, + ...members: (string | number | Buffer)[] + ): Promise { + return this.client?.sadd(key, ...members); + } + + public async eval( + script: string, + keys: string[], + args: (string | number | Buffer)[] + ): Promise { + return this.client?.eval( + script, + keys.length, + ...keys, + ...args + ) as Promise; + } } export function splitKey(key: string) { diff --git a/packages/application-generic/src/services/cache/key-builders/entities.ts b/packages/application-generic/src/services/cache/key-builders/entities.ts index e11e8428461..0521d2cd2b3 100644 --- a/packages/application-generic/src/services/cache/key-builders/entities.ts +++ b/packages/application-generic/src/services/cache/key-builders/entities.ts @@ -1,11 +1,13 @@ import { BLUEPRINT_IDENTIFIER, buildCommonKey, + buildKeyById, CacheKeyPrefixEnum, CacheKeyTypeEnum, IdentifierPrefixEnum, OrgScopePrefixEnum, prefixWrapper, + ServiceConfigIdentifierEnum, } from './shared'; const buildSubscriberKey = ({ @@ -68,19 +70,6 @@ const buildEnvironmentByApiKey = ({ apiKey }: { apiKey: string }): string => identifierPrefix: IdentifierPrefixEnum.API_KEY, }); -const buildKeyById = ({ - type, - keyEntity, - identifierPrefix = IdentifierPrefixEnum.ID, - identifier, -}: { - type: CacheKeyTypeEnum; - keyEntity: CacheKeyPrefixEnum; - identifierPrefix?: IdentifierPrefixEnum; - identifier: string; -}): string => - prefixWrapper(`${type}:${keyEntity}:${identifierPrefix}=${identifier}`); - const buildGroupedBlueprintsKey = (): string => buildCommonKey({ type: CacheKeyTypeEnum.ENTITY, @@ -114,6 +103,36 @@ const buildMaximumApiRateLimitKey = ({ identifier: apiRateLimitCategory, }); +const buildEvaluateApiRateLimitKey = ({ + apiRateLimitCategory, + _environmentId, +}: { + apiRateLimitCategory: string; + _environmentId: string; +}): string => + buildCommonKey({ + type: CacheKeyTypeEnum.ENTITY, + keyEntity: CacheKeyPrefixEnum.EVALUATE_API_RATE_LIMIT, + environmentId: _environmentId, + identifierPrefix: IdentifierPrefixEnum.API_RATE_LIMIT_CATEGORY, + identifier: apiRateLimitCategory, + }); + +const buildServiceConfigKey = ( + identifier: ServiceConfigIdentifierEnum +): string => + buildKeyById({ + type: CacheKeyTypeEnum.ENTITY, + keyEntity: CacheKeyPrefixEnum.SERVICE_CONFIG, + identifierPrefix: IdentifierPrefixEnum.SERVICE_CONFIG, + identifier, + }); + +const buildServiceConfigApiRateLimitMaximumKey = (): string => + buildServiceConfigKey( + ServiceConfigIdentifierEnum.API_RATE_LIMIT_SERVICE_MAXIMUM + ); + export { buildUserKey, buildSubscriberKey, @@ -124,4 +143,6 @@ export { buildGroupedBlueprintsKey, buildAuthServiceKey, buildMaximumApiRateLimitKey, + buildEvaluateApiRateLimitKey, + buildServiceConfigApiRateLimitMaximumKey, }; diff --git a/packages/application-generic/src/services/cache/key-builders/shared.ts b/packages/application-generic/src/services/cache/key-builders/shared.ts index a176c99836d..8e8f34d1f9a 100644 --- a/packages/application-generic/src/services/cache/key-builders/shared.ts +++ b/packages/application-generic/src/services/cache/key-builders/shared.ts @@ -1,3 +1,6 @@ +/** + * Use this to build a key for entities that are scoped to an environment + */ export const buildCommonKey = ({ type, keyEntity, @@ -17,6 +20,22 @@ export const buildCommonKey = ({ `${type}:${keyEntity}:${environmentIdPrefix}=${environmentId}:${identifierPrefix}=${identifier}` ); +/** + * Use this to build a key for entities that are unscoped (do not belong to a hierarchy) + */ +export const buildKeyById = ({ + type, + keyEntity, + identifierPrefix = IdentifierPrefixEnum.ID, + identifier, +}: { + type: CacheKeyTypeEnum; + keyEntity: CacheKeyPrefixEnum; + identifierPrefix?: IdentifierPrefixEnum; + identifier: string; +}): string => + prefixWrapper(`${type}:${keyEntity}:${identifierPrefix}=${identifier}`); + export function prefixWrapper(prefixString: string) { return `{${prefixString}}`; } @@ -34,6 +53,8 @@ export enum CacheKeyPrefixEnum { GROUPED_BLUEPRINTS = 'grouped-blueprints', AUTH_SERVICE = 'auth_service', MAXIMUM_API_RATE_LIMIT = 'maximum_api_rate_limit', + EVALUATE_API_RATE_LIMIT = 'evaluate_api_rate_limit', + SERVICE_CONFIG = 'service_config', } export enum CacheKeyTypeEnum { @@ -48,6 +69,11 @@ export enum IdentifierPrefixEnum { API_KEY = 'a_k', GROUPED_BLUEPRINT = 'g_b', API_RATE_LIMIT_CATEGORY = 'a_r_l_c', + SERVICE_CONFIG = 's_c', +} + +export enum ServiceConfigIdentifierEnum { + API_RATE_LIMIT_SERVICE_MAXIMUM = 'api_rate_limit_service_maximum', } export enum OrgScopePrefixEnum { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aa17cd7cdae..49b4e656445 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -309,6 +309,9 @@ importers: '@types/newrelic': specifier: ^9.14.0 version: 9.14.0 + '@upstash/ratelimit': + specifier: ^0.4.4 + version: 0.4.4 axios: specifier: ^1.3.3 version: 1.3.5 @@ -24919,6 +24922,25 @@ packages: resolution: {integrity: sha512-bYuSNomfn4hu2tPiDN+JZtnzCpSpbJ/PNeulmocDy3xN2X5OkJL65zo6rPZp65cPPhLF9vfT/dgE+RtFRCSxOA==} dev: false + /@upstash/core-analytics@0.0.6: + resolution: {integrity: sha512-cpPSR0XJAJs4Ddz9nq3tINlPS5aLfWVCqhhtHnXt4p7qr5+/Znlt1Es736poB/9rnl1hAHrOsOvVj46NEXcVqA==} + engines: {node: '>=16.0.0'} + dependencies: + '@upstash/redis': 1.25.1 + dev: false + + /@upstash/ratelimit@0.4.4: + resolution: {integrity: sha512-y3q6cNDdcRQ2MRPRf5UNWBN36IwnZ4kAEkGoH3i6OqdWwz4qlBxNsw4/Rpqn9h93+Nx1cqg5IOq7O2e2zMJY1w==} + dependencies: + '@upstash/core-analytics': 0.0.6 + dev: false + + /@upstash/redis@1.25.1: + resolution: {integrity: sha512-ACj0GhJ4qrQyBshwFgPod6XufVEfKX2wcaihsEvSdLYnY+m+pa13kGt1RXm/yTHKf4TQi/Dy2A8z/y6WUEOmlg==} + dependencies: + crypto-js: 4.2.0 + dev: false + /@vitejs/plugin-basic-ssl@1.0.1(vite@4.4.7): resolution: {integrity: sha512-pcub+YbFtFhaGRTo1832FQHQSHvMrlb43974e2eS8EKleR3p1cDdkJFPci1UhwkEf1J9Bz+wKBSzqpKp7nNj2A==} engines: {node: '>=14.6.0'} @@ -29088,6 +29110,10 @@ packages: resolution: {integrity: sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow==} dev: false + /crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + dev: false + /crypto-random-string@2.0.0: resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} engines: {node: '>=8'}