Kurs NestJS · Moduł 2: Routing i cykl żądania

PROJEKT - skarbiec trybutów, czyli pełny cykl żądania

5 min czytania
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:

  1. Kontroler z pełnym CRUD-em i parametrami trasy obsłużonymi przez ParseIntPipe.
  2. Middleware logujące metodę, ścieżkę i identyfikator korelacji, zarejestrowane przez NestModule.
  3. Dwa guardy - uwierzytelnienia i ról - z rolami zapisanymi przez SetMetadata i odczytanymi przez Reflector.
  4. Globalny ValidationPipe z DTO dla każdego wejścia.
  5. Interceptor ujednolicający odpowiedź i mierzący czas.
  6. Filtr wyjątków zwracający błędy w tym samym kształcie co odpowiedzi udane.
  7. 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

Przydatne artykuły