Kurs NestJS · Moduł 2: Routing i cykl żądania
PROJEKT - skarbiec trybutów, czyli pełny cykl żądania
W tej lekcji7
Ten moduł przeszedł całą drogę, którą pokonuje żądanie: od trasy, przez middleware, guardy, interceptory i pipes, aż po filtry wyjątków i pełny cykl życia. Każdy mechanizm poznawałeś osobno. Projekt jest miejscem, w którym mają zadziałać jednocześnie - i gdzie okaże się, czy wiesz, który z nich odpowiada za co.
Zbudujesz API skarbca trybutów: rejestr kosztowności imperium, z kontrolą dostępu, walidacją i jednolitą obsługą błędów.
Miara sukcesu nie jest liczbą funkcji
Ten projekt nie ocenia się po tym, ile endpointów napisałeś. Ocenia się po jednym: czy każda odpowiedzialność trafiła do właściwego mechanizmu.
Podział jest ścisły i wynika z tego, co każdy z nich w ogóle może:
- Middleware - logowanie, identyfikator korelacji, nagłówki CORS. Nie zna trasy docelowej, więc nie wolno mu decydować o dostępie.
- Guard - kto ma prawo wejść. Zna handler, zwraca wartość logiczną.
- Pipe - jeden argument metody: walidacja i transformacja.
- Interceptor - kształt odpowiedzi i pomiar czasu; jedyny działa w obie strony.
- Exception filter - jednolita postać błędu wychodzącego do klienta.
- Własny dekorator - skrót na powtarzalne wyciąganie danych z kontekstu.
Dwa najczęstsze błędy wynikają z pomylenia tych ról. Autoryzacja w middleware nie może działać poprawnie, bo w tym momencie NestJS nie wie jeszcze, który endpoint obsłuży żądanie - a więc i jakiej roli wymaga. Walidacja w serwisie działa, ale jest w złym miejscu: powtarza się w każdej metodzie i wykonuje po tym, jak dane przeszły już przez pół aplikacji.
Krok 1 - trasy i kontroler
Zacznij od tego, co widać z zewnątrz:
1@Controller('tributes')
2export class TributesController {
3 @Get(':id')
4 findOne(@Param('id', ParseIntPipe) id: number) {
5 return this.tributesService.findOne(id);
6 }
7
8 @Post()
9 @UseGuards(AuthGuard, RolesGuard)
10 @Roles('quaestor')
11 create(@Body() dto: CreateTributeDto, @CurrentUser() user: User) {
12 return this.tributesService.create(dto, user);
13 }
14}Dwie rzeczy w tym kodzie są celowe. ParseIntPipe przy @Param gwarantuje, że do metody trafi number, więc adnotacja typu nie jest życzeniem. @CurrentUser() to twój własny dekorator - bez niego każda metoda musiałaby sięgać po request.user przez @Req(), powtarzając ten sam kod w kilkunastu miejscach.
Krok 2 - konfiguracja globalna
To, co ma obowiązywać wszędzie, rejestrujesz raz w main.ts:
1async function bootstrap() {
2 const app = await NestFactory.create(AppModule);
3
4 app.useGlobalPipes(
5 new ValidationPipe({
6 whitelist: true,
7 forbidNonWhitelisted: true,
8 transform: true,
9 }),
10 );
11
12 app.useGlobalInterceptors(new TransformInterceptor());
13 app.useGlobalFilters(new HttpExceptionFilter());
14
15 await app.listen(3000);
16}Rejestracja globalna to decyzja, nie wygoda. ValidationPipe musi obowiązywać wszędzie, bo endpoint bez walidacji jest luką, a nie wyjątkiem. TransformInterceptor też - inaczej połowa API zwracałaby kopertę { success, data }, a połowa surowy obiekt, i każdy klient musiałby obsłużyć oba przypadki.
Guardów nie rejestruj globalnie w tym projekcie. Skarbiec ma endpointy publiczne (odczyt katalogu) i chronione (zapis) - a globalny guard z wyjątkami przez @Public() to rozwiązanie na większy system niż ten.
Krok 3 - guard i metadane
Kontrola dostępu opiera się na parze: dekorator zapisuje wymaganie, guard je odczytuje.
1export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
2
3@Injectable()
4export class RolesGuard implements CanActivate {
5 constructor(private reflector: Reflector) {}
6
7 canActivate(context: ExecutionContext): boolean {
8 const required = this.reflector.get<string[]>('roles', context.getHandler());
9
10 if (!required) {
11 return true;
12 }
13
14 const { user } = context.switchToHttp().getRequest();
15
16 return required.includes(user?.role);
17 }
18}Zwróć uwagę na kolejność guardów w @UseGuards(AuthGuard, RolesGuard). Są sprawdzane od lewej, więc AuthGuard ustawia request.user, zanim RolesGuard po niego sięgnie. Odwrócenie tej pary daje guard porównujący rolę użytkownika, którego jeszcze nie ma - i odmowę dostępu dla wszystkich.
Krok 4 - interceptor i filtr wyjątków
Ostatnia para pilnuje tego, co wychodzi:
1@Injectable()
2export class TransformInterceptor implements NestInterceptor {
3 intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
4 const start = Date.now();
5
6 return next.handle().pipe(
7 tap(() => console.log('czas:', Date.now() - start, 'ms')),
8 map((data) => ({ success: true, data })),
9 );
10 }
11}
12
13@Catch(HttpException)
14export class HttpExceptionFilter implements ExceptionFilter {
15 catch(exception: HttpException, host: ArgumentsHost) {
16 const response = host.switchToHttp().getResponse();
17 const status = exception.getStatus();
18
19 response.status(status).json({
20 success: false,
21 message: exception.message,
22 timestamp: new Date().toISOString(),
23 });
24 }
25}Zauważ, że oba składają odpowiedź w tym samym kształcie: success plus treść. To nie przypadek - klient ma rozróżniać powodzenie od błędu jednym polem, niezależnie od tego, która ścieżka zadziałała. Rozjazd między nimi zmusza autora klienta do zgadywania.
W interceptorze tap i map stoją w jednym .pipe() i robią co innego: pierwszy podgląda wartość, żeby zmierzyć czas, drugi ją podmienia.
Kolejność, którą musisz umieć odtworzyć
Sprawdzianem zrozumienia tego modułu jest jedno pytanie: co się dzieje i w jakiej kolejności?
Middleware → guard → interceptor (przed) → pipe → handler → interceptor (po) → exception filter.
Wynikają z tego konsekwencje, które zobaczysz w swoim kodzie. Guard odrzucający żądanie sprawia, że pipe nie zwaliduje niczego, a handler się nie uruchomi - ale middleware już się wykonało i wpis w logu jest. Wyjątek rzucony przez pipe omija tap() i map() interceptora i trafia do filtra - więc czas takiego żądania nie zostanie zmierzony (chyba że interceptor ma też catchError albo finalize).
Co oddajesz
Projekt jest gotowy, gdy zawiera:
- Kontroler z pełnym CRUD-em i parametrami trasy obsłużonymi przez
ParseIntPipe. - Middleware logujące metodę, ścieżkę i identyfikator korelacji, zarejestrowane przez
NestModule. - Dwa guardy - uwierzytelnienia i ról - z rolami zapisanymi przez
SetMetadatai odczytanymi przezReflector. - Globalny
ValidationPipez DTO dla każdego wejścia. - Interceptor ujednolicający odpowiedź i mierzący czas.
- Filtr wyjątków zwracający błędy w tym samym kształcie co odpowiedzi udane.
- Własny dekorator
@CurrentUser()używany zamiast@Req().
Na koniec wykonaj próbę, która sprawdza całość naraz: wyślij żądanie POST bez tokenu i z niepoprawnym ciałem jednocześnie. Musisz dostać 403, nie 400 - bo guard biegnie przed pipe'em. Jeśli dostajesz 400, twoja walidacja dzieje się za wcześnie i przecieka informacja o tym, które pola są wymagane, komuś, kto nie ma prawa wywołać tego endpointu.
Prześlij link do repozytorium, gdy skończysz.
Kod do tej lekcji: src/tribute-project.ts
1// PROJEKT: System Zarządzania Trybutami Imperium
2import {
3 Module, Controller, Injectable, Get, Post, Put,
4 Body, Param,
5} from '@nestjs/common';
6
7console.log("=== PROJEKT: ImperiumTributeVault ===");
8
9// ===========================================
10// 1. Model trybutu
11// ===========================================
12
13interface Tribute {
14 id: number;
15 name: string;
16 province: string;
17 amount: number;
18 type: 'gold' | 'silver' | 'goods';
19 collector: string;
20 collectedAt: Date;
21 verified: boolean;
22}
23
24// ===========================================
25// 2. Serwis trybutów z pełnym CRUD
26// ===========================================
27
28@Injectable()
29export class TributeService {
30 private tributes: Tribute[] = [
31 {
32 id: 1, name: 'Aurum Galliae', province: 'Gallia',
33 amount: 50000, type: 'gold', collector: 'Marcus',
34 collectedAt: new Date(), verified: true,
35 },
36 {
37 id: 2, name: 'Argentum Hispaniae', province: 'Hispania',
38 amount: 30000, type: 'silver', collector: 'Titus',
39 collectedAt: new Date(), verified: false,
40 },
41 ];
42
43 findAll(filters?: { province?: string; type?: string }) {
44 let result = this.tributes;
45 if (filters?.province) {
46 result = result.filter(t => t.province === filters.province);
47 }
48 if (filters?.type) {
49 result = result.filter(t => t.type === filters.type);
50 }
51 return result;
52 }
53
54 findById(id: number) {
55 return this.tributes.find(t => t.id === id);
56 }
57
58 create(data: Omit<Tribute, 'id' | 'collectedAt' | 'verified'>) {
59 const tribute = {
60 id: this.tributes.length + 1,
61 ...data,
62 collectedAt: new Date(),
63 verified: false,
64 };
65 this.tributes.push(tribute);
66 return tribute;
67 }
68
69 verify(id: number) {
70 const tribute = this.findById(id);
71 if (tribute) tribute.verified = true;
72 return tribute;
73 }
74
75 getStatistics() {
76 return {
77 total: this.tributes.reduce((sum, t) => sum + t.amount, 0),
78 byType: {
79 gold: this.tributes.filter(t => t.type === 'gold').length,
80 silver: this.tributes.filter(t => t.type === 'silver').length,
81 goods: this.tributes.filter(t => t.type === 'goods').length,
82 },
83 verified: this.tributes.filter(t => t.verified).length,
84 pending: this.tributes.filter(t => !t.verified).length,
85 };
86 }
87}
88
89// ===========================================
90// 3. Kontroler trybutów
91// ===========================================
92
93@Controller('tributes')
94export class TributeController {
95 constructor(private tributeService: TributeService) {}
96
97 @Get()
98 findAll() { return this.tributeService.findAll(); }
99
100 @Get('stats')
101 getStats() { return this.tributeService.getStatistics(); }
102
103 @Get(':id')
104 findOne(@Param('id') id: string) { return this.tributeService.findById(+id); }
105
106 @Post()
107 create(@Body() data: any) { return this.tributeService.create(data); }
108
109 @Put(':id/verify')
110 verify(@Param('id') id: string) { return this.tributeService.verify(+id); }
111}
112
113// ===========================================
114// Moduł projektu
115// ===========================================
116
117@Module({
118 controllers: [TributeController],
119 providers: [TributeService],
120})
121export class TributeModule {}
122
123console.log("\n=== STRUKTURA PROJEKTU ===");
124console.log("TributeModule - moduł główny");
125console.log("TributeService - logika biznesowa (CRUD + statystyki)");
126console.log("TributeController - endpointy REST API");
127console.log("\nProjekt łączy moduł, serwis i kontroler; guardy, interceptor i filtr dodasz według lekcji projektowej.");
128