diff --git a/packages/cli/src/metadataGeneration/initializer-value.ts b/packages/cli/src/metadataGeneration/initializer-value.ts index 0ea9d898b..a556ef332 100644 --- a/packages/cli/src/metadataGeneration/initializer-value.ts +++ b/packages/cli/src/metadataGeneration/initializer-value.ts @@ -4,6 +4,16 @@ import { Tsoa } from '@namecheap/tsoa-runtime'; const hasInitializer = (node: ts.Node): node is ts.HasInitializer => Object.prototype.hasOwnProperty.call(node, 'initializer'); const extractInitializer = (decl?: ts.Declaration) => (decl && hasInitializer(decl) && (decl.initializer as ts.Expression)) || undefined; const extractImportSpecifier = (symbol?: ts.Symbol) => (symbol?.declarations && symbol.declarations.length > 0 && ts.isImportSpecifier(symbol.declarations[0]) && symbol.declarations[0]) || undefined; +const getImportSpecifierValue = (importSpecifier: ts.ImportSpecifier, typeChecker: ts.TypeChecker) => { + const importSymbol = typeChecker.getSymbolAtLocation(importSpecifier.name); + if (!importSymbol) { + return; + } + const aliasedSymbol = typeChecker.getAliasedSymbol(importSymbol); + const declarations = aliasedSymbol.getDeclarations(); + const declaration = declarations && declarations.length > 0 ? declarations[0] : undefined; + return getInitializerValue(extractInitializer(declaration), typeChecker); +}; export const getInitializerValue = (initializer?: ts.Expression | ts.ImportSpecifier, typeChecker?: ts.TypeChecker, type?: Tsoa.Type) => { if (!initializer || !typeChecker) { @@ -11,6 +21,24 @@ export const getInitializerValue = (initializer?: ts.Expression | ts.ImportSpeci } switch (initializer.kind) { + case ts.SyntaxKind.AsExpression: + return getInitializerValue((initializer as ts.AsExpression).expression, typeChecker, type); + case ts.SyntaxKind.BinaryExpression: { + const be = initializer as ts.BinaryExpression; + const leftVal = getInitializerValue(be.left as any, typeChecker, type); + const rightVal = getInitializerValue(be.right as any, typeChecker, type); + if (typeof leftVal === 'number' && typeof rightVal === 'number') { + switch (be.operatorToken.kind) { + case ts.SyntaxKind.AsteriskToken: + return leftVal * rightVal; + case ts.SyntaxKind.SlashToken: + return rightVal !== 0 ? leftVal / rightVal : undefined; + default: + return undefined; + } + } + return undefined; + } case ts.SyntaxKind.ArrayLiteralExpression: { const arrayLiteral = initializer as ts.ArrayLiteralExpression; return arrayLiteral.elements.map(element => getInitializerValue(element, typeChecker)); @@ -56,17 +84,24 @@ export const getInitializerValue = (initializer?: ts.Expression | ts.ImportSpeci return nestedObject; } case ts.SyntaxKind.ImportSpecifier: { - const importSpecifier = initializer as ts.ImportSpecifier; - const importSymbol = typeChecker.getSymbolAtLocation(importSpecifier.name); - if (!importSymbol) return; - const aliasedSymbol = typeChecker.getAliasedSymbol(importSymbol); - const declarations = aliasedSymbol.getDeclarations(); - const declaration = declarations && declarations.length > 0 ? declarations[0] : undefined; - return getInitializerValue(extractInitializer(declaration), typeChecker); + return getImportSpecifierValue(initializer as ts.ImportSpecifier, typeChecker); } default: { + // Attempt to resolve the value for any non-special expression by looking up its symbol. const symbol = typeChecker.getSymbolAtLocation(initializer); - return getInitializerValue(extractInitializer(symbol?.valueDeclaration) || extractImportSpecifier(symbol), typeChecker); + const directInit = extractInitializer(symbol?.valueDeclaration); + if (directInit) { + return getInitializerValue(directInit, typeChecker); + } + + // If it's a named import (e.g., import { cfg } from './x'; @Dec(cfg)), + // follow the alias to the exported declaration and evaluate its initializer. + const importSpecifier = extractImportSpecifier(symbol); + if (importSpecifier) { + return getImportSpecifierValue(importSpecifier, typeChecker); + } + + return; } } }; diff --git a/tests/fixtures/controllers/rateLimitConfigs.ts b/tests/fixtures/controllers/rateLimitConfigs.ts new file mode 100644 index 000000000..ae07d6a79 --- /dev/null +++ b/tests/fixtures/controllers/rateLimitConfigs.ts @@ -0,0 +1,5 @@ +export const userIpRateLimitConfigExpr = { + limit: 100, + slidingWindowSec: 15 * 60, + description: 'expr config', +}; diff --git a/tests/fixtures/controllers/rateLimitController.ts b/tests/fixtures/controllers/rateLimitController.ts index fd187acfc..2c5b38f12 100644 --- a/tests/fixtures/controllers/rateLimitController.ts +++ b/tests/fixtures/controllers/rateLimitController.ts @@ -1,5 +1,6 @@ import { Route, Get, Controller } from '@namecheap/tsoa-runtime'; -import { RateLimitByUserId } from './rateLimitDecorator'; +import { RateLimitByUserId, RateLimitByUserIp } from './rateLimitDecorator'; +import { userIpRateLimitConfigExpr } from './rateLimitConfigs'; @Route('rateLimit') export class RateLimitController extends Controller { @@ -8,4 +9,10 @@ export class RateLimitController extends Controller { public async getRateLimitedResource(): Promise { return 'This is a rate-limited resource'; } + + @Get('expr') + @RateLimitByUserIp(userIpRateLimitConfigExpr) + public async getByExpr(): Promise { + return 'OK'; + } } diff --git a/tests/fixtures/controllers/rateLimitDecorator.ts b/tests/fixtures/controllers/rateLimitDecorator.ts index 2f0626693..7fcfb14ff 100644 --- a/tests/fixtures/controllers/rateLimitDecorator.ts +++ b/tests/fixtures/controllers/rateLimitDecorator.ts @@ -38,3 +38,40 @@ export const RateLimitByUserIdProcessor: NodeDecoratorProcessor = (context: Deco }, }); }; + +export interface UserIpRateLimitConfig { + limit: number; + slidingWindowSec: number; + description?: string; +} + +export function RateLimitByUserIp(config: UserIpRateLimitConfig): MethodDecorator { + return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { + const metadataKey = Symbol('tsoa:rate-limit-by-user-ip'); + Reflect.defineMetadata(metadataKey, config, descriptor.value); + + return descriptor; + }; +} + +export const RateLimitByUserIpProcessor: NodeDecoratorProcessor = (context: DecoratorProcessorContext) => { + const { methodObject, decoratorArguments } = context; + const arg = (decoratorArguments && decoratorArguments[0]) as UserIpRateLimitConfig | undefined; + + const limit = arg?.limit ?? 20; + const slidingWindowSec = arg?.slidingWindowSec ?? 60; + const description = arg?.description ?? `${limit} requests per ${slidingWindowSec} seconds per IP`; + + if (!methodObject.extensions) { + methodObject.extensions = []; + } + + methodObject.extensions.push({ + key: 'x-rate-limit-by-user-ip', + value: { + limit, + slidingWindowSec, + description, + }, + }); +}; diff --git a/tests/integration/rate-limit-decorator.spec.ts b/tests/integration/rate-limit-decorator.spec.ts index cec32d365..1af330649 100644 --- a/tests/integration/rate-limit-decorator.spec.ts +++ b/tests/integration/rate-limit-decorator.spec.ts @@ -3,7 +3,7 @@ import { expect } from 'chai'; import { MetadataGenerator, MetadataGeneratorOptions } from '../../packages/cli/src/metadataGeneration/metadataGenerator'; import { SpecGenerator3 } from '../../packages/cli/src/swagger/specGenerator3'; import * as path from 'path'; -import { RateLimitByUserIdProcessor } from '../fixtures/controllers/rateLimitDecorator'; +import { RateLimitByUserIdProcessor, RateLimitByUserIpProcessor } from '../fixtures/controllers/rateLimitDecorator'; describe('Rate Limit Decorator Integration Test', () => { it('should generate OpenAPI spec with rate limit extension using decorator processor', () => { @@ -35,4 +35,38 @@ describe('Rate Limit Decorator Integration Test', () => { expect(testOperation['x-rate-limit-by-user-id'].limit).to.equal(100); expect(testOperation['x-rate-limit-by-user-id'].timeWindow).to.equal(60); }); + + it('should resolve imported consts as decorator arguments', () => { + const entry = path.join(__dirname, '../fixtures/controllers/rateLimitController.ts'); + const metadata = new MetadataGenerator(entry, {}, [], undefined, { + customDecoratorProcessors: { + RateLimitByUserIp: RateLimitByUserIpProcessor, + }, + } as MetadataGeneratorOptions); + + const generatedMetadata = metadata.Generate(); + + const spec = new SpecGenerator3(generatedMetadata, { + name: 'Rate Limit IP Test API', + description: 'Test API with IP-based rate limiting', + version: '1.0.0', + outputDirectory: 'temp', + specVersion: '3.0.0' as any, + entryFile: '', + controllerPathGlobs: [], + noImplicitAdditionalProperties: 'ignore', + }).GetSpec(); + + const paths = spec.paths as any; + expect(paths['/rateLimit/expr']).to.exist; + expect(paths['/rateLimit/expr'].get).to.exist; + + const opExpr = paths['/rateLimit/expr'].get; + + expect(opExpr['x-rate-limit-by-user-ip']).to.deep.equal({ + limit: 100, + slidingWindowSec: 900, + description: 'expr config', + }); + }); });