Skip to content

Commit 83b25ab

Browse files
decorators: introduce unified Api decorator with envelope/pagination and enhanced Field metadata
1 parent 8672bcb commit 83b25ab

File tree

2 files changed

+377
-98
lines changed

2 files changed

+377
-98
lines changed
Lines changed: 295 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,308 @@
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';
43
import {
5-
ApiTags,
6-
ApiResponse,
74
ApiOperation,
85
ApiUnauthorizedResponse,
96
ApiCreatedResponse,
107
ApiForbiddenResponse,
8+
ApiResponse,
9+
ApiBearerAuth,
10+
ApiBody,
1111
ApiOperationOptions,
1212
ApiResponseOptions,
13-
ApiBearerAuth,
13+
ApiParam,
14+
ApiQuery,
15+
getSchemaPath,
1416
} 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;
1620

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) */
1827
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) */
1931
apiResponses?: Partial<ApiResponseOptions>[];
32+
/** When true attaches RedisAuthGuard + bearer auth + 401 response (if not explicitly supplied). */
2033
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+
};
42308
}

0 commit comments

Comments
 (0)