From 0603aa9c8662cdf58363cd17af09e4574f921897 Mon Sep 17 00:00:00 2001 From: Hyeonss Date: Fri, 26 Dec 2025 04:17:39 +0900 Subject: [PATCH] feat(nestjs): support @ApiBearerAuth and custom auth decorators (#94) - Add support for @ApiBearerAuth, @ApiBasicAuth, @ApiOAuth2, @ApiSecurity decorators - Add authDecorators config option for custom composite decorators - Update NestJS integration documentation with security decorators section - Add comprehensive tests for security decorator parsing --- docs/guide/nestjs-integration.md | 142 +++++++++++++++++- packages/tspec/src/generator/index.ts | 1 + packages/tspec/src/nestjs/openapiGenerator.ts | 1 + packages/tspec/src/nestjs/parser.ts | 92 +++++++++++- packages/tspec/src/nestjs/types.ts | 8 + .../test/nestjs/fixtures/users.controller.ts | 64 +++++++- packages/tspec/src/test/nestjs/parser.test.ts | 111 ++++++++++++++ packages/tspec/src/types/tspec.ts | 6 + 8 files changed, 416 insertions(+), 9 deletions(-) diff --git a/docs/guide/nestjs-integration.md b/docs/guide/nestjs-integration.md index 769ee2a..c8ee5e6 100644 --- a/docs/guide/nestjs-integration.md +++ b/docs/guide/nestjs-integration.md @@ -294,6 +294,10 @@ Tspec parses the following NestJS decorators: ### Swagger Decorators - `@ApiTags(...tags)` - Adds tags to all operations in the controller - `@ApiResponse({ status, description?, type? })` - Defines response status codes and types +- `@ApiBearerAuth(name?)` - Adds Bearer token authentication to the operation +- `@ApiBasicAuth(name?)` - Adds Basic authentication to the operation +- `@ApiOAuth2(scopes[], name?)` - Adds OAuth2 authentication with scopes +- `@ApiSecurity(name, scopes?)` - Adds custom security scheme ## JSDoc Support @@ -316,9 +320,8 @@ findAll(): Promise { The current NestJS integration has some limitations: 1. **Type inference**: Complex generic types may not be fully resolved -2. **Custom decorators**: Only standard NestJS decorators are supported -3. **Validation decorators**: `class-validator` decorators are not parsed -4. **Interceptors/Guards**: These are not reflected in the generated spec +2. **Validation decorators**: `class-validator` decorators are not parsed +3. **Interceptors/Guards**: These are not reflected in the generated spec ::: tip For more advanced use cases, consider using the standard Tspec approach with `Tspec.DefineApiSpec` alongside your NestJS controllers. @@ -359,6 +362,7 @@ console.log(JSON.stringify(spec, null, 2)); | `openapi.description` | `string` | - | API description | | `openapi.securityDefinitions` | `object` | - | Security schemes | | `openapi.servers` | `array` | - | Server URLs | +| `openapi.authDecorators` | `object` | - | Map custom decorator names to security scheme names | ## Using @ApiResponse @@ -415,6 +419,138 @@ responses: When `@ApiResponse` decorators are present, they override the default response generation based on return type. ::: +## Security Decorators + +Tspec supports security decorators from `@nestjs/swagger` to add authentication requirements to your API operations. + +### Using @ApiBearerAuth + +Add Bearer token authentication to endpoints: + +```ts +import { Controller, Get } from '@nestjs/common'; +import { ApiBearerAuth } from '@nestjs/swagger'; + +@Controller('users') +export class UsersController { + @Get('me') + @ApiBearerAuth('bearerAuth') // Uses 'bearerAuth' security scheme + getCurrentUser(): Promise { + // This endpoint requires Bearer token authentication + } + + @Get('profile') + @ApiBearerAuth() // Uses default 'bearer' security scheme + getProfile(): Promise { + // ... + } +} +``` + +Make sure to define the security scheme in your config: + +```json +{ + "nestjs": true, + "openapi": { + "securityDefinitions": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + } + } +} +``` + +This generates: + +```yaml +paths: + /users/me: + get: + security: + - bearerAuth: [] +``` + +### Using @ApiOAuth2 + +Add OAuth2 authentication with scopes: + +```ts +@Get('admin') +@ApiOAuth2(['read', 'write'], 'oauth2Auth') +getAdminData(): Promise { + // Requires OAuth2 with read and write scopes +} +``` + +### Custom Auth Decorators + +If you use composite decorators that wrap security decorators (e.g., using `applyDecorators`), you can configure Tspec to recognize them: + +```ts +// auth.decorator.ts +import { applyDecorators, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth } from '@nestjs/swagger'; +import { AuthGuard } from './auth.guard'; + +export function Auth(): MethodDecorator & ClassDecorator { + return applyDecorators( + UseGuards(AuthGuard), + ApiBearerAuth('access-token'), + ); +} + +export function AdminAuth(): MethodDecorator & ClassDecorator { + return applyDecorators( + UseGuards(AuthGuard, AdminGuard), + ApiBearerAuth('access-token'), + ); +} +``` + +Configure `authDecorators` in your tspec config to map custom decorators to security schemes: + +```json +{ + "nestjs": true, + "openapi": { + "securityDefinitions": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + }, + "authDecorators": { + "Auth": "bearerAuth", + "AdminAuth": "bearerAuth" + } + } +} +``` + +Now your custom decorators will be recognized: + +```ts +@Controller('users') +export class UsersController { + @Get('me') + @Auth() // ✅ Recognized as bearerAuth + getCurrentUser(): Promise { + // ... + } + + @Get('admin') + @AdminAuth() // ✅ Recognized as bearerAuth + getAdminData(): Promise { + // ... + } +} +``` + ## Using @ApiTags Tspec supports the `@ApiTags` decorator from `@nestjs/swagger` to organize your API operations: diff --git a/packages/tspec/src/generator/index.ts b/packages/tspec/src/generator/index.ts index 0da2284..14dfd71 100644 --- a/packages/tspec/src/generator/index.ts +++ b/packages/tspec/src/generator/index.ts @@ -241,6 +241,7 @@ export const generateTspec = async ( const app = parseNestControllers({ tsconfigPath: params.tsconfigPath || 'tsconfig.json', controllerGlobs: params.specPathGlobs || ['src/**/*.controller.ts'], + authDecorators: params.openapi?.authDecorators, }); logger.log(`Found ${app.controllers.length} controller(s)`); diff --git a/packages/tspec/src/nestjs/openapiGenerator.ts b/packages/tspec/src/nestjs/openapiGenerator.ts index 49e2cef..06e03eb 100644 --- a/packages/tspec/src/nestjs/openapiGenerator.ts +++ b/packages/tspec/src/nestjs/openapiGenerator.ts @@ -275,5 +275,6 @@ const buildOperation = ( parameters: parameters.length > 0 ? parameters : undefined, requestBody, responses, + security: method.security, }; }; diff --git a/packages/tspec/src/nestjs/parser.ts b/packages/tspec/src/nestjs/parser.ts index 7aa6311..d4f380a 100644 --- a/packages/tspec/src/nestjs/parser.ts +++ b/packages/tspec/src/nestjs/parser.ts @@ -20,7 +20,7 @@ const PARAM_DECORATORS = ['Param', 'Query', 'Body', 'Headers']; const FILE_DECORATORS = ['UploadedFile', 'UploadedFiles']; export const parseNestControllers = (options: NestParserOptions): ParsedNestApp => { - const { tsconfigPath, controllerGlobs } = options; + const { tsconfigPath, controllerGlobs, authDecorators } = options; const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile); if (configFile.error) { @@ -49,7 +49,7 @@ export const parseNestControllers = (options: NestParserOptions): ParsedNestApp ts.forEachChild(sourceFile, (node) => { if (ts.isClassDeclaration(node)) { - const controller = parseController(node, checker, sourceFile); + const controller = parseController(node, checker, sourceFile, authDecorators); if (controller) { controllers.push(controller); collectImports(sourceFile, imports); @@ -279,6 +279,7 @@ const parseController = ( node: ts.ClassDeclaration, checker: ts.TypeChecker, sourceFile: ts.SourceFile, + authDecorators?: Record, ): NestControllerMetadata | null => { const decorators = ts.canHaveDecorators(node) ? ts.getDecorators(node) : undefined; if (!decorators) return null; @@ -301,7 +302,7 @@ const parseController = ( node.members.forEach((member) => { if (ts.isMethodDeclaration(member)) { - const method = parseMethod(member, checker); + const method = parseMethod(member, checker, authDecorators); if (method) { methods.push(method); } @@ -380,6 +381,86 @@ const parseApiResponses = ( return responses; }; +// Security decorator names and their default security scheme names +const SECURITY_DECORATORS: Record = { + ApiBearerAuth: 'bearer', + ApiBasicAuth: 'basic', + ApiOAuth2: 'oauth2', + ApiSecurity: '', // Uses first argument as security name +}; + +// Parse security decorators (@ApiBearerAuth, @ApiBasicAuth, @ApiOAuth2, @ApiSecurity, and custom decorators) +const parseApiSecurity = ( + decorators: readonly ts.Decorator[], + authDecorators?: Record, +): Array> => { + const security: Array> = []; + + // Merge built-in security decorators with custom authDecorators + const allSecurityDecorators = { ...SECURITY_DECORATORS, ...authDecorators }; + + for (const decorator of decorators) { + if (!ts.isCallExpression(decorator.expression)) continue; + if (!ts.isIdentifier(decorator.expression.expression)) continue; + + const decoratorName = decorator.expression.expression.text; + + // Check if it's a custom auth decorator (from authDecorators config) + if (authDecorators && decoratorName in authDecorators) { + const securityName = authDecorators[decoratorName]; + security.push({ [securityName]: [] }); + continue; + } + + // Check if it's a built-in security decorator + if (!(decoratorName in SECURITY_DECORATORS)) continue; + + const args = decorator.expression.arguments; + let securityName: string; + let scopes: string[] = []; + + if (decoratorName === 'ApiSecurity') { + // @ApiSecurity('securityName', ['scope1', 'scope2']) + if (args.length === 0) continue; + const firstArg = args[0]; + if (!ts.isStringLiteral(firstArg)) continue; + securityName = firstArg.text; + + // Parse scopes if provided + if (args.length > 1 && ts.isArrayLiteralExpression(args[1])) { + scopes = args[1].elements + .filter((el): el is ts.StringLiteral => ts.isStringLiteral(el)) + .map((el) => el.text); + } + } else if (decoratorName === 'ApiOAuth2') { + // @ApiOAuth2(['scope1', 'scope2'], 'securityName') + // First arg is scopes array, second arg is optional security name + if (args.length > 0 && ts.isArrayLiteralExpression(args[0])) { + scopes = args[0].elements + .filter((el): el is ts.StringLiteral => ts.isStringLiteral(el)) + .map((el) => el.text); + } + if (args.length > 1 && ts.isStringLiteral(args[1])) { + securityName = args[1].text; + } else { + securityName = SECURITY_DECORATORS[decoratorName]; + } + } else { + // @ApiBearerAuth('securityName') or @ApiBasicAuth('securityName') + // First argument is optional security scheme name + if (args.length > 0 && ts.isStringLiteral(args[0])) { + securityName = args[0].text; + } else { + securityName = SECURITY_DECORATORS[decoratorName]; + } + } + + security.push({ [securityName]: scopes }); + } + + return security; +}; + // Parse @ApiTags decorator to extract tag names const parseApiTags = ( decorators: readonly ts.Decorator[], @@ -427,6 +508,7 @@ const parseApiTags = ( const parseMethod = ( node: ts.MethodDeclaration, checker: ts.TypeChecker, + authDecorators?: Record, ): NestMethodMetadata | null => { const decorators = ts.canHaveDecorators(node) ? ts.getDecorators(node) : undefined; if (!decorators) return null; @@ -463,6 +545,9 @@ const parseMethod = ( // Parse @ApiResponse decorators const responses = parseApiResponses(decorators, checker); + // Parse security decorators (@ApiBearerAuth, @ApiBasicAuth, @ApiOAuth2, @ApiSecurity, and custom decorators) + const security = parseApiSecurity(decorators, authDecorators); + return { name: methodName, httpMethod, @@ -473,6 +558,7 @@ const parseMethod = ( summary, tags: tags.length > 0 ? tags : undefined, responses: responses.length > 0 ? responses : undefined, + security: security.length > 0 ? security : undefined, }; }; diff --git a/packages/tspec/src/nestjs/types.ts b/packages/tspec/src/nestjs/types.ts index 0feff14..422552b 100644 --- a/packages/tspec/src/nestjs/types.ts +++ b/packages/tspec/src/nestjs/types.ts @@ -24,6 +24,8 @@ export interface NestMethodMetadata { summary?: string; tags?: string[]; responses?: NestApiResponse[]; + /** Security requirements from @ApiBearerAuth, @ApiBasicAuth, @ApiOAuth2, @ApiSecurity decorators */ + security?: Array>; } export type HttpMethod = 'get' | 'post' | 'put' | 'patch' | 'delete' | 'options' | 'head'; @@ -42,6 +44,12 @@ export interface NestParameterMetadata { export interface NestParserOptions { tsconfigPath: string; controllerGlobs: string[]; + /** + * Map custom decorator names to security scheme names. + * Useful for composite decorators that wrap @ApiBearerAuth, etc. + * @example { "Auth": "bearerAuth", "AdminAuth": "bearerAuth" } + */ + authDecorators?: Record; } export interface ParsedNestApp { diff --git a/packages/tspec/src/test/nestjs/fixtures/users.controller.ts b/packages/tspec/src/test/nestjs/fixtures/users.controller.ts index 0933e3a..f912411 100644 --- a/packages/tspec/src/test/nestjs/fixtures/users.controller.ts +++ b/packages/tspec/src/test/nestjs/fixtures/users.controller.ts @@ -23,6 +23,25 @@ function Query(): ParameterDecorator { function ApiTags(...tags: string[]): ClassDecorator { return () => {}; } +function ApiBearerAuth(name?: string): MethodDecorator & ClassDecorator { + return () => {}; +} +function ApiBasicAuth(name?: string): MethodDecorator & ClassDecorator { + return () => {}; +} +function ApiOAuth2(scopes?: string[], name?: string): MethodDecorator & ClassDecorator { + return () => {}; +} +function ApiSecurity(name: string, scopes?: string[]): MethodDecorator & ClassDecorator { + return () => {}; +} +// Custom composite decorator (simulating applyDecorators pattern) +function Auth(): MethodDecorator & ClassDecorator { + return () => {}; +} +function AdminAuth(): MethodDecorator & ClassDecorator { + return () => {}; +} /** * 사용자 성별 @@ -205,25 +224,28 @@ export class UsersController { } /** - * 사용자 상세 조회 + * 사용자 상세 조회 (인증 필요) */ @Get(':id') + @ApiBearerAuth('bearerAuth') findOne(@Param('id') id: string): Promise> { return Promise.resolve({ data: {} as UserDto }); } /** - * 사용자 생성 + * 사용자 생성 (인증 필요 - 기본 이름) */ @Post() + @ApiBearerAuth() create(@Body() createUserDto: CreateUserDto): Promise> { return Promise.resolve({ data: {} as UserDto }); } /** - * 사용자 수정 + * 사용자 수정 (Basic Auth 필요) */ @Put(':id') + @ApiBasicAuth('basicAuth') update( @Param('id') id: string, @Body() updateUserDto: Partial, @@ -231,6 +253,24 @@ export class UsersController { return Promise.resolve({ data: {} as UserDto }); } + /** + * OAuth2 테스트 + */ + @Get('oauth-test') + @ApiOAuth2(['read', 'write'], 'oauth2Auth') + oauthTest(): Promise { + return Promise.resolve(); + } + + /** + * 커스텀 시큐리티 테스트 + */ + @Get('custom-security') + @ApiSecurity('apiKey', ['admin']) + customSecurityTest(): Promise { + return Promise.resolve(); + } + /** * 사용자 목록 조회 (배열 프로퍼티 테스트용) */ @@ -238,4 +278,22 @@ export class UsersController { getList(): Promise { return Promise.resolve({ users: [], totalCount: 0 }); } + + /** + * 커스텀 Auth 데코레이터 테스트 + */ + @Get('protected') + @Auth() + protectedEndpoint(): Promise { + return Promise.resolve(); + } + + /** + * 커스텀 AdminAuth 데코레이터 테스트 + */ + @Get('admin-only') + @AdminAuth() + adminOnlyEndpoint(): Promise { + return Promise.resolve(); + } } diff --git a/packages/tspec/src/test/nestjs/parser.test.ts b/packages/tspec/src/test/nestjs/parser.test.ts index 36477c2..fdc8046 100644 --- a/packages/tspec/src/test/nestjs/parser.test.ts +++ b/packages/tspec/src/test/nestjs/parser.test.ts @@ -196,5 +196,116 @@ describe('NestJS Parser', () => { expect(withErrorOp.responses['200'].description).toBe('Successful response'); expect(withErrorOp.responses['200'].content['application/json']).toBeDefined(); }); + + it('should parse @ApiBearerAuth decorator and generate security field (Issue #94)', async () => { + const result = parseNestControllers({ + tsconfigPath: path.join(fixturesPath, 'tsconfig.json'), + controllerGlobs: [path.join(fixturesPath, 'users.controller.ts')], + }); + + const openapi = await generateOpenApiFromNest(result, { + title: 'Users API', + version: '1.0.0', + securitySchemes: { + bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, + basicAuth: { type: 'http', scheme: 'basic' }, + oauth2Auth: { type: 'oauth2', flows: {} }, + apiKey: { type: 'apiKey', in: 'header', name: 'X-API-Key' }, + }, + }); + + // @ApiBearerAuth('bearerAuth') - with explicit name + const findOnePath = openapi.paths['/users/{id}']; + expect(findOnePath?.get).toBeDefined(); + const findOneOp = findOnePath?.get as any; + expect(findOneOp.security).toBeDefined(); + expect(findOneOp.security).toEqual([{ bearerAuth: [] }]); + + // @ApiBearerAuth() - with default name + const createPath = openapi.paths['/users']; + expect(createPath?.post).toBeDefined(); + const createOp = createPath?.post as any; + expect(createOp.security).toBeDefined(); + expect(createOp.security).toEqual([{ bearer: [] }]); + + // @ApiBasicAuth('basicAuth') + const updatePath = openapi.paths['/users/{id}']; + expect(updatePath?.put).toBeDefined(); + const updateOp = updatePath?.put as any; + expect(updateOp.security).toBeDefined(); + expect(updateOp.security).toEqual([{ basicAuth: [] }]); + + // @ApiOAuth2(['read', 'write'], 'oauth2Auth') + const oauthPath = openapi.paths['/users/oauth-test']; + expect(oauthPath?.get).toBeDefined(); + const oauthOp = oauthPath?.get as any; + expect(oauthOp.security).toBeDefined(); + expect(oauthOp.security).toEqual([{ oauth2Auth: ['read', 'write'] }]); + + // @ApiSecurity('apiKey', ['admin']) + const customSecurityPath = openapi.paths['/users/custom-security']; + expect(customSecurityPath?.get).toBeDefined(); + const customSecurityOp = customSecurityPath?.get as any; + expect(customSecurityOp.security).toBeDefined(); + expect(customSecurityOp.security).toEqual([{ apiKey: ['admin'] }]); + + // No security decorator - should not have security field + const findAllPath = openapi.paths['/users']; + expect(findAllPath?.get).toBeDefined(); + const findAllOp = findAllPath?.get as any; + expect(findAllOp.security).toBeUndefined(); + }); + + it('should support custom auth decorators via authDecorators config (Issue #94)', async () => { + const result = parseNestControllers({ + tsconfigPath: path.join(fixturesPath, 'tsconfig.json'), + controllerGlobs: [path.join(fixturesPath, 'users.controller.ts')], + authDecorators: { + Auth: 'bearerAuth', + AdminAuth: 'adminAuth', + }, + }); + + const openapi = await generateOpenApiFromNest(result, { + title: 'Users API', + version: '1.0.0', + securitySchemes: { + bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, + adminAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' }, + }, + }); + + // @Auth() - custom decorator mapped to bearerAuth + const protectedPath = openapi.paths['/users/protected']; + expect(protectedPath?.get).toBeDefined(); + const protectedOp = protectedPath?.get as any; + expect(protectedOp.security).toBeDefined(); + expect(protectedOp.security).toEqual([{ bearerAuth: [] }]); + + // @AdminAuth() - custom decorator mapped to adminAuth + const adminOnlyPath = openapi.paths['/users/admin-only']; + expect(adminOnlyPath?.get).toBeDefined(); + const adminOnlyOp = adminOnlyPath?.get as any; + expect(adminOnlyOp.security).toBeDefined(); + expect(adminOnlyOp.security).toEqual([{ adminAuth: [] }]); + + // Without authDecorators config, custom decorators should be ignored + const resultWithoutConfig = parseNestControllers({ + tsconfigPath: path.join(fixturesPath, 'tsconfig.json'), + controllerGlobs: [path.join(fixturesPath, 'users.controller.ts')], + // No authDecorators config + }); + + const openapiWithoutConfig = await generateOpenApiFromNest(resultWithoutConfig, { + title: 'Users API', + version: '1.0.0', + }); + + // @Auth() without config - should not have security field + const protectedPathNoConfig = openapiWithoutConfig.paths['/users/protected']; + expect(protectedPathNoConfig?.get).toBeDefined(); + const protectedOpNoConfig = protectedPathNoConfig?.get as any; + expect(protectedOpNoConfig.security).toBeUndefined(); + }); }); }); diff --git a/packages/tspec/src/types/tspec.ts b/packages/tspec/src/types/tspec.ts index a72f7f8..6ea53fa 100644 --- a/packages/tspec/src/types/tspec.ts +++ b/packages/tspec/src/types/tspec.ts @@ -118,6 +118,12 @@ export namespace Tspec { description?: string, securityDefinitions?: OpenAPIV3.ComponentsObject['securitySchemes'], servers?: OpenAPIV3.ServerObject[], + /** + * Map custom decorator names to security scheme names. + * Useful for composite decorators that wrap @ApiBearerAuth, etc. + * @example { "Auth": "bearerAuth", "AdminAuth": "bearerAuth" } + */ + authDecorators?: Record, }, debug?: boolean, ignoreErrors?: boolean,