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
22 changes: 14 additions & 8 deletions .github/workflows/runTestsOnPush.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ customRoutes.ts
*.swp
yarn-error.log
tsconfig.tsbuildinfo
.history/
.vscode/
58 changes: 0 additions & 58 deletions .vscode/launch.json

This file was deleted.

6 changes: 0 additions & 6 deletions .vscode/settings.json

This file was deleted.

28 changes: 0 additions & 28 deletions .vscode/tasks.json

This file was deleted.

4 changes: 4 additions & 0 deletions packages/cli/src/metadataGeneration/metadataGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -10,12 +11,14 @@ import { SecurityGenerator } from './securityGenerator';

export interface MetadataGeneratorOptions {
securityGenerator?: SecurityGenerator;
customDecoratorProcessors?: Record<string, NodeDecoratorProcessor>;
}

export class MetadataGenerator {
public readonly controllerNodes = new Array<ts.ClassDeclaration>();
public readonly typeChecker: ts.TypeChecker;
public readonly securityGenerator: SecurityGenerator | undefined;
public readonly customDecoratorProcessors: Record<string, NodeDecoratorProcessor> | undefined;

private readonly program: ts.Program;
private referenceTypeMap: Tsoa.ReferenceTypeMap = {};
Expand All @@ -27,6 +30,7 @@ export class MetadataGenerator {
this.typeChecker = this.program.getTypeChecker();

this.securityGenerator = generatorOptions?.securityGenerator;
this.customDecoratorProcessors = generatorOptions?.customDecoratorProcessors;
}

public Generate(): Tsoa.Metadata {
Expand Down
29 changes: 28 additions & 1 deletion packages/cli/src/metadataGeneration/methodGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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),
Expand All @@ -73,6 +74,10 @@ export class MethodGenerator {
tags: this.getTags(),
type,
};

this.processCustomDecorators(methodMetadata);

return methodMetadata;
}

private buildParameters() {
Expand Down Expand Up @@ -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);
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Tsoa } from '@namecheap/tsoa-runtime';

export interface DecoratorProcessorContext {
methodObject: Tsoa.Method;
decoratorArguments: any[];
}

export type NodeDecoratorProcessor = (context: DecoratorProcessorContext) => void;
11 changes: 11 additions & 0 deletions tests/fixtures/controllers/rateLimitController.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
return 'This is a rate-limited resource';
}
}
40 changes: 40 additions & 0 deletions tests/fixtures/controllers/rateLimitDecorator.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does it mean that consumers must be adding separate decorators for documentation?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, this is just as example. The device gets current decorators by provided name and then applies any modification to the openapi method metadata

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`,
},
});
};
38 changes: 38 additions & 0 deletions tests/integration/rate-limit-decorator.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading