Skip to content

Commit 0a79144

Browse files
authored
Merge pull request #7 from namecheap/feat/add_node_extension
feat: add node decorator processor system for dynamic OpenAPI schema
2 parents fb2e69e + c348419 commit 0a79144

12 files changed

Lines changed: 271 additions & 101 deletions

File tree

.github/workflows/runTestsOnPush.yml

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,28 @@
1-
on: [push, pull_request]
1+
on:
2+
push:
3+
branches:
4+
- master
5+
tags-ignore:
6+
- '**'
7+
pull_request:
8+
branches:
9+
- '**'
10+
211
name: Build and Test
312
jobs:
413
build:
514
name: Build
6-
runs-on: ${{ matrix.os }}
7-
strategy:
8-
matrix:
9-
node-version: [12, 14, 16, 17]
10-
os: [macos-latest, ubuntu-latest, windows-latest]
15+
runs-on: ubuntu-latest
1116

1217
steps:
1318
- uses: actions/checkout@master
1419

1520
- name: Setup Node
16-
uses: actions/setup-node@v2
21+
uses: actions/setup-node@v4
1722
with:
18-
node-version: ${{ matrix.node-version }}
23+
node-version: 17
1924
cache: yarn
25+
cache-dependency-path: '**/yarn.lock'
2026

2127
- name: Install Yarn
2228
run: npm install -g yarn

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,5 @@ customRoutes.ts
77
*.swp
88
yarn-error.log
99
tsconfig.tsbuildinfo
10+
.history/
11+
.vscode/

.vscode/launch.json

Lines changed: 0 additions & 58 deletions
This file was deleted.

.vscode/settings.json

Lines changed: 0 additions & 6 deletions
This file was deleted.

.vscode/tasks.json

Lines changed: 0 additions & 28 deletions
This file was deleted.

packages/cli/src/metadataGeneration/metadataGenerator.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as mm from 'minimatch';
22
import * as ts from 'typescript';
33
import { importClassesFromDirectories } from '../utils/importClassesFromDirectories';
44
import { ControllerGenerator } from './controllerGenerator';
5+
import { NodeDecoratorProcessor } from './types/nodeDecoratorProcessor';
56
import { GenerateMetadataError } from './exceptions';
67
import { Tsoa } from '@namecheap/tsoa-runtime';
78
import { TypeResolver } from './typeResolver';
@@ -10,12 +11,14 @@ import { SecurityGenerator } from './securityGenerator';
1011

1112
export interface MetadataGeneratorOptions {
1213
securityGenerator?: SecurityGenerator;
14+
customDecoratorProcessors?: Record<string, NodeDecoratorProcessor>;
1315
}
1416

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

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

2932
this.securityGenerator = generatorOptions?.securityGenerator;
33+
this.customDecoratorProcessors = generatorOptions?.customDecoratorProcessors;
3034
}
3135

3236
public Generate(): Tsoa.Metadata {

packages/cli/src/metadataGeneration/methodGenerator.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { ParameterGenerator } from './parameterGenerator';
1111
import { Tsoa } from '@namecheap/tsoa-runtime';
1212
import { TypeResolver } from './typeResolver';
1313
import { getHeaderType } from '../utils/headerTypeHelpers';
14+
import { DecoratorProcessorContext } from './types/nodeDecoratorProcessor';
1415

1516
export class MethodGenerator {
1617
private method: 'options' | 'get' | 'post' | 'put' | 'patch' | 'delete' | 'head';
@@ -54,7 +55,7 @@ export class MethodGenerator {
5455
const additionalResponses = parameters.filter((p): p is Tsoa.ResParameter => p.in === 'res');
5556
responses.push(...additionalResponses);
5657

57-
return {
58+
const methodMetadata: Tsoa.Method = {
5859
extensions: this.getExtensions(),
5960
deprecated: this.getIsDeprecated(),
6061
description: getJSDocDescription(this.node),
@@ -73,6 +74,10 @@ export class MethodGenerator {
7374
tags: this.getTags(),
7475
type,
7576
};
77+
78+
this.processCustomDecorators(methodMetadata);
79+
80+
return methodMetadata;
7681
}
7782

7883
private buildParameters() {
@@ -353,4 +358,26 @@ export class MethodGenerator {
353358
}
354359
return;
355360
}
361+
362+
private processCustomDecorators(methodMetadata: Tsoa.Method): void {
363+
if (!this.current.customDecoratorProcessors) {
364+
return;
365+
}
366+
367+
for (const [decoratorName, processor] of Object.entries(this.current.customDecoratorProcessors)) {
368+
const decorators = getDecorators(this.node, identifier => identifier.text === decoratorName);
369+
370+
for (const decorator of decorators) {
371+
try {
372+
const context: DecoratorProcessorContext = {
373+
methodObject: methodMetadata,
374+
decoratorArguments: getDecoratorValues(decorator, this.current.typeChecker),
375+
};
376+
processor(context);
377+
} catch (error) {
378+
throw new GenerateMetadataError(`Error in custom decorator processor for '${decoratorName}'`, this.node);
379+
}
380+
}
381+
}
382+
}
356383
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Tsoa } from '@namecheap/tsoa-runtime';
2+
3+
export interface DecoratorProcessorContext {
4+
methodObject: Tsoa.Method;
5+
decoratorArguments: any[];
6+
}
7+
8+
export type NodeDecoratorProcessor = (context: DecoratorProcessorContext) => void;
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Route, Get, Controller } from '@namecheap/tsoa-runtime';
2+
import { RateLimitByUserId } from './rateLimitDecorator';
3+
4+
@Route('rateLimit')
5+
export class RateLimitController extends Controller {
6+
@Get('test')
7+
@RateLimitByUserId(100, 60)
8+
public async getRateLimitedResource(): Promise<string> {
9+
return 'This is a rate-limited resource';
10+
}
11+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import 'reflect-metadata';
2+
import { NodeDecoratorProcessor, DecoratorProcessorContext } from '../../../packages/cli/src/metadataGeneration/types/nodeDecoratorProcessor';
3+
4+
export function RateLimitByUserId(limit: number, timeWindow: number, description?: string): MethodDecorator {
5+
return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
6+
const metadataKey = Symbol('tsoa:rate-limit-by-user-id');
7+
Reflect.defineMetadata(
8+
metadataKey,
9+
{
10+
limit,
11+
timeWindow,
12+
description: description || `${limit} requests per ${timeWindow} seconds per user`,
13+
},
14+
descriptor.value,
15+
);
16+
17+
return descriptor;
18+
};
19+
}
20+
21+
export const RateLimitByUserIdProcessor: NodeDecoratorProcessor = (context: DecoratorProcessorContext) => {
22+
const { methodObject, decoratorArguments } = context;
23+
24+
const limit = decoratorArguments[0] as number;
25+
const timeWindow = decoratorArguments[1] as number;
26+
const description = decoratorArguments[2] as string;
27+
28+
if (!methodObject.extensions) {
29+
methodObject.extensions = [];
30+
}
31+
32+
methodObject.extensions.push({
33+
key: 'x-rate-limit-by-user-id',
34+
value: {
35+
limit: limit || 100,
36+
timeWindow: timeWindow || 60,
37+
description: description || `${limit || 100} requests per ${timeWindow || 60} seconds per user`,
38+
},
39+
});
40+
};

0 commit comments

Comments
 (0)