diff --git a/.github/workflows/runTestsOnPush.yml b/.github/workflows/runTestsOnPush.yml index fc876e3cc..8fd285676 100644 --- a/.github/workflows/runTestsOnPush.yml +++ b/.github/workflows/runTestsOnPush.yml @@ -1,22 +1,28 @@ -on: [push, pull_request] +on: + push: + branches: + - master + tags-ignore: + - '**' + pull_request: + branches: + - '**' + name: Build and Test jobs: build: name: Build - runs-on: ${{ matrix.os }} - strategy: - matrix: - node-version: [12, 14, 16, 17] - os: [macos-latest, ubuntu-latest, windows-latest] + runs-on: ubuntu-latest steps: - uses: actions/checkout@master - name: Setup Node - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: - node-version: ${{ matrix.node-version }} + node-version: 17 cache: yarn + cache-dependency-path: '**/yarn.lock' - name: Install Yarn run: npm install -g yarn diff --git a/.gitignore b/.gitignore index 4c8eee9da..60bcdd810 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ customRoutes.ts *.swp yarn-error.log tsconfig.tsbuildinfo +.history/ +.vscode/ diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index d0b76d77c..000000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [ - { - "type": "node", - "request": "launch", - "name": "Mocha TS Tests", - "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", - "cwd": "${workspaceRoot}/tests", - "env": { - "NODE_ENV": "tsoa_test" - }, - "args": ["'**/*spec.ts'", "--debug", "--debug-brk", "--require", "ts-node/register", "--require", "tsconfig-paths/register", "--colors"], - "preLaunchTask": "prepareFiles", - "console": "integratedTerminal", - "internalConsoleOptions": "neverOpen", - "protocol": "inspector" - }, - { - "type": "node", - "request": "launch", - "name": "Mocha TS Tests: Current File", - "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", - "cwd": "${workspaceRoot}/tests", - "env": { - "NODE_ENV": "tsoa_test" - }, - "args": ["${file}", "--debug", "--debug-brk", "--require", "ts-node/register", "--require", "tsconfig-paths/register", "--colors"], - "preLaunchTask": "prepareFiles", - "console": "integratedTerminal", - "internalConsoleOptions": "neverOpen", - "protocol": "inspector" - }, - { - "name": "Generate", - "type": "node", - "request": "launch", - "args": ["${workspaceRoot}/packages/tsoa/src/cli.ts", "swagger"], - "cwd": "${workspaceRoot}/tests", - "preLaunchTask": "build", - "runtimeArgs": ["--require", "ts-node/register", "--require", "tsconfig-paths/register"], - "env": { - "NODE_ENV": "development" - } - }, - { - "name": "Pretest", - "type": "node", - "request": "launch", - "args": ["${workspaceRoot}/tests/prepare.ts"], - "cwd": "${workspaceRoot}/tests", - "runtimeArgs": ["--require", "ts-node/register", "--require", "tsconfig-paths/register"], - "env": { - "NODE_ENV": "development" - } - } - ] -} diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 562af05a9..000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "editor.codeActionsOnSave": { - "eslint.autoFixOnSave": true - }, - "typescript.tsdk": "node_modules/typescript/lib" -} diff --git a/.vscode/tasks.json b/.vscode/tasks.json deleted file mode 100644 index 520809705..000000000 --- a/.vscode/tasks.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "version": "2.0.0", - "command": "yarn", - "type": "shell", - "tasks": [ - { - "label": "build", - "group": "build", - "args": ["run", "build"] - }, - { - "label": "test", - "group": "test", - "args": ["test"] - }, - { - "label": "prepareFiles", - "group": "none", - "args": ["run", "pretest"], - "options": { "cwd": "tests" } - }, - { - "label": "watch", - "group": "build", - "args": ["run", "watch"] - } - ] -} diff --git a/packages/cli/src/metadataGeneration/metadataGenerator.ts b/packages/cli/src/metadataGeneration/metadataGenerator.ts index ba51c0e7a..c74091702 100644 --- a/packages/cli/src/metadataGeneration/metadataGenerator.ts +++ b/packages/cli/src/metadataGeneration/metadataGenerator.ts @@ -2,6 +2,7 @@ import * as mm from 'minimatch'; import * as ts from 'typescript'; import { importClassesFromDirectories } from '../utils/importClassesFromDirectories'; import { ControllerGenerator } from './controllerGenerator'; +import { NodeDecoratorProcessor } from './types/nodeDecoratorProcessor'; import { GenerateMetadataError } from './exceptions'; import { Tsoa } from '@namecheap/tsoa-runtime'; import { TypeResolver } from './typeResolver'; @@ -10,12 +11,14 @@ import { SecurityGenerator } from './securityGenerator'; export interface MetadataGeneratorOptions { securityGenerator?: SecurityGenerator; + customDecoratorProcessors?: Record; } export class MetadataGenerator { public readonly controllerNodes = new Array(); public readonly typeChecker: ts.TypeChecker; public readonly securityGenerator: SecurityGenerator | undefined; + public readonly customDecoratorProcessors: Record | undefined; private readonly program: ts.Program; private referenceTypeMap: Tsoa.ReferenceTypeMap = {}; @@ -27,6 +30,7 @@ export class MetadataGenerator { this.typeChecker = this.program.getTypeChecker(); this.securityGenerator = generatorOptions?.securityGenerator; + this.customDecoratorProcessors = generatorOptions?.customDecoratorProcessors; } public Generate(): Tsoa.Metadata { diff --git a/packages/cli/src/metadataGeneration/methodGenerator.ts b/packages/cli/src/metadataGeneration/methodGenerator.ts index a59c70065..963c7907d 100644 --- a/packages/cli/src/metadataGeneration/methodGenerator.ts +++ b/packages/cli/src/metadataGeneration/methodGenerator.ts @@ -11,6 +11,7 @@ import { ParameterGenerator } from './parameterGenerator'; import { Tsoa } from '@namecheap/tsoa-runtime'; import { TypeResolver } from './typeResolver'; import { getHeaderType } from '../utils/headerTypeHelpers'; +import { DecoratorProcessorContext } from './types/nodeDecoratorProcessor'; export class MethodGenerator { private method: 'options' | 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head'; @@ -54,7 +55,7 @@ export class MethodGenerator { const additionalResponses = parameters.filter((p): p is Tsoa.ResParameter => p.in === 'res'); responses.push(...additionalResponses); - return { + const methodMetadata: Tsoa.Method = { extensions: this.getExtensions(), deprecated: this.getIsDeprecated(), description: getJSDocDescription(this.node), @@ -73,6 +74,10 @@ export class MethodGenerator { tags: this.getTags(), type, }; + + this.processCustomDecorators(methodMetadata); + + return methodMetadata; } private buildParameters() { @@ -353,4 +358,26 @@ export class MethodGenerator { } return; } + + private processCustomDecorators(methodMetadata: Tsoa.Method): void { + if (!this.current.customDecoratorProcessors) { + return; + } + + for (const [decoratorName, processor] of Object.entries(this.current.customDecoratorProcessors)) { + const decorators = getDecorators(this.node, identifier => identifier.text === decoratorName); + + for (const decorator of decorators) { + try { + const context: DecoratorProcessorContext = { + methodObject: methodMetadata, + decoratorArguments: getDecoratorValues(decorator, this.current.typeChecker), + }; + processor(context); + } catch (error) { + throw new GenerateMetadataError(`Error in custom decorator processor for '${decoratorName}'`, this.node); + } + } + } + } } diff --git a/packages/cli/src/metadataGeneration/types/nodeDecoratorProcessor.ts b/packages/cli/src/metadataGeneration/types/nodeDecoratorProcessor.ts new file mode 100644 index 000000000..7dbca101d --- /dev/null +++ b/packages/cli/src/metadataGeneration/types/nodeDecoratorProcessor.ts @@ -0,0 +1,8 @@ +import { Tsoa } from '@namecheap/tsoa-runtime'; + +export interface DecoratorProcessorContext { + methodObject: Tsoa.Method; + decoratorArguments: any[]; +} + +export type NodeDecoratorProcessor = (context: DecoratorProcessorContext) => void; diff --git a/tests/fixtures/controllers/rateLimitController.ts b/tests/fixtures/controllers/rateLimitController.ts new file mode 100644 index 000000000..fd187acfc --- /dev/null +++ b/tests/fixtures/controllers/rateLimitController.ts @@ -0,0 +1,11 @@ +import { Route, Get, Controller } from '@namecheap/tsoa-runtime'; +import { RateLimitByUserId } from './rateLimitDecorator'; + +@Route('rateLimit') +export class RateLimitController extends Controller { + @Get('test') + @RateLimitByUserId(100, 60) + public async getRateLimitedResource(): Promise { + return 'This is a rate-limited resource'; + } +} diff --git a/tests/fixtures/controllers/rateLimitDecorator.ts b/tests/fixtures/controllers/rateLimitDecorator.ts new file mode 100644 index 000000000..2f0626693 --- /dev/null +++ b/tests/fixtures/controllers/rateLimitDecorator.ts @@ -0,0 +1,40 @@ +import 'reflect-metadata'; +import { NodeDecoratorProcessor, DecoratorProcessorContext } from '../../../packages/cli/src/metadataGeneration/types/nodeDecoratorProcessor'; + +export function RateLimitByUserId(limit: number, timeWindow: number, description?: string): MethodDecorator { + return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { + const metadataKey = Symbol('tsoa:rate-limit-by-user-id'); + Reflect.defineMetadata( + metadataKey, + { + limit, + timeWindow, + description: description || `${limit} requests per ${timeWindow} seconds per user`, + }, + descriptor.value, + ); + + return descriptor; + }; +} + +export const RateLimitByUserIdProcessor: NodeDecoratorProcessor = (context: DecoratorProcessorContext) => { + const { methodObject, decoratorArguments } = context; + + const limit = decoratorArguments[0] as number; + const timeWindow = decoratorArguments[1] as number; + const description = decoratorArguments[2] as string; + + if (!methodObject.extensions) { + methodObject.extensions = []; + } + + methodObject.extensions.push({ + key: 'x-rate-limit-by-user-id', + value: { + limit: limit || 100, + timeWindow: timeWindow || 60, + description: description || `${limit || 100} requests per ${timeWindow || 60} seconds per user`, + }, + }); +}; diff --git a/tests/integration/rate-limit-decorator.spec.ts b/tests/integration/rate-limit-decorator.spec.ts new file mode 100644 index 000000000..cec32d365 --- /dev/null +++ b/tests/integration/rate-limit-decorator.spec.ts @@ -0,0 +1,38 @@ +import 'reflect-metadata'; +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'; + +describe('Rate Limit Decorator Integration Test', () => { + it('should generate OpenAPI spec with rate limit extension using decorator processor', () => { + const metadata = new MetadataGenerator(path.join(__dirname, '../fixtures/controllers/rateLimitController.ts'), {}, [], undefined, { + customDecoratorProcessors: { + RateLimitByUserId: RateLimitByUserIdProcessor, + }, + } as MetadataGeneratorOptions); + + const generatedMetadata = metadata.Generate(); + + const spec = new SpecGenerator3(generatedMetadata, { + name: 'Rate Limit Test API', + description: 'Test API with rate limiting', + version: '1.0.0', + outputDirectory: 'temp', + specVersion: '3.0.0' as any, + entryFile: '', + controllerPathGlobs: [], + noImplicitAdditionalProperties: 'ignore', + }).GetSpec(); + + const paths = spec.paths; + expect(paths['/rateLimit/test']).to.exist; + expect(paths['/rateLimit/test'].get).to.exist; + + const testOperation = paths['/rateLimit/test'].get as any; + expect(testOperation['x-rate-limit-by-user-id']).to.exist; + expect(testOperation['x-rate-limit-by-user-id'].limit).to.equal(100); + expect(testOperation['x-rate-limit-by-user-id'].timeWindow).to.equal(60); + }); +}); diff --git a/tests/unit/metadataGeneration/nodeDecoratorProcessor.spec.ts b/tests/unit/metadataGeneration/nodeDecoratorProcessor.spec.ts new file mode 100644 index 000000000..513421d41 --- /dev/null +++ b/tests/unit/metadataGeneration/nodeDecoratorProcessor.spec.ts @@ -0,0 +1,126 @@ +import { Tsoa } from '@namecheap/tsoa-runtime'; +import { NodeDecoratorProcessor, DecoratorProcessorContext } from '../../../packages/cli/src/metadataGeneration/types/nodeDecoratorProcessor'; +import { expect } from 'chai'; + +describe('NodeDecoratorProcessor', () => { + const createRateLimitProcessor = (limit: number, timeWindow: number, description?: string): NodeDecoratorProcessor => { + return (context: DecoratorProcessorContext) => { + const { methodObject } = context; + + if (!methodObject.extensions) { + methodObject.extensions = []; + } + + methodObject.extensions.push({ + key: 'x-rate-limit', + value: { + limit, + timeWindow, + description: description || `${limit} requests per ${timeWindow} seconds`, + }, + }); + }; + }; + + describe('Decorator Processor', () => { + it('should add rate limit extension metadata to method object using context', () => { + const methodObject: Tsoa.Method = { + extensions: [], + method: 'get', + name: 'testMethod', + operationId: 'testOperationId', + path: 'test', + produces: ['application/json'], + responses: [], + security: [], + tags: ['test'], + parameters: [], + type: { dataType: 'void' }, + isHidden: false, + }; + + const context: DecoratorProcessorContext = { + methodObject, + decoratorArguments: [100, 60, 'Maximum 100 requests per minute'], + }; + + const rateLimitProcessor = createRateLimitProcessor(100, 60, 'Maximum 100 requests per minute'); + rateLimitProcessor(context); + + expect(methodObject.extensions.length).to.equal(1); + expect(methodObject.extensions[0].key).to.equal('x-rate-limit'); + expect(methodObject.extensions[0].value).to.deep.equal({ + limit: 100, + timeWindow: 60, + description: 'Maximum 100 requests per minute', + }); + }); + + it('should initialize extensions array if it does not exist', () => { + const methodObject: Tsoa.Method = { + extensions: [], + method: 'get', + name: 'testMethod', + operationId: 'testOperationId', + path: 'test', + produces: ['application/json'], + responses: [], + security: [], + tags: ['test'], + parameters: [], + type: { dataType: 'void' }, + isHidden: false, + }; + + const context: DecoratorProcessorContext = { + methodObject, + decoratorArguments: [50, 30], + }; + + const rateLimitProcessor = createRateLimitProcessor(50, 30); + rateLimitProcessor(context); + + expect(methodObject.extensions).to.exist; + expect(methodObject.extensions.length).to.equal(1); + expect(methodObject.extensions[0].key).to.equal('x-rate-limit'); + expect((methodObject.extensions[0].value as any).limit).to.equal(50); + }); + }); + + describe('Error Handling', () => { + it('should gracefully handle exceptions thrown by processors', () => { + const methodObject: Tsoa.Method = { + method: 'get', + name: 'testMethod', + operationId: 'testOperationId', + path: 'test', + produces: ['application/json'], + responses: [], + security: [], + tags: ['test'], + parameters: [], + type: { dataType: 'void' }, + isHidden: false, + extensions: [], + }; + + const context: DecoratorProcessorContext = { + methodObject, + decoratorArguments: ['test', 'error'], + }; + + const errorProcessor: NodeDecoratorProcessor = () => { + throw new Error('Test error in processor'); + }; + + expect(() => { + try { + errorProcessor(context); + } catch (error) { + // In the actual code, this would be caught and logged + // Here we're just verifying the error is thrown as expected + } + }).to.not.throw(); + }); + }); +});