Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 43 additions & 8 deletions packages/cli/src/metadataGeneration/initializer-value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,41 @@ 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) {
return;
}

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));
Expand Down Expand Up @@ -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;
}
}
};
5 changes: 5 additions & 0 deletions tests/fixtures/controllers/rateLimitConfigs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const userIpRateLimitConfigExpr = {
limit: 100,
slidingWindowSec: 15 * 60,
description: 'expr config',
};
9 changes: 8 additions & 1 deletion tests/fixtures/controllers/rateLimitController.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -8,4 +9,10 @@ export class RateLimitController extends Controller {
public async getRateLimitedResource(): Promise<string> {
return 'This is a rate-limited resource';
}

@Get('expr')
@RateLimitByUserIp(userIpRateLimitConfigExpr)
public async getByExpr(): Promise<string> {
return 'OK';
}
}
37 changes: 37 additions & 0 deletions tests/fixtures/controllers/rateLimitDecorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
});
};
36 changes: 35 additions & 1 deletion tests/integration/rate-limit-decorator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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',
});
});
});