Używamy cookies, żeby zwiększyć Twoje doświadczenia na stronie
CodeWorlds

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

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.

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 trafia prosto do filtra, z pominięciem części "po" interceptora - więc czas takiego żądania nie zostanie zmierzony.

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, @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.

Przejdź do CodeWorlds