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 tributów: rejestr kosztowności imperium, z kontrolą dostępu, walidacją i jednolitą obsługą błędów.
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:
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.
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.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.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.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.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 trafia prosto do filtra, z pominięciem części "po" interceptora - więc czas takiego żądania nie zostanie zmierzony.
Projekt jest gotowy, gdy zawiera:
ParseIntPipe.NestModule.SetMetadata i odczytanymi przez Reflector.ValidationPipe z DTO dla każdego wejścia.@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, @name. 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.