@breadstone/archipel-mcp 0.0.16 → 0.0.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,766 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Controller Best Practices
|
|
3
|
+
description: Structure, annotate, and implement NestJS controllers following Archipel conventions — regions, dependency injection, ResponseReturn, guards, and the @Api decorator.
|
|
4
|
+
order: 4
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Controller Best Practices
|
|
8
|
+
|
|
9
|
+
This guide defines the canonical structure of a NestJS controller in an Archipel-powered application. Controllers are the HTTP boundary — they map transport concerns (routes, headers, status codes) to service calls and response objects. **No business logic lives here.**
|
|
10
|
+
|
|
11
|
+
If you haven't set up OpenAPI documentation yet, read the [OpenAPI & Feature Discovery](/guides/openapi-and-feature-discovery) guide first.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Anatomy of a Controller
|
|
16
|
+
|
|
17
|
+
Every controller follows the same skeleton: **Imports → Class decorator → Regions (Fields, Ctor, Methods)**. No other regions are allowed.
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
// #region Imports
|
|
21
|
+
|
|
22
|
+
import { Body, Controller, Get, HttpStatus, Post, Query, UseGuards } from '@nestjs/common';
|
|
23
|
+
import { Api } from '@breadstone/archipel-platform-openapi';
|
|
24
|
+
import { Public, ResponseReturn, IpAddress, UserAgent } from '@breadstone/archipel-platform-core';
|
|
25
|
+
import { JwtAuthGuard, Token, User, type IAuthSubject } from '@breadstone/archipel-platform-authentication';
|
|
26
|
+
import { CreateOrderRequest, OrderResponse, OrderListResponse } from '../models';
|
|
27
|
+
|
|
28
|
+
// #endregion
|
|
29
|
+
|
|
30
|
+
@Api({ tags: ['Orders'] })
|
|
31
|
+
@Controller('api/orders')
|
|
32
|
+
export class OrderController {
|
|
33
|
+
|
|
34
|
+
// #region Fields
|
|
35
|
+
|
|
36
|
+
private readonly _orderService: OrderService;
|
|
37
|
+
|
|
38
|
+
// #endregion
|
|
39
|
+
|
|
40
|
+
// #region Ctor
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Constructs a new instance of the `OrderController` class.
|
|
44
|
+
*
|
|
45
|
+
* @public
|
|
46
|
+
*/
|
|
47
|
+
public constructor(orderService: OrderService) {
|
|
48
|
+
this._orderService = orderService;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// #endregion
|
|
52
|
+
|
|
53
|
+
// #region Methods
|
|
54
|
+
|
|
55
|
+
// ... endpoint methods
|
|
56
|
+
|
|
57
|
+
// #endregion
|
|
58
|
+
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Key Rules
|
|
63
|
+
|
|
64
|
+
| Rule | Why |
|
|
65
|
+
|------|-----|
|
|
66
|
+
| Controllers use **constructor injection** (not `inject()`) | Framework requires it for request-scoped providers; keeps DI explicit |
|
|
67
|
+
| Private fields prefixed with `_` | Coding standard — distinguishes injected dependencies from local variables |
|
|
68
|
+
| Regions in strict order: Fields → Ctor → Methods | Consistency across the entire codebase |
|
|
69
|
+
| No business logic in controllers | Controllers are I/O adapters; logic belongs in services |
|
|
70
|
+
| No direct Prisma/repository calls | Services orchestrate data access; controllers never touch persistence |
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Class-Level Decorators
|
|
75
|
+
|
|
76
|
+
Apply `@Api({ tags })` and `@Controller(path)` at class level. The tag groups all endpoints under a single OpenAPI section.
|
|
77
|
+
|
|
78
|
+
```typescript
|
|
79
|
+
@Api({ tags: ['Auth'] })
|
|
80
|
+
@Controller('api/auth')
|
|
81
|
+
export class AuthController { ... }
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
For versioned APIs, include the version in the path:
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
@Api({ tags: ['Users'] })
|
|
88
|
+
@Controller('api/v1/users')
|
|
89
|
+
export class UserController { ... }
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Dependency Injection
|
|
95
|
+
|
|
96
|
+
Controllers receive services via constructor parameters. Store them in private `readonly` fields.
|
|
97
|
+
|
|
98
|
+
```typescript
|
|
99
|
+
// #region Fields
|
|
100
|
+
|
|
101
|
+
private readonly _userService: UserService;
|
|
102
|
+
private readonly _verificationService: VerificationService;
|
|
103
|
+
private readonly _userNameGeneratorService: UserNameGeneratorService;
|
|
104
|
+
|
|
105
|
+
// #endregion
|
|
106
|
+
|
|
107
|
+
// #region Ctor
|
|
108
|
+
|
|
109
|
+
public constructor(
|
|
110
|
+
userService: UserService,
|
|
111
|
+
verificationService: VerificationService,
|
|
112
|
+
userNameGeneratorService: UserNameGeneratorService
|
|
113
|
+
) {
|
|
114
|
+
this._userService = userService;
|
|
115
|
+
this._verificationService = verificationService;
|
|
116
|
+
this._userNameGeneratorService = userNameGeneratorService;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// #endregion
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
**Never** create service instances manually. NestJS resolves the dependency graph automatically via the module system.
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## ResponseReturn
|
|
127
|
+
|
|
128
|
+
Every endpoint returns `Promise<ResponseReturn<T>>`. This wrapper provides a consistent response envelope with `success`, `status`, `data`, `error`, and `timestamp` fields.
|
|
129
|
+
|
|
130
|
+
### Success Responses
|
|
131
|
+
|
|
132
|
+
| Factory Method | HTTP Status | Use Case |
|
|
133
|
+
|---------------|-------------|----------|
|
|
134
|
+
| `ResponseReturn.ok(Type, data)` | 200 | Returning data (GET, POST with result) |
|
|
135
|
+
| `ResponseReturn.created(Type, data)` | 201 | Resource creation |
|
|
136
|
+
| `ResponseReturn.accepted(Type, data)` | 202 | Async processing started |
|
|
137
|
+
| `ResponseReturn.noContent()` | 204 | Mutations with no response body |
|
|
138
|
+
|
|
139
|
+
### Examples
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
// 200 OK — return data
|
|
143
|
+
public async login(
|
|
144
|
+
@Body() body: AuthLoginRequest
|
|
145
|
+
): Promise<ResponseReturn<AuthLoginResponse>> {
|
|
146
|
+
const result = await this._userService.signIn(body.login, body.password);
|
|
147
|
+
return ResponseReturn.ok(AuthLoginResponse, {
|
|
148
|
+
accessToken: result.accessToken,
|
|
149
|
+
mfaRequired: result.mfaRequired
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 204 No Content — mutation without response body
|
|
154
|
+
public async register(
|
|
155
|
+
@Body() body: AuthRegisterRequest
|
|
156
|
+
): Promise<ResponseReturn<void>> {
|
|
157
|
+
await this._userService.signUp(body.email, body.password, body.type);
|
|
158
|
+
return ResponseReturn.noContent();
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The first argument to `ok()` / `created()` is the **response DTO class** — `ResponseReturn` uses `class-transformer` internally to ensure the response matches the declared shape. This prevents accidentally leaking internal fields.
|
|
163
|
+
|
|
164
|
+
### What NOT to Do
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
// ❌ BAD — inline object literal without ResponseReturn
|
|
168
|
+
public async getUser(): Promise<{ id: string; name: string }> {
|
|
169
|
+
const user = await this._userService.find(id);
|
|
170
|
+
return { id: user.id, name: user.name };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ❌ BAD — raw service entity as response
|
|
174
|
+
public async getUser(): Promise<UserEntity> {
|
|
175
|
+
return this._userService.find(id);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ✅ GOOD — ResponseReturn with typed DTO
|
|
179
|
+
public async getUser(): Promise<ResponseReturn<UserResponse>> {
|
|
180
|
+
const result = await this._userService.find(id);
|
|
181
|
+
return ResponseReturn.ok(UserResponse, result);
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## Guards and Decorators
|
|
188
|
+
|
|
189
|
+
### Authentication Guard
|
|
190
|
+
|
|
191
|
+
Use `@UseGuards(JwtAuthGuard)` for protected endpoints. Extract the authenticated user with `@User()`:
|
|
192
|
+
|
|
193
|
+
```typescript
|
|
194
|
+
@Get('me')
|
|
195
|
+
@UseGuards(JwtAuthGuard)
|
|
196
|
+
@Api({
|
|
197
|
+
auth: 'bearer',
|
|
198
|
+
operation: {
|
|
199
|
+
operationId: 'me',
|
|
200
|
+
description: 'Gets the current user.',
|
|
201
|
+
summary: 'Get current user'
|
|
202
|
+
},
|
|
203
|
+
responses: [{
|
|
204
|
+
status: HttpStatus.OK,
|
|
205
|
+
description: 'Authenticated user profile.',
|
|
206
|
+
type: UserResponse
|
|
207
|
+
}, {
|
|
208
|
+
status: HttpStatus.UNAUTHORIZED,
|
|
209
|
+
description: 'Authentication is required.'
|
|
210
|
+
}]
|
|
211
|
+
})
|
|
212
|
+
public async me(
|
|
213
|
+
@User() user: IAuthSubject
|
|
214
|
+
): Promise<ResponseReturn<UserResponse>> {
|
|
215
|
+
const result = await this._userService.find({ id: user.id });
|
|
216
|
+
return ResponseReturn.ok(UserResponse, result);
|
|
217
|
+
}
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### Public Routes
|
|
221
|
+
|
|
222
|
+
Mark unauthenticated endpoints with `@Public()`. This bypasses the global JWT guard if one is registered:
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
@Post('register')
|
|
226
|
+
@Public()
|
|
227
|
+
public async register(@Body() body: RegisterRequest): Promise<ResponseReturn<void>> { ... }
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Token Extraction
|
|
231
|
+
|
|
232
|
+
Use `@Token()` when you need the raw JWT string (e.g., for logout or refresh):
|
|
233
|
+
|
|
234
|
+
```typescript
|
|
235
|
+
@Post('logout')
|
|
236
|
+
@UseGuards(JwtAuthGuard)
|
|
237
|
+
public async logout(@Token() token: string): Promise<ResponseReturn<void>> {
|
|
238
|
+
await this._userService.signOut(token);
|
|
239
|
+
return ResponseReturn.noContent();
|
|
240
|
+
}
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### Custom Parameter Decorators
|
|
244
|
+
|
|
245
|
+
Archipel provides built-in decorators for common request data:
|
|
246
|
+
|
|
247
|
+
| Decorator | Extracts | Typical Use |
|
|
248
|
+
|-----------|----------|-------------|
|
|
249
|
+
| `@User()` | `request.user` as `IAuthSubject` | Authenticated user identity |
|
|
250
|
+
| `@Token()` | Bearer token string | Logout, refresh |
|
|
251
|
+
| `@IpAddress()` | Client IP address | Audit logging, rate limiting |
|
|
252
|
+
| `@UserAgent()` | User-Agent header | Device tracking, session metadata |
|
|
253
|
+
|
|
254
|
+
---
|
|
255
|
+
|
|
256
|
+
## Annotating with @Api
|
|
257
|
+
|
|
258
|
+
Every endpoint **must** have an `@Api()` decorator with `operation` and `responses`. This generates accurate OpenAPI documentation.
|
|
259
|
+
|
|
260
|
+
### Minimal Annotation
|
|
261
|
+
|
|
262
|
+
```typescript
|
|
263
|
+
@Get()
|
|
264
|
+
@Api({
|
|
265
|
+
operation: {
|
|
266
|
+
operationId: 'listOrders',
|
|
267
|
+
description: 'Lists all orders for the authenticated user.',
|
|
268
|
+
summary: 'List orders'
|
|
269
|
+
},
|
|
270
|
+
responses: [{
|
|
271
|
+
status: HttpStatus.OK,
|
|
272
|
+
description: 'List of orders.',
|
|
273
|
+
type: OrderListResponse
|
|
274
|
+
}]
|
|
275
|
+
})
|
|
276
|
+
public async listOrders(): Promise<ResponseReturn<OrderListResponse>> { ... }
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
### Full Annotation with Body, Query, and Auth
|
|
280
|
+
|
|
281
|
+
```typescript
|
|
282
|
+
@Post('login')
|
|
283
|
+
@Api({
|
|
284
|
+
operation: {
|
|
285
|
+
operationId: 'login',
|
|
286
|
+
description: 'Logs the user in and returns an access token.',
|
|
287
|
+
summary: 'Authenticate user'
|
|
288
|
+
},
|
|
289
|
+
body: {
|
|
290
|
+
description: 'Credentials used for authentication.',
|
|
291
|
+
type: AuthLoginRequest
|
|
292
|
+
},
|
|
293
|
+
responses: [{
|
|
294
|
+
status: HttpStatus.OK,
|
|
295
|
+
description: 'Access token successfully generated.',
|
|
296
|
+
type: AuthLoginResponse
|
|
297
|
+
}, {
|
|
298
|
+
status: HttpStatus.UNAUTHORIZED,
|
|
299
|
+
description: 'Provided credentials are invalid.'
|
|
300
|
+
}]
|
|
301
|
+
})
|
|
302
|
+
public async login(
|
|
303
|
+
@IpAddress() ipAddress: string,
|
|
304
|
+
@UserAgent() userAgent: string,
|
|
305
|
+
@Body() body: AuthLoginRequest
|
|
306
|
+
): Promise<ResponseReturn<AuthLoginResponse>> { ... }
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### Protected Endpoint with Params
|
|
310
|
+
|
|
311
|
+
```typescript
|
|
312
|
+
@Get(':provider')
|
|
313
|
+
@UseGuards(JwtAuthGuard)
|
|
314
|
+
@Api({
|
|
315
|
+
auth: 'bearer',
|
|
316
|
+
operation: {
|
|
317
|
+
operationId: 'getProvider',
|
|
318
|
+
description: 'Returns details about a payment provider.',
|
|
319
|
+
summary: 'Get provider'
|
|
320
|
+
},
|
|
321
|
+
params: [{
|
|
322
|
+
name: 'provider',
|
|
323
|
+
description: 'Provider identifier.',
|
|
324
|
+
required: true,
|
|
325
|
+
type: String
|
|
326
|
+
}],
|
|
327
|
+
responses: [{
|
|
328
|
+
status: HttpStatus.OK,
|
|
329
|
+
description: 'Provider details.',
|
|
330
|
+
type: ProviderResponse
|
|
331
|
+
}, {
|
|
332
|
+
status: HttpStatus.NOT_FOUND,
|
|
333
|
+
description: 'Provider not found.'
|
|
334
|
+
}]
|
|
335
|
+
})
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
### Query Parameters
|
|
339
|
+
|
|
340
|
+
```typescript
|
|
341
|
+
@Get('availability')
|
|
342
|
+
@Api({
|
|
343
|
+
operation: {
|
|
344
|
+
operationId: 'checkAvailability',
|
|
345
|
+
description: 'Checks if a username is available.',
|
|
346
|
+
summary: 'Check availability'
|
|
347
|
+
},
|
|
348
|
+
query: [{
|
|
349
|
+
name: 'username',
|
|
350
|
+
description: 'The username to check.',
|
|
351
|
+
required: true,
|
|
352
|
+
type: String
|
|
353
|
+
}],
|
|
354
|
+
responses: [{
|
|
355
|
+
status: HttpStatus.OK,
|
|
356
|
+
description: 'Availability status.',
|
|
357
|
+
type: AvailabilityResponse
|
|
358
|
+
}]
|
|
359
|
+
})
|
|
360
|
+
public async checkAvailability(
|
|
361
|
+
@Query('username') username: string
|
|
362
|
+
): Promise<ResponseReturn<AvailabilityResponse>> { ... }
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
---
|
|
366
|
+
|
|
367
|
+
## Method Naming Conventions
|
|
368
|
+
|
|
369
|
+
Controller methods should clearly reflect the action and resource:
|
|
370
|
+
|
|
371
|
+
| Pattern | HTTP Method | Example |
|
|
372
|
+
|---------|-------------|---------|
|
|
373
|
+
| `listX` | GET (collection) | `listOrders()` |
|
|
374
|
+
| `getX` / `X` | GET (single) | `getOrder()`, `me()` |
|
|
375
|
+
| `createX` / domain verb | POST | `createOrder()`, `login()`, `register()` |
|
|
376
|
+
| `updateX` | PUT / PATCH | `updateProfile()` |
|
|
377
|
+
| `deleteX` | DELETE | `deleteOrder()` |
|
|
378
|
+
| `verbNoun` | POST (action) | `changePassword()`, `forgotPassword()`, `resendVerification()` |
|
|
379
|
+
|
|
380
|
+
Methods are always `public async` and return `Promise<ResponseReturn<T>>`.
|
|
381
|
+
|
|
382
|
+
---
|
|
383
|
+
|
|
384
|
+
## Rate Limiting
|
|
385
|
+
|
|
386
|
+
Use `@Throttle()` from `@nestjs/throttler` for endpoints that need rate limiting:
|
|
387
|
+
|
|
388
|
+
```typescript
|
|
389
|
+
import { Throttle } from '@nestjs/throttler';
|
|
390
|
+
|
|
391
|
+
@Get('availability')
|
|
392
|
+
@Public()
|
|
393
|
+
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
|
394
|
+
public async checkAvailability(
|
|
395
|
+
@Query('username') username: string
|
|
396
|
+
): Promise<ResponseReturn<AvailabilityResponse>> { ... }
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
Always document the rate limit in the `@Api` responses:
|
|
400
|
+
|
|
401
|
+
```typescript
|
|
402
|
+
responses: [{
|
|
403
|
+
status: HttpStatus.OK,
|
|
404
|
+
description: 'Availability result.'
|
|
405
|
+
}, {
|
|
406
|
+
status: HttpStatus.TOO_MANY_REQUESTS,
|
|
407
|
+
description: 'Rate limit exceeded (max 10 requests per minute).'
|
|
408
|
+
}]
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
---
|
|
412
|
+
|
|
413
|
+
## Social Authentication Pattern
|
|
414
|
+
|
|
415
|
+
Social login uses a guard factory and a two-endpoint flow: initiate → callback.
|
|
416
|
+
|
|
417
|
+
```typescript
|
|
418
|
+
@Public()
|
|
419
|
+
@Get('social/:provider')
|
|
420
|
+
@UseGuards(socialAuthGuardFactory(':provider'))
|
|
421
|
+
@Api({
|
|
422
|
+
operation: {
|
|
423
|
+
operationId: 'socialAuthentication',
|
|
424
|
+
description: 'Redirects the user to the social provider.',
|
|
425
|
+
summary: 'Start social authentication'
|
|
426
|
+
},
|
|
427
|
+
params: [{
|
|
428
|
+
name: 'provider',
|
|
429
|
+
description: 'Authentication provider (github, google, microsoft, apple).',
|
|
430
|
+
required: true,
|
|
431
|
+
type: String
|
|
432
|
+
}],
|
|
433
|
+
responses: [{
|
|
434
|
+
status: HttpStatus.TEMPORARY_REDIRECT,
|
|
435
|
+
description: 'Redirects to the social provider.'
|
|
436
|
+
}]
|
|
437
|
+
})
|
|
438
|
+
public async socialAuthentication(): Promise<void> {
|
|
439
|
+
// Guard handles the redirect flow.
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
@Public()
|
|
443
|
+
@Get('social/:provider/callback')
|
|
444
|
+
@UseGuards(socialAuthGuardFactory(':provider'))
|
|
445
|
+
@Api({
|
|
446
|
+
operation: {
|
|
447
|
+
operationId: 'socialAuthenticationCallback',
|
|
448
|
+
description: 'Handles the callback from the authentication provider.',
|
|
449
|
+
summary: 'Handle social callback'
|
|
450
|
+
},
|
|
451
|
+
params: [{
|
|
452
|
+
name: 'provider',
|
|
453
|
+
description: 'Authentication provider that initiated the callback.',
|
|
454
|
+
required: true,
|
|
455
|
+
type: String
|
|
456
|
+
}],
|
|
457
|
+
responses: [{
|
|
458
|
+
status: HttpStatus.TEMPORARY_REDIRECT,
|
|
459
|
+
description: 'Redirects to the application.'
|
|
460
|
+
}]
|
|
461
|
+
})
|
|
462
|
+
public async socialAuthenticationCallback(
|
|
463
|
+
@Req() req: Request,
|
|
464
|
+
@Res({ passthrough: true }) res: Response
|
|
465
|
+
): Promise<void> {
|
|
466
|
+
const user = req.user as IAuthSubject;
|
|
467
|
+
await new Promise<void>((resolve, reject) => {
|
|
468
|
+
req.login(user, (err) => err ? reject(err) : resolve());
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
const redirectUri = (req.authInfo as { state: { redirectUri?: string } }).state?.redirectUri;
|
|
472
|
+
res.redirect(redirectUri ?? '/');
|
|
473
|
+
}
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
> **Note:** Social callback endpoints are the only place where `@Req()` and `@Res()` are acceptable. In all other endpoints, use typed parameter decorators (`@Body()`, `@Query()`, `@User()`, etc.).
|
|
477
|
+
|
|
478
|
+
---
|
|
479
|
+
|
|
480
|
+
## Complete Controller Example
|
|
481
|
+
|
|
482
|
+
Below is a full authentication controller demonstrating every pattern covered in this guide:
|
|
483
|
+
|
|
484
|
+
```typescript
|
|
485
|
+
// #region Imports
|
|
486
|
+
|
|
487
|
+
import { Body, Controller, Get, HttpStatus, Post, Query, Req, Res, UseGuards } from '@nestjs/common';
|
|
488
|
+
import { Request, Response } from 'express';
|
|
489
|
+
import { Throttle } from '@nestjs/throttler';
|
|
490
|
+
import { Api } from '@breadstone/archipel-platform-openapi';
|
|
491
|
+
import { IpAddress, Public, ResponseReturn, UserAgent } from '@breadstone/archipel-platform-core';
|
|
492
|
+
import {
|
|
493
|
+
type IAuthSubject,
|
|
494
|
+
JwtAuthGuard,
|
|
495
|
+
Token,
|
|
496
|
+
User,
|
|
497
|
+
socialAuthGuardFactory
|
|
498
|
+
} from '@breadstone/archipel-platform-authentication';
|
|
499
|
+
import {
|
|
500
|
+
AuthLoginRequest,
|
|
501
|
+
AuthLoginResponse,
|
|
502
|
+
AuthRefreshResponse,
|
|
503
|
+
AuthRegisterRequest,
|
|
504
|
+
AuthChangePasswordRequest,
|
|
505
|
+
AuthForgotPasswordRequest,
|
|
506
|
+
AuthVerifyPinRequest,
|
|
507
|
+
AuthVerifyResponse,
|
|
508
|
+
UserResponse
|
|
509
|
+
} from '../models';
|
|
510
|
+
|
|
511
|
+
// #endregion
|
|
512
|
+
|
|
513
|
+
@Api({ tags: ['Auth'] })
|
|
514
|
+
@Controller('api/auth')
|
|
515
|
+
export class AuthController {
|
|
516
|
+
|
|
517
|
+
// #region Fields
|
|
518
|
+
|
|
519
|
+
private readonly _userService: UserService;
|
|
520
|
+
private readonly _verificationService: VerificationService;
|
|
521
|
+
|
|
522
|
+
// #endregion
|
|
523
|
+
|
|
524
|
+
// #region Ctor
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Constructs a new instance of the `AuthController` class.
|
|
528
|
+
*
|
|
529
|
+
* @public
|
|
530
|
+
*/
|
|
531
|
+
public constructor(
|
|
532
|
+
userService: UserService,
|
|
533
|
+
verificationService: VerificationService
|
|
534
|
+
) {
|
|
535
|
+
this._userService = userService;
|
|
536
|
+
this._verificationService = verificationService;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// #endregion
|
|
540
|
+
|
|
541
|
+
// #region Methods
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Logs the user in and returns an access token.
|
|
545
|
+
*
|
|
546
|
+
* @public
|
|
547
|
+
*/
|
|
548
|
+
@Post('login')
|
|
549
|
+
@Api({
|
|
550
|
+
operation: {
|
|
551
|
+
operationId: 'login',
|
|
552
|
+
description: 'Logs the user in and returns an access token.',
|
|
553
|
+
summary: 'Authenticate user'
|
|
554
|
+
},
|
|
555
|
+
body: {
|
|
556
|
+
description: 'Credentials used for authentication.',
|
|
557
|
+
type: AuthLoginRequest
|
|
558
|
+
},
|
|
559
|
+
responses: [{
|
|
560
|
+
status: HttpStatus.OK,
|
|
561
|
+
description: 'Access token successfully generated.',
|
|
562
|
+
type: AuthLoginResponse
|
|
563
|
+
}, {
|
|
564
|
+
status: HttpStatus.UNAUTHORIZED,
|
|
565
|
+
description: 'Provided credentials are invalid.'
|
|
566
|
+
}]
|
|
567
|
+
})
|
|
568
|
+
public async login(
|
|
569
|
+
@IpAddress() ipAddress: string,
|
|
570
|
+
@UserAgent() userAgent: string,
|
|
571
|
+
@Body() body: AuthLoginRequest
|
|
572
|
+
): Promise<ResponseReturn<AuthLoginResponse>> {
|
|
573
|
+
const result = await this._userService.signIn(
|
|
574
|
+
body.login, body.password, ipAddress, userAgent
|
|
575
|
+
);
|
|
576
|
+
return ResponseReturn.ok(AuthLoginResponse, {
|
|
577
|
+
accessToken: result.accessToken,
|
|
578
|
+
mfaRequired: result.mfaRequired
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Logs the user out.
|
|
584
|
+
*
|
|
585
|
+
* @public
|
|
586
|
+
*/
|
|
587
|
+
@Post('logout')
|
|
588
|
+
@UseGuards(JwtAuthGuard)
|
|
589
|
+
@Api({
|
|
590
|
+
auth: 'bearer',
|
|
591
|
+
operation: {
|
|
592
|
+
operationId: 'logout',
|
|
593
|
+
description: 'Logs the authenticated user out.',
|
|
594
|
+
summary: 'Log out user'
|
|
595
|
+
},
|
|
596
|
+
responses: [{
|
|
597
|
+
status: HttpStatus.NO_CONTENT,
|
|
598
|
+
description: 'User has been logged out.'
|
|
599
|
+
}, {
|
|
600
|
+
status: HttpStatus.UNAUTHORIZED,
|
|
601
|
+
description: 'Authentication is required.'
|
|
602
|
+
}]
|
|
603
|
+
})
|
|
604
|
+
public async logout(
|
|
605
|
+
@Token() token: string
|
|
606
|
+
): Promise<ResponseReturn<void>> {
|
|
607
|
+
await this._userService.signOut(token);
|
|
608
|
+
return ResponseReturn.noContent();
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Registers a new user.
|
|
613
|
+
*
|
|
614
|
+
* @public
|
|
615
|
+
*/
|
|
616
|
+
@Post('register')
|
|
617
|
+
@Api({
|
|
618
|
+
operation: {
|
|
619
|
+
operationId: 'register',
|
|
620
|
+
description: 'Registers a new user.',
|
|
621
|
+
summary: 'Register user'
|
|
622
|
+
},
|
|
623
|
+
body: {
|
|
624
|
+
description: 'Registration payload.',
|
|
625
|
+
type: AuthRegisterRequest
|
|
626
|
+
},
|
|
627
|
+
responses: [{
|
|
628
|
+
status: HttpStatus.NO_CONTENT,
|
|
629
|
+
description: 'The user has been registered.'
|
|
630
|
+
}, {
|
|
631
|
+
status: HttpStatus.BAD_REQUEST,
|
|
632
|
+
description: 'Email or password are missing.'
|
|
633
|
+
}, {
|
|
634
|
+
status: HttpStatus.CONFLICT,
|
|
635
|
+
description: 'The email address is already registered.'
|
|
636
|
+
}]
|
|
637
|
+
})
|
|
638
|
+
public async register(
|
|
639
|
+
@Body() body: AuthRegisterRequest
|
|
640
|
+
): Promise<ResponseReturn<void>> {
|
|
641
|
+
await this._userService.signUp(body.email, body.password, body.type, {
|
|
642
|
+
avatar: body.profile?.avatar,
|
|
643
|
+
firstName: body.profile?.firstName,
|
|
644
|
+
lastName: body.profile?.lastName,
|
|
645
|
+
userName: body.profile?.userName
|
|
646
|
+
});
|
|
647
|
+
return ResponseReturn.noContent();
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/**
|
|
651
|
+
* Gets the current user.
|
|
652
|
+
*
|
|
653
|
+
* @public
|
|
654
|
+
*/
|
|
655
|
+
@Get('me')
|
|
656
|
+
@UseGuards(JwtAuthGuard)
|
|
657
|
+
@Api({
|
|
658
|
+
auth: 'bearer',
|
|
659
|
+
operation: {
|
|
660
|
+
operationId: 'me',
|
|
661
|
+
description: 'Gets the current user.',
|
|
662
|
+
summary: 'Get current user'
|
|
663
|
+
},
|
|
664
|
+
responses: [{
|
|
665
|
+
status: HttpStatus.OK,
|
|
666
|
+
description: 'Authenticated user profile.',
|
|
667
|
+
type: UserResponse
|
|
668
|
+
}, {
|
|
669
|
+
status: HttpStatus.UNAUTHORIZED,
|
|
670
|
+
description: 'Authentication is required.'
|
|
671
|
+
}]
|
|
672
|
+
})
|
|
673
|
+
public async me(
|
|
674
|
+
@User() user: IAuthSubject
|
|
675
|
+
): Promise<ResponseReturn<UserResponse>> {
|
|
676
|
+
const result = await this._userService.find({ id: user.id });
|
|
677
|
+
return ResponseReturn.ok(UserResponse, result);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Changes the password.
|
|
682
|
+
*
|
|
683
|
+
* @public
|
|
684
|
+
*/
|
|
685
|
+
@Post('change-password')
|
|
686
|
+
@UseGuards(JwtAuthGuard)
|
|
687
|
+
@Api({
|
|
688
|
+
auth: 'bearer',
|
|
689
|
+
operation: {
|
|
690
|
+
operationId: 'changePassword',
|
|
691
|
+
description: 'Changes the authenticated user\'s password.',
|
|
692
|
+
summary: 'Change password'
|
|
693
|
+
},
|
|
694
|
+
body: {
|
|
695
|
+
description: 'Payload containing the new password.',
|
|
696
|
+
type: AuthChangePasswordRequest
|
|
697
|
+
},
|
|
698
|
+
responses: [{
|
|
699
|
+
status: HttpStatus.NO_CONTENT,
|
|
700
|
+
description: 'Password has been changed.'
|
|
701
|
+
}, {
|
|
702
|
+
status: HttpStatus.UNAUTHORIZED,
|
|
703
|
+
description: 'Authentication is required.'
|
|
704
|
+
}]
|
|
705
|
+
})
|
|
706
|
+
public async changePassword(
|
|
707
|
+
@Body() body: AuthChangePasswordRequest,
|
|
708
|
+
@User() user: IAuthSubject
|
|
709
|
+
): Promise<ResponseReturn<void>> {
|
|
710
|
+
await this._userService.changePassword({ id: user.id }, body.password);
|
|
711
|
+
return ResponseReturn.noContent();
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Sends a password reset email.
|
|
716
|
+
*
|
|
717
|
+
* @public
|
|
718
|
+
*/
|
|
719
|
+
@Post('forgot-password')
|
|
720
|
+
@Api({
|
|
721
|
+
operation: {
|
|
722
|
+
operationId: 'forgotPassword',
|
|
723
|
+
description: 'Sends a password reset email.',
|
|
724
|
+
summary: 'Forgot password'
|
|
725
|
+
},
|
|
726
|
+
body: {
|
|
727
|
+
description: 'Payload identifying the account.',
|
|
728
|
+
type: AuthForgotPasswordRequest
|
|
729
|
+
},
|
|
730
|
+
responses: [{
|
|
731
|
+
status: HttpStatus.NO_CONTENT,
|
|
732
|
+
description: 'Password reset instructions sent.'
|
|
733
|
+
}, {
|
|
734
|
+
status: HttpStatus.BAD_REQUEST,
|
|
735
|
+
description: 'Login identifier is missing.'
|
|
736
|
+
}]
|
|
737
|
+
})
|
|
738
|
+
public async forgotPassword(
|
|
739
|
+
@Body() body: AuthForgotPasswordRequest
|
|
740
|
+
): Promise<ResponseReturn<void>> {
|
|
741
|
+
await this._userService.forgotPassword(body.login);
|
|
742
|
+
return ResponseReturn.noContent();
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// #endregion
|
|
746
|
+
|
|
747
|
+
}
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
---
|
|
751
|
+
|
|
752
|
+
## Checklist
|
|
753
|
+
|
|
754
|
+
Before submitting a controller, verify:
|
|
755
|
+
|
|
756
|
+
- [ ] Class has `@Api({ tags })` and `@Controller(path)` decorators
|
|
757
|
+
- [ ] Every method has `@Api()` with `operation` and `responses`
|
|
758
|
+
- [ ] Protected endpoints use `@UseGuards(JwtAuthGuard)` and `@Api({ auth: 'bearer' })`
|
|
759
|
+
- [ ] Public endpoints have `@Public()` decorator
|
|
760
|
+
- [ ] All methods return `Promise<ResponseReturn<T>>`
|
|
761
|
+
- [ ] No business logic — only delegation to services
|
|
762
|
+
- [ ] No direct repository or Prisma calls
|
|
763
|
+
- [ ] Constructor injection with private `readonly` fields
|
|
764
|
+
- [ ] Regions: Fields → Ctor → Methods (no other regions)
|
|
765
|
+
- [ ] TSDoc on every public method
|
|
766
|
+
- [ ] Rate-limited endpoints document `TOO_MANY_REQUESTS` response
|
package/data/guides/index.md
CHANGED
|
@@ -9,13 +9,17 @@ Practical guides for working with Archipel packages in your NestJS application.
|
|
|
9
9
|
|
|
10
10
|
## Foundations
|
|
11
11
|
|
|
12
|
-
| Guide
|
|
13
|
-
|
|
|
14
|
-
| [Getting Started](./getting-started)
|
|
15
|
-
| [Configuration Management](./configuration)
|
|
16
|
-
| [Implementing Ports](./implementing-ports)
|
|
17
|
-
| [
|
|
18
|
-
| [
|
|
12
|
+
| Guide | What you'll learn |
|
|
13
|
+
| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
|
14
|
+
| [Getting Started](./getting-started) | Install packages, register your first module, implement ports, and run the app. |
|
|
15
|
+
| [Configuration Management](./configuration) | Type-safe config keys, strategy-based resolution, central registry, and validation. |
|
|
16
|
+
| [Implementing Ports](./implementing-ports) | How the port/adapter pattern works, how to write adapters, and how to test them. |
|
|
17
|
+
| [Controller Best Practices](./controller-best-practices) | Structure, annotate, and implement controllers — regions, DI, ResponseReturn, guards, and the @Api decorator. |
|
|
18
|
+
| [Request & Response DTOs](./request-response-dtos) | DTO naming conventions, validation decorators, cross-field validation, and class-transformer integration. |
|
|
19
|
+
| [Testing](./testing) | Unit testing services, testing port adapters, integration testing with real databases. |
|
|
20
|
+
| [TSDoc Guidelines](./tsdoc-guidelines) | Documentation standards for public APIs and generated reference pages. ld validation, and class-transformer integration. |
|
|
21
|
+
| [Testing](./testing) | Unit testing services, testing port adapters, integration testing with real databases. |
|
|
22
|
+
| [TSDoc Guidelines](./tsdoc-guidelines) | Documentation standards for public APIs and generated reference pages. |
|
|
19
23
|
|
|
20
24
|
## Security & Identity
|
|
21
25
|
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Request & Response DTOs
|
|
3
|
+
description: Naming conventions, validation decorators, and class-transformer integration for request and response data transfer objects in Archipel NestJS applications.
|
|
4
|
+
order: 4
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Request & Response DTOs
|
|
8
|
+
|
|
9
|
+
This guide covers how to define, validate, and transform request and response DTOs in an Archipel-powered NestJS application. DTOs are the contract between your API and its consumers — they define what goes in and what comes out.
|
|
10
|
+
|
|
11
|
+
For how controllers use DTOs together with `ResponseReturn`, see the [Controller Best Practices](/guides/controller-best-practices) guide.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Naming Conventions
|
|
16
|
+
|
|
17
|
+
DTOs follow a strict naming pattern:
|
|
18
|
+
|
|
19
|
+
| Type | Pattern | Example |
|
|
20
|
+
|------|---------|---------|
|
|
21
|
+
| Create request | `Create{Resource}Request` | `CreateOrderRequest` |
|
|
22
|
+
| Update request | `Update{Resource}Request` | `UpdateProfileRequest` |
|
|
23
|
+
| Action request | `{Action}{Resource}Request` | `AuthLoginRequest`, `AuthVerifyPinRequest` |
|
|
24
|
+
| Single response | `{Resource}Response` | `UserResponse`, `OrderResponse` |
|
|
25
|
+
| List response | `{Resource}ListResponse` | `OrderListResponse` |
|
|
26
|
+
| Action response | `{Action}{Resource}Response` | `AuthLoginResponse`, `AuthRefreshResponse` |
|
|
27
|
+
| Availability / status | `{Resource}{Status}Response` | `AuthUsernameAvailabilityResponse` |
|
|
28
|
+
|
|
29
|
+
### Grouping by Feature
|
|
30
|
+
|
|
31
|
+
When a controller has many DTOs, prefix them with the feature name to keep imports clean:
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
// Auth feature DTOs
|
|
35
|
+
AuthLoginRequest
|
|
36
|
+
AuthLoginResponse
|
|
37
|
+
AuthRegisterRequest
|
|
38
|
+
AuthRefreshResponse
|
|
39
|
+
AuthChangePasswordRequest
|
|
40
|
+
AuthChangeEmailRequest
|
|
41
|
+
AuthForgotPasswordRequest
|
|
42
|
+
AuthVerifyPinRequest
|
|
43
|
+
AuthVerifyResponse
|
|
44
|
+
AuthResendVerificationRequest
|
|
45
|
+
AuthUsernameSuggestionsResponse
|
|
46
|
+
AuthUsernameAvailabilityResponse
|
|
47
|
+
AuthAvatarSuggestionResponse
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
All DTOs for a feature are exported from a `models/` barrel file:
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
// models/index.ts
|
|
54
|
+
export { AuthLoginRequest } from './AuthLoginRequest';
|
|
55
|
+
export { AuthLoginResponse } from './AuthLoginResponse';
|
|
56
|
+
export { AuthRegisterRequest } from './AuthRegisterRequest';
|
|
57
|
+
// ...
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## File Organization
|
|
63
|
+
|
|
64
|
+
**One public export per file.** Each DTO class gets its own file, named after the class.
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
src/
|
|
68
|
+
auth/
|
|
69
|
+
models/
|
|
70
|
+
AuthLoginRequest.ts
|
|
71
|
+
AuthLoginResponse.ts
|
|
72
|
+
AuthRegisterRequest.ts
|
|
73
|
+
AuthRefreshResponse.ts
|
|
74
|
+
UserResponse.ts
|
|
75
|
+
index.ts
|
|
76
|
+
AuthController.ts
|
|
77
|
+
AuthModule.ts
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## Request DTOs (Validation)
|
|
83
|
+
|
|
84
|
+
Request DTOs are classes decorated with `class-validator` and `@ApiProperty` from `@nestjs/swagger`.
|
|
85
|
+
|
|
86
|
+
### Basic Request
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
import { ApiProperty } from '@nestjs/swagger';
|
|
90
|
+
import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator';
|
|
91
|
+
|
|
92
|
+
export class AuthLoginRequest {
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The user's email address or username.
|
|
96
|
+
*
|
|
97
|
+
* @public
|
|
98
|
+
*/
|
|
99
|
+
@ApiProperty({ description: 'Email address or username.' })
|
|
100
|
+
@IsNotEmpty()
|
|
101
|
+
@IsString()
|
|
102
|
+
public login!: string;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The user's password.
|
|
106
|
+
*
|
|
107
|
+
* @public
|
|
108
|
+
*/
|
|
109
|
+
@ApiProperty({ description: 'Account password.' })
|
|
110
|
+
@IsNotEmpty()
|
|
111
|
+
@IsString()
|
|
112
|
+
@MinLength(8)
|
|
113
|
+
public password!: string;
|
|
114
|
+
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Request with Optional Fields
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
122
|
+
import { IsEmail, IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator';
|
|
123
|
+
import { Type } from 'class-transformer';
|
|
124
|
+
|
|
125
|
+
export class AuthRegisterRequest {
|
|
126
|
+
|
|
127
|
+
@ApiProperty({ description: 'Email address for the new account.' })
|
|
128
|
+
@IsNotEmpty()
|
|
129
|
+
@IsEmail()
|
|
130
|
+
public email!: string;
|
|
131
|
+
|
|
132
|
+
@ApiProperty({ description: 'Password for the new account.', minLength: 8 })
|
|
133
|
+
@IsNotEmpty()
|
|
134
|
+
@IsString()
|
|
135
|
+
@MinLength(8)
|
|
136
|
+
public password!: string;
|
|
137
|
+
|
|
138
|
+
@ApiProperty({ description: 'Account type.', example: 'personal' })
|
|
139
|
+
@IsNotEmpty()
|
|
140
|
+
@IsString()
|
|
141
|
+
public type!: string;
|
|
142
|
+
|
|
143
|
+
@ApiPropertyOptional({ description: 'Optional profile information.' })
|
|
144
|
+
@IsOptional()
|
|
145
|
+
@ValidateNested()
|
|
146
|
+
@Type(() => ProfilePayload)
|
|
147
|
+
public profile?: ProfilePayload;
|
|
148
|
+
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Nested Payloads
|
|
153
|
+
|
|
154
|
+
When a request contains nested objects, define them as separate classes with `@ValidateNested()` and `@Type()`:
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
import { ApiPropertyOptional } from '@nestjs/swagger';
|
|
158
|
+
import { IsOptional, IsString } from 'class-validator';
|
|
159
|
+
|
|
160
|
+
export class ProfilePayload {
|
|
161
|
+
|
|
162
|
+
@ApiPropertyOptional()
|
|
163
|
+
@IsOptional()
|
|
164
|
+
@IsString()
|
|
165
|
+
public avatar?: string;
|
|
166
|
+
|
|
167
|
+
@ApiPropertyOptional()
|
|
168
|
+
@IsOptional()
|
|
169
|
+
@IsString()
|
|
170
|
+
public firstName?: string;
|
|
171
|
+
|
|
172
|
+
@ApiPropertyOptional()
|
|
173
|
+
@IsOptional()
|
|
174
|
+
@IsString()
|
|
175
|
+
public lastName?: string;
|
|
176
|
+
|
|
177
|
+
@ApiPropertyOptional()
|
|
178
|
+
@IsOptional()
|
|
179
|
+
@IsString()
|
|
180
|
+
public userName?: string;
|
|
181
|
+
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## Response DTOs (Transformation)
|
|
188
|
+
|
|
189
|
+
Response DTOs use `@ApiProperty` for documentation and `@Expose()` from `class-transformer` for serialization control.
|
|
190
|
+
|
|
191
|
+
### Basic Response
|
|
192
|
+
|
|
193
|
+
```typescript
|
|
194
|
+
import { ApiProperty } from '@nestjs/swagger';
|
|
195
|
+
import { Expose } from 'class-transformer';
|
|
196
|
+
|
|
197
|
+
export class AuthLoginResponse {
|
|
198
|
+
|
|
199
|
+
@ApiProperty({ description: 'JWT access token.' })
|
|
200
|
+
@Expose()
|
|
201
|
+
public accessToken!: string;
|
|
202
|
+
|
|
203
|
+
@ApiProperty({ description: 'Whether multi-factor authentication is required.' })
|
|
204
|
+
@Expose()
|
|
205
|
+
public mfaRequired!: boolean;
|
|
206
|
+
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### Response with Optional Fields
|
|
211
|
+
|
|
212
|
+
```typescript
|
|
213
|
+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
214
|
+
import { Expose } from 'class-transformer';
|
|
215
|
+
|
|
216
|
+
export class AuthLoginResponse {
|
|
217
|
+
|
|
218
|
+
@ApiProperty({ description: 'JWT access token.' })
|
|
219
|
+
@Expose()
|
|
220
|
+
public accessToken!: string;
|
|
221
|
+
|
|
222
|
+
@ApiProperty({ description: 'Whether MFA is required.' })
|
|
223
|
+
@Expose()
|
|
224
|
+
public mfaRequired!: boolean;
|
|
225
|
+
|
|
226
|
+
@ApiProperty({ description: 'Whether email verification is required.' })
|
|
227
|
+
@Expose()
|
|
228
|
+
public verificationRequired!: boolean;
|
|
229
|
+
|
|
230
|
+
@ApiPropertyOptional({ description: 'MFA challenge identifier.' })
|
|
231
|
+
@Expose()
|
|
232
|
+
public challengeId?: string;
|
|
233
|
+
|
|
234
|
+
@ApiPropertyOptional({ description: 'Available MFA methods.', type: [String] })
|
|
235
|
+
@Expose()
|
|
236
|
+
public methods?: Array<string>;
|
|
237
|
+
|
|
238
|
+
@ApiPropertyOptional({ description: 'Preferred MFA method.' })
|
|
239
|
+
@Expose()
|
|
240
|
+
public preferredMethod?: string;
|
|
241
|
+
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### User / Entity Response
|
|
246
|
+
|
|
247
|
+
```typescript
|
|
248
|
+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
249
|
+
import { Expose } from 'class-transformer';
|
|
250
|
+
|
|
251
|
+
export class UserResponse {
|
|
252
|
+
|
|
253
|
+
@ApiProperty({ description: 'Unique user identifier.' })
|
|
254
|
+
@Expose()
|
|
255
|
+
public id!: string;
|
|
256
|
+
|
|
257
|
+
@ApiProperty({ description: 'Email address.' })
|
|
258
|
+
@Expose()
|
|
259
|
+
public email!: string;
|
|
260
|
+
|
|
261
|
+
@ApiPropertyOptional({ description: 'Display username.' })
|
|
262
|
+
@Expose()
|
|
263
|
+
public userName?: string;
|
|
264
|
+
|
|
265
|
+
@ApiPropertyOptional({ description: 'First name.' })
|
|
266
|
+
@Expose()
|
|
267
|
+
public firstName?: string;
|
|
268
|
+
|
|
269
|
+
@ApiPropertyOptional({ description: 'Last name.' })
|
|
270
|
+
@Expose()
|
|
271
|
+
public lastName?: string;
|
|
272
|
+
|
|
273
|
+
@ApiPropertyOptional({ description: 'Avatar URL or base64 image.' })
|
|
274
|
+
@Expose()
|
|
275
|
+
public avatar?: string;
|
|
276
|
+
|
|
277
|
+
@ApiProperty({ description: 'Whether the email is verified.' })
|
|
278
|
+
@Expose()
|
|
279
|
+
public isVerified!: boolean;
|
|
280
|
+
|
|
281
|
+
}
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
286
|
+
## How ResponseReturn Uses DTOs
|
|
287
|
+
|
|
288
|
+
When a controller calls `ResponseReturn.ok(AuthLoginResponse, data)`, the response pipeline:
|
|
289
|
+
|
|
290
|
+
1. Instantiates `AuthLoginResponse` via `class-transformer`'s `plainToInstance()`
|
|
291
|
+
2. Applies `@Expose()` / `@Exclude()` rules — only explicitly exposed fields appear in the output
|
|
292
|
+
3. Wraps the result in the standard envelope: `{ success, status, data, timestamp }`
|
|
293
|
+
|
|
294
|
+
This means the DTO class **controls what the client sees**. Even if the service returns extra fields, they won't leak unless the DTO exposes them.
|
|
295
|
+
|
|
296
|
+
```typescript
|
|
297
|
+
// Service returns: { id, email, password, internalFlag, ... }
|
|
298
|
+
// DTO only exposes: { id, email, userName }
|
|
299
|
+
// Client receives: { success: true, status: 200, data: { id, email, userName }, timestamp: ... }
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
## Validation Pipeline
|
|
305
|
+
|
|
306
|
+
Archipel applications use a global `ValidationPipe` with strict settings:
|
|
307
|
+
|
|
308
|
+
```typescript
|
|
309
|
+
app.useGlobalPipes(new ValidationPipe({
|
|
310
|
+
whitelist: true, // Strip properties not in the DTO
|
|
311
|
+
forbidNonWhitelisted: true, // Throw if unknown properties are sent
|
|
312
|
+
transform: true, // Auto-transform payloads to DTO instances
|
|
313
|
+
}));
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
This means:
|
|
317
|
+
- Properties without decorators are **silently removed** (`whitelist`)
|
|
318
|
+
- Unknown properties cause a **400 Bad Request** (`forbidNonWhitelisted`)
|
|
319
|
+
- Request bodies are automatically transformed to class instances (`transform`)
|
|
320
|
+
|
|
321
|
+
---
|
|
322
|
+
|
|
323
|
+
## Cross-Field Validation
|
|
324
|
+
|
|
325
|
+
When fields depend on each other (e.g., either `email` or `phone` is required, but not both), implement a custom class-level validator:
|
|
326
|
+
|
|
327
|
+
```typescript
|
|
328
|
+
import { ValidatorConstraint, ValidatorConstraintInterface, Validate, ValidationArguments } from 'class-validator';
|
|
329
|
+
|
|
330
|
+
@ValidatorConstraint({ name: 'eitherEmailOrPhone', async: false })
|
|
331
|
+
export class EitherEmailOrPhoneConstraint implements ValidatorConstraintInterface {
|
|
332
|
+
|
|
333
|
+
public validate(_: unknown, args: ValidationArguments): boolean {
|
|
334
|
+
const obj = args.object as { email?: string; phone?: string };
|
|
335
|
+
return Boolean(obj.email) !== Boolean(obj.phone);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
public defaultMessage(): string {
|
|
339
|
+
return 'Exactly one of email or phone must be provided.';
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
@Validate(EitherEmailOrPhoneConstraint)
|
|
345
|
+
export class ContactRequest {
|
|
346
|
+
|
|
347
|
+
@ApiPropertyOptional()
|
|
348
|
+
@IsOptional()
|
|
349
|
+
@IsEmail()
|
|
350
|
+
public email?: string;
|
|
351
|
+
|
|
352
|
+
@ApiPropertyOptional()
|
|
353
|
+
@IsOptional()
|
|
354
|
+
@IsString()
|
|
355
|
+
public phone?: string;
|
|
356
|
+
|
|
357
|
+
}
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
**Never** do cross-field validation in controllers or services — always use class-level validators so it's reusable and testable.
|
|
361
|
+
|
|
362
|
+
---
|
|
363
|
+
|
|
364
|
+
## Constants for Limits
|
|
365
|
+
|
|
366
|
+
Define and reuse constants for bounds that appear in validation and pagination:
|
|
367
|
+
|
|
368
|
+
```typescript
|
|
369
|
+
// constants.ts
|
|
370
|
+
export const MAX_PAGE_SIZE = 100;
|
|
371
|
+
export const MAX_SEARCH_LIMIT = 50;
|
|
372
|
+
export const DEFAULT_PAGE_SIZE = 20;
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
```typescript
|
|
376
|
+
import { Max, Min, IsOptional, IsInt } from 'class-validator';
|
|
377
|
+
import { MAX_PAGE_SIZE, DEFAULT_PAGE_SIZE } from '../constants';
|
|
378
|
+
|
|
379
|
+
export class PaginationQuery {
|
|
380
|
+
|
|
381
|
+
@ApiPropertyOptional({ default: DEFAULT_PAGE_SIZE, maximum: MAX_PAGE_SIZE })
|
|
382
|
+
@IsOptional()
|
|
383
|
+
@IsInt()
|
|
384
|
+
@Min(1)
|
|
385
|
+
@Max(MAX_PAGE_SIZE)
|
|
386
|
+
public limit?: number = DEFAULT_PAGE_SIZE;
|
|
387
|
+
|
|
388
|
+
@ApiPropertyOptional({ default: 0 })
|
|
389
|
+
@IsOptional()
|
|
390
|
+
@IsInt()
|
|
391
|
+
@Min(0)
|
|
392
|
+
public offset?: number = 0;
|
|
393
|
+
|
|
394
|
+
}
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
---
|
|
398
|
+
|
|
399
|
+
## Checklist
|
|
400
|
+
|
|
401
|
+
Before submitting DTOs, verify:
|
|
402
|
+
|
|
403
|
+
- [ ] One DTO class per file, named after the class
|
|
404
|
+
- [ ] Request DTOs have `class-validator` decorators on every property
|
|
405
|
+
- [ ] Response DTOs have `@Expose()` on every public property
|
|
406
|
+
- [ ] Nested objects use `@ValidateNested()` + `@Type()`
|
|
407
|
+
- [ ] All properties have `@ApiProperty` or `@ApiPropertyOptional`
|
|
408
|
+
- [ ] No `any` types — use `unknown` or specific types
|
|
409
|
+
- [ ] Cross-field validation uses class-level validators, not imperative checks
|
|
410
|
+
- [ ] Pagination limits use shared constants with `@Max()`
|
|
411
|
+
- [ ] DTOs exported from a `models/index.ts` barrel
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@breadstone/archipel-mcp",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.17",
|
|
4
4
|
"description": "MCP server providing Archipel platform knowledge — documentation, query patterns, and coding conventions — to AI development tools.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "./src/main.js",
|