|
1 | | -import { applyDecorators } from '@nestjs/common'; |
2 | | -import { UseGuards, SetMetadata } from '@nestjs/common'; |
3 | | -import { InputType, Field as GraphQLField } from '@nestjs/graphql'; |
| 1 | +import { applyDecorators, Type as NestType } from '@nestjs/common'; |
| 2 | +import { UseGuards } from '@nestjs/common'; |
4 | 3 | import { |
5 | | - ApiTags, |
6 | | - ApiResponse, |
7 | 4 | ApiOperation, |
8 | 5 | ApiUnauthorizedResponse, |
9 | 6 | ApiCreatedResponse, |
10 | 7 | ApiForbiddenResponse, |
| 8 | + ApiResponse, |
| 9 | + ApiBearerAuth, |
| 10 | + ApiBody, |
11 | 11 | ApiOperationOptions, |
12 | 12 | ApiResponseOptions, |
13 | | - ApiBearerAuth, |
| 13 | + ApiParam, |
| 14 | + ApiQuery, |
| 15 | + getSchemaPath, |
14 | 16 | } from '@nestjs/swagger'; |
15 | | -import { AuthUser, RedisAuthGuard } from '../../auth/redis-auth.guard'; |
| 17 | +import { RedisAuthGuard } from '../../auth/redis-auth.guard'; |
| 18 | + |
| 19 | +export type ApiDecoratorResponse = ApiResponseOptions; |
16 | 20 |
|
17 | | -type ApiOptions = { |
| 21 | +export interface ApiOptions { |
| 22 | + /** Shorthand for ApiOperation -> summary */ |
| 23 | + summary?: string; |
| 24 | + /** Shorthand for ApiOperation -> description */ |
| 25 | + description?: string; |
| 26 | + /** Full ApiOperation options (overrides summary/description if provided) */ |
18 | 27 | apiOperationOptions?: Partial<ApiOperationOptions>; |
| 28 | + /** Explicit list of responses. If provided, default Created/Unauthorized/Forbidden set is suppressed (except Unauthorized when auth required). */ |
| 29 | + responses?: ApiDecoratorResponse[]; |
| 30 | + /** Backwards compat (deprecated) */ |
19 | 31 | apiResponses?: Partial<ApiResponseOptions>[]; |
| 32 | + /** When true attaches RedisAuthGuard + bearer auth + 401 response (if not explicitly supplied). */ |
20 | 33 | authenticationRequired?: boolean; |
21 | | -}; |
22 | | - |
23 | | -export function Api(apiOptions: ApiOptions) { |
24 | | - const properties = [ |
25 | | - apiOptions.authenticationRequired ? UseGuards(RedisAuthGuard) : null, |
26 | | - apiOptions.authenticationRequired ? ApiBearerAuth() : null, |
27 | | - //InputType(), |
28 | | - ApiOperation(apiOptions?.apiOperationOptions), |
29 | | - //SetMetadata('roles', roles), |
30 | | - //UseGuards(AuthGuard, RolesGuard), |
31 | | - //ApiBearerAuth(), |
32 | | - ApiUnauthorizedResponse({ description: 'Unauthorized' }), |
33 | | - ApiCreatedResponse({ description: 'The record has been successfully created.' }), |
34 | | - ApiForbiddenResponse({ description: 'Forbidden.' }), |
35 | | - ]; |
36 | | - |
37 | | - const decorators = properties.filter((elements) => { |
38 | | - return elements !== null; |
39 | | - }); |
40 | | - |
41 | | - return applyDecorators(...decorators); |
| 34 | + /** DTO / class for request body */ |
| 35 | + bodyType?: NestType<any> | (new (...args: any[]) => any); |
| 36 | + /** Path params */ |
| 37 | + params?: Array<{ |
| 38 | + name: string; |
| 39 | + description?: string; |
| 40 | + type?: any; |
| 41 | + required?: boolean; |
| 42 | + enum?: any[]; |
| 43 | + }>; |
| 44 | + /** Query params */ |
| 45 | + queries?: Array<{ |
| 46 | + name: string; |
| 47 | + description?: string; |
| 48 | + type?: any; |
| 49 | + required?: boolean; |
| 50 | + enum?: any[]; |
| 51 | + example?: any; |
| 52 | + }>; |
| 53 | + /** Derive query params from one or more DTO / classes with Field decorators */ |
| 54 | + queriesFrom?: (new (...args: any[]) => any) | Array<new (...args: any[]) => any>; |
| 55 | + /** Derive path params from one or more DTO / classes with Field decorators */ |
| 56 | + pathParamsFrom?: (new (...args: any[]) => any) | Array<new (...args: any[]) => any>; |
| 57 | + /** Mark operation deprecated */ |
| 58 | + deprecated?: boolean; |
| 59 | + /** Shorthand to specify a single 200 response type */ |
| 60 | + responseType?: NestType<any>; |
| 61 | + /** Shorthand to specify an array 200 response type */ |
| 62 | + responseArrayType?: NestType<any>; |
| 63 | + /** Shorthand to specify a paginated 200 response type (items + pageInfo) */ |
| 64 | + paginatedResponseType?: NestType<any>; |
| 65 | + /** Wrap successful 2xx response in a standard envelope { success, data, error? } */ |
| 66 | + envelope?: boolean; |
| 67 | +} |
| 68 | + |
| 69 | +export function Api(options: ApiOptions): MethodDecorator { |
| 70 | + return function (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) { |
| 71 | + // Attempt body type inference if not supplied |
| 72 | + if (!options.bodyType) { |
| 73 | + try { |
| 74 | + const paramTypes: any[] = Reflect.getMetadata('design:paramtypes', target, propertyKey) || []; |
| 75 | + const inferred = paramTypes.find((t) => { |
| 76 | + if (!t) return false; |
| 77 | + const isPrimitive = [String, Number, Boolean, Array, Object].includes(t); |
| 78 | + return !isPrimitive && /Dto|Input/i.test(t.name || ''); |
| 79 | + }); |
| 80 | + if (inferred) { |
| 81 | + options.bodyType = inferred; |
| 82 | + } |
| 83 | + } catch { |
| 84 | + /* ignore */ |
| 85 | + } |
| 86 | + } |
| 87 | + |
| 88 | + const op: ApiOperationOptions = { |
| 89 | + summary: options.summary, |
| 90 | + description: options.description, |
| 91 | + deprecated: options.deprecated, |
| 92 | + ...(options.apiOperationOptions || {}), |
| 93 | + } as ApiOperationOptions; |
| 94 | + |
| 95 | + const userProvidedResponses = (options.responses || options.apiResponses || []).filter((v) => |
| 96 | + Boolean(v) |
| 97 | + ) as ApiResponseOptions[]; |
| 98 | + const addDefaultSet = userProvidedResponses.length === 0; // Only add defaults when no custom responses passed |
| 99 | + |
| 100 | + const decorators: any[] = []; |
| 101 | + if (options.authenticationRequired) { |
| 102 | + decorators.push(UseGuards(RedisAuthGuard), ApiBearerAuth()); |
| 103 | + } |
| 104 | + decorators.push(ApiOperation(op)); |
| 105 | + |
| 106 | + if (options.bodyType) { |
| 107 | + decorators.push(ApiBody({ type: options.bodyType })); |
| 108 | + } |
| 109 | + |
| 110 | + // Params |
| 111 | + (options.params || []).forEach((p) => |
| 112 | + decorators.push( |
| 113 | + ApiParam({ |
| 114 | + name: p.name, |
| 115 | + description: p.description, |
| 116 | + required: p.required !== false, // default true |
| 117 | + enum: p.enum, |
| 118 | + type: p.type, |
| 119 | + }) |
| 120 | + ) |
| 121 | + ); |
| 122 | + |
| 123 | + // Auto path params from metadata |
| 124 | + if (options.pathParamsFrom) { |
| 125 | + const sources = Array.isArray(options.pathParamsFrom) ? options.pathParamsFrom : [options.pathParamsFrom]; |
| 126 | + sources.forEach((src) => { |
| 127 | + if (!src) return; |
| 128 | + const meta = Reflect.getMetadata('cb:fieldMeta', src.prototype) || []; |
| 129 | + meta |
| 130 | + .filter((m: any) => m.inPath) |
| 131 | + .forEach((m: any) => |
| 132 | + decorators.push( |
| 133 | + ApiParam({ |
| 134 | + name: m.name, |
| 135 | + description: m.description, |
| 136 | + required: m.required, |
| 137 | + enum: m.enum, |
| 138 | + type: m.type, |
| 139 | + }) |
| 140 | + ) |
| 141 | + ); |
| 142 | + }); |
| 143 | + } |
| 144 | + |
| 145 | + // Queries |
| 146 | + (options.queries || []).forEach((q) => |
| 147 | + decorators.push( |
| 148 | + ApiQuery({ |
| 149 | + name: q.name, |
| 150 | + description: q.description, |
| 151 | + required: q.required === true, // default false |
| 152 | + enum: q.enum, |
| 153 | + type: q.type, |
| 154 | + example: q.example, |
| 155 | + }) |
| 156 | + ) |
| 157 | + ); |
| 158 | + |
| 159 | + if (options.queriesFrom) { |
| 160 | + const sources = Array.isArray(options.queriesFrom) ? options.queriesFrom : [options.queriesFrom]; |
| 161 | + sources.forEach((src) => { |
| 162 | + if (!src) return; |
| 163 | + const qMeta = Reflect.getMetadata('cb:fieldMeta', src.prototype) || []; |
| 164 | + qMeta |
| 165 | + .filter((m: any) => m.inQuery) |
| 166 | + .forEach((m: any) => |
| 167 | + decorators.push( |
| 168 | + ApiQuery({ |
| 169 | + name: m.name, |
| 170 | + description: m.description, |
| 171 | + required: m.required === true, |
| 172 | + enum: m.enum, |
| 173 | + type: m.type, |
| 174 | + }) |
| 175 | + ) |
| 176 | + ); |
| 177 | + }); |
| 178 | + } |
| 179 | + |
| 180 | + // Shorthand 200 response helpers (only if user didn't explicitly define 200) |
| 181 | + const hasExplicit200 = userProvidedResponses.some((r) => r.status === 200); |
| 182 | + if (!hasExplicit200) { |
| 183 | + if (options.responseType) { |
| 184 | + if (options.envelope) { |
| 185 | + decorators.push( |
| 186 | + ApiResponse({ |
| 187 | + status: 200, |
| 188 | + description: 'Successful response', |
| 189 | + schema: { |
| 190 | + type: 'object', |
| 191 | + properties: { |
| 192 | + success: { type: 'boolean', example: true }, |
| 193 | + data: { $ref: getSchemaPath(options.responseType) }, |
| 194 | + }, |
| 195 | + }, |
| 196 | + }) |
| 197 | + ); |
| 198 | + } else { |
| 199 | + decorators.push(ApiResponse({ status: 200, description: 'Successful response', type: options.responseType })); |
| 200 | + } |
| 201 | + } else if (options.responseArrayType) { |
| 202 | + const arraySchema = { |
| 203 | + type: 'array', |
| 204 | + items: { $ref: getSchemaPath(options.responseArrayType) }, |
| 205 | + }; |
| 206 | + if (options.envelope) { |
| 207 | + decorators.push( |
| 208 | + ApiResponse({ |
| 209 | + status: 200, |
| 210 | + description: 'Successful response', |
| 211 | + schema: { |
| 212 | + type: 'object', |
| 213 | + properties: { |
| 214 | + success: { type: 'boolean', example: true }, |
| 215 | + data: arraySchema, |
| 216 | + }, |
| 217 | + }, |
| 218 | + }) |
| 219 | + ); |
| 220 | + } else { |
| 221 | + decorators.push( |
| 222 | + ApiResponse({ |
| 223 | + status: 200, |
| 224 | + description: 'Successful response', |
| 225 | + schema: arraySchema, |
| 226 | + }) |
| 227 | + ); |
| 228 | + } |
| 229 | + } else if (options.paginatedResponseType) { |
| 230 | + const basePaginated = { |
| 231 | + type: 'object', |
| 232 | + properties: { |
| 233 | + items: { |
| 234 | + type: 'array', |
| 235 | + items: { $ref: getSchemaPath(options.paginatedResponseType) }, |
| 236 | + }, |
| 237 | + pageInfo: { |
| 238 | + type: 'object', |
| 239 | + properties: { |
| 240 | + hasNextPage: { type: 'boolean' }, |
| 241 | + hasPreviousPage: { type: 'boolean' }, |
| 242 | + startCursor: { type: 'string', nullable: true }, |
| 243 | + endCursor: { type: 'string', nullable: true }, |
| 244 | + }, |
| 245 | + }, |
| 246 | + totalCount: { type: 'number' }, |
| 247 | + meta: { |
| 248 | + type: 'object', |
| 249 | + additionalProperties: true, |
| 250 | + nullable: true, |
| 251 | + description: 'Optional metadata related to this collection (e.g., company, tag, filters)', |
| 252 | + }, |
| 253 | + }, |
| 254 | + }; |
| 255 | + if (options.envelope) { |
| 256 | + decorators.push( |
| 257 | + ApiResponse({ |
| 258 | + status: 200, |
| 259 | + description: 'Successful response', |
| 260 | + schema: { |
| 261 | + type: 'object', |
| 262 | + properties: { |
| 263 | + success: { type: 'boolean', example: true }, |
| 264 | + data: basePaginated, |
| 265 | + }, |
| 266 | + }, |
| 267 | + }) |
| 268 | + ); |
| 269 | + } else { |
| 270 | + decorators.push( |
| 271 | + ApiResponse({ |
| 272 | + status: 200, |
| 273 | + description: 'Successful response', |
| 274 | + schema: basePaginated, |
| 275 | + }) |
| 276 | + ); |
| 277 | + } |
| 278 | + } |
| 279 | + } |
| 280 | + |
| 281 | + if (addDefaultSet) { |
| 282 | + decorators.push(ApiUnauthorizedResponse({ description: 'Unauthorized' })); |
| 283 | + decorators.push(ApiCreatedResponse({ description: 'The record has been successfully created.' })); |
| 284 | + decorators.push(ApiForbiddenResponse({ description: 'Forbidden.' })); |
| 285 | + } else { |
| 286 | + let has401 = userProvidedResponses.some((r) => r.status === 401); |
| 287 | + if (options.authenticationRequired && !has401) { |
| 288 | + decorators.push(ApiUnauthorizedResponse({ description: 'Unauthorized' })); |
| 289 | + has401 = true; |
| 290 | + } |
| 291 | + } |
| 292 | + |
| 293 | + if (userProvidedResponses.length > 0) { |
| 294 | + userProvidedResponses.forEach((r) => decorators.push(ApiResponse(r))); |
| 295 | + } |
| 296 | + |
| 297 | + // Store envelope intention for interceptor usage |
| 298 | + if (options.envelope) { |
| 299 | + try { |
| 300 | + Reflect.defineMetadata('cb:envelope', true, descriptor.value); |
| 301 | + } catch { |
| 302 | + /* ignore */ |
| 303 | + } |
| 304 | + } |
| 305 | + |
| 306 | + applyDecorators(...decorators)(target, propertyKey, descriptor); |
| 307 | + }; |
42 | 308 | } |
0 commit comments