We use cookies to enhance your experience on the site
CodeWorlds

PROJECT - the tribute vault, or the full request cycle

This module travelled the whole road a request takes: from the route, through middleware, guards, interceptors and pipes, as far as exception filters and the complete lifecycle. You met each mechanism separately. The project is where they must work at once - and where it will show whether you know which of them answers for what.

You will build a tribute vault API: a register of the Empire's valuables, with access control, validation and a uniform handling of errors.

The measure of success is not a count of features

This project is not judged by how many endpoints you wrote. It is judged by one thing: whether every responsibility landed in the right mechanism.

The division is strict and follows from what each of them can do at all:

  • Middleware - logging, a correlation identifier, CORS headers. It does not know the target route, so it must not decide about access.
  • A guard - who may enter. It knows the handler and returns a boolean.
  • A pipe - one argument of a method: validation and transformation.
  • An interceptor - the shape of the response and timing; the only one that works in both directions.
  • An exception filter - a uniform form for the error going out to the client.
  • A custom decorator - a shorthand for repeatedly digging data out of the context.

The two commonest mistakes come from confusing these roles. Authorisation in middleware cannot work correctly, because at that moment NestJS does not yet know which endpoint will handle the request - and so what role it demands. Validation in a service does work, but it is in the wrong place: it repeats in every method and runs after the data has already passed through half the application.

Step 1 - routes and the controller

Begin with what is visible from outside:

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}

Two things in this code are deliberate.

ParseIntPipe
on
@Param
guarantees that a
number
reaches the method, so the type annotation is not a wish. And
@CurrentUser()
is your own decorator - without it every method would have to reach for
request.user
through
@Req()
, repeating the same code in a dozen places.

Step 2 - global configuration

Whatever is to hold everywhere you register once, in

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}

Global registration is a decision, not a convenience.

ValidationPipe
must hold everywhere, because an endpoint without validation is a hole rather than an exception. So must
TransformInterceptor
- otherwise half the API would return a
{ success, data }
envelope and half a raw object, and every client would have to handle both cases.

Do not register the guards globally in this project. The vault has public endpoints (reading the catalogue) and protected ones (writing) - and a global guard with exceptions through

@Public()
suits a larger system than this one.

Step 3 - the guard and metadata

Access control rests on a pair: a decorator records the requirement, a guard reads it.

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}

Note the order of the guards in

@UseGuards(AuthGuard, RolesGuard)
. They are checked from the left, so
AuthGuard
sets
request.user
before
RolesGuard
reaches for it. Reversing that pair gives you a guard comparing the role of a user who does not exist yet - and a refusal for everybody.

Step 4 - the interceptor and the exception filter

The last pair watches what goes out:

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('time:', 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}

Notice that both assemble the response in the same shape:

success
plus content. That is no accident - the client should tell success from failure by one field, whichever path ran. A divergence between them forces whoever writes the client to guess.

In the interceptor,

tap
and
map
stand in one
.pipe()
and do different things: the first watches the value in order to measure the time, the second replaces it.

The order you must be able to recite

The test of understanding for this module is a single question: what happens, and in what order?

Middleware → guard → interceptor (before) → pipe → handler → interceptor (after) → exception filter.

Consequences follow that you will see in your own code. A guard rejecting a request means the pipe validates nothing and the handler never runs - but the middleware has already run and the log entry exists. An exception thrown by a pipe goes straight to the filter, skipping the interceptor's "after" part - so such a request's time is never measured.

What you hand in

The project is finished when it contains:

  1. A controller with full CRUD and route parameters handled by
    ParseIntPipe
    .
  2. Middleware logging the method, the path and a correlation identifier, registered through
    NestModule
    .
  3. Two guards - authentication and roles - with roles recorded by
    SetMetadata
    and read by
    Reflector
    .
  4. A global
    ValidationPipe
    with a DTO for every input.
  5. An interceptor unifying the response and measuring time.
  6. An exception filter returning errors in the same shape as successful responses.
  7. A custom decorator
    @CurrentUser()
    used in place of
    @Req()
    .

Finish with a trial that tests the whole at once: send a

POST
request with no token and an invalid body at the same time. You must get a
403
, not a
400
- because the guard runs before the pipe, @name. If you get a
400
, your validation happens too early and leaks which fields are required to somebody with no right to call that endpoint at all.

Send the link to your repository when you are done.

Go to CodeWorlds