From 3fc6944bc04a2807c76c781204dbe4f04a47622a Mon Sep 17 00:00:00 2001 From: christopher Date: Fri, 19 Jun 2026 11:42:15 +0100 Subject: [PATCH] feat(interceptors): add ResponseValidationInterceptor for response DTO validation --- .../decorators/response-dto.decorator.ts | 10 +++ .../response-validation.interceptor.spec.ts | 50 +++++++++++++++ .../response-validation.interceptor.ts | 61 +++++++++++++++++++ app/backend/src/main.ts | 3 +- .../src/usernames/usernames.controller.ts | 6 ++ 5 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 app/backend/src/common/decorators/response-dto.decorator.ts create mode 100644 app/backend/src/common/interceptors/response-validation.interceptor.spec.ts create mode 100644 app/backend/src/common/interceptors/response-validation.interceptor.ts diff --git a/app/backend/src/common/decorators/response-dto.decorator.ts b/app/backend/src/common/decorators/response-dto.decorator.ts new file mode 100644 index 000000000..54e793d30 --- /dev/null +++ b/app/backend/src/common/decorators/response-dto.decorator.ts @@ -0,0 +1,10 @@ +import { SetMetadata } from "@nestjs/common"; + +export const RESPONSE_DTO_KEY = "response:dto"; + +/** + * Attach a response DTO class to a route handler or controller. + * The global ResponseValidationInterceptor will validate outgoing + * responses against this DTO and fail-fast if the shape drifts. + */ +export const ResponseDto = (dto: unknown) => SetMetadata(RESPONSE_DTO_KEY, dto); diff --git a/app/backend/src/common/interceptors/response-validation.interceptor.spec.ts b/app/backend/src/common/interceptors/response-validation.interceptor.spec.ts new file mode 100644 index 000000000..bdcee059e --- /dev/null +++ b/app/backend/src/common/interceptors/response-validation.interceptor.spec.ts @@ -0,0 +1,50 @@ +import "reflect-metadata"; +import { ResponseValidationInterceptor } from "./response-validation.interceptor"; +import { RESPONSE_DTO_KEY } from "../decorators/response-dto.decorator"; +import { of, lastValueFrom } from "rxjs"; +import { InternalServerErrorException } from "@nestjs/common"; +import { plainToInstance } from "class-transformer"; +import { IsString } from "class-validator"; + +class TestDto { + @IsString() + name!: string; +} + +describe("ResponseValidationInterceptor", () => { + const interceptor = new ResponseValidationInterceptor(); + + it("passes when response matches DTO", async () => { + const handler = () => {}; + Reflect.defineMetadata(RESPONSE_DTO_KEY, TestDto, handler); + + const context: any = { + getHandler: () => handler, + getClass: () => ({}), + }; + + const result$ = interceptor.intercept(context as any, { + handle: () => of({ name: "Alice" }), + } as any); + + const out = await lastValueFrom(result$); + expect(out).toBeInstanceOf(TestDto); + expect((out as any).name).toEqual("Alice"); + }); + + it("throws when response is missing required fields", async () => { + const handler = () => {}; + Reflect.defineMetadata(RESPONSE_DTO_KEY, TestDto, handler); + + const context: any = { + getHandler: () => handler, + getClass: () => ({}), + }; + + const result$ = interceptor.intercept(context as any, { + handle: () => of({}), + } as any); + + await expect(lastValueFrom(result$)).rejects.toThrow(InternalServerErrorException); + }); +}); diff --git a/app/backend/src/common/interceptors/response-validation.interceptor.ts b/app/backend/src/common/interceptors/response-validation.interceptor.ts new file mode 100644 index 000000000..a1c1f37c1 --- /dev/null +++ b/app/backend/src/common/interceptors/response-validation.interceptor.ts @@ -0,0 +1,61 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, + InternalServerErrorException, +} from "@nestjs/common"; +import { Observable } from "rxjs"; +import { map } from "rxjs/operators"; +import { plainToInstance } from "class-transformer"; +import { validateSync } from "class-validator"; +import { Reflector } from "@nestjs/core"; +import { RESPONSE_DTO_KEY } from "../decorators/response-dto.decorator"; + +@Injectable() +export class ResponseValidationInterceptor implements NestInterceptor { + constructor(private readonly reflector = new Reflector()) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const handler = context.getHandler(); + const controller = context.getClass(); + + const dto = this.reflector.get(RESPONSE_DTO_KEY, handler) || + this.reflector.get(RESPONSE_DTO_KEY, controller); + + if (!dto) { + return next.handle(); + } + + return next.handle().pipe( + map((data) => { + // Validate arrays of items as a whole by mapping each element. + if (Array.isArray(data)) { + const instances = data.map((d) => plainToInstance(dto, d)); + const allErrors = instances + .map((inst) => validateSync(inst as object, { whitelist: true })) + .flat(); + if (allErrors.length > 0) { + throw new InternalServerErrorException({ + code: "RESPONSE_VALIDATION_ERROR", + message: "Response validation failed (array)", + details: allErrors, + }); + } + return instances; + } + + const instance = plainToInstance(dto, data); + const errors = validateSync(instance as object, { whitelist: true }); + if (errors.length > 0) { + throw new InternalServerErrorException({ + code: "RESPONSE_VALIDATION_ERROR", + message: "Response validation failed", + details: errors, + }); + } + return instance; + }), + ); + } +} diff --git a/app/backend/src/main.ts b/app/backend/src/main.ts index c6143f352..35a403e34 100644 --- a/app/backend/src/main.ts +++ b/app/backend/src/main.ts @@ -12,6 +12,7 @@ import helmet from "helmet"; import { WinstonModule } from "nest-winston"; import { winstonConfig } from "./common/logging/winston.config"; import { LoggingInterceptor } from "./common/interceptors/logging.interceptor"; +import { ResponseValidationInterceptor } from "./common/interceptors/response-validation.interceptor"; // ---------------------------------------- import { buildCorsOptions } from "./config/cors.config"; @@ -130,7 +131,7 @@ async function bootstrap() { }), ); - app.useGlobalInterceptors(new LoggingInterceptor()); + app.useGlobalInterceptors(new LoggingInterceptor(), new ResponseValidationInterceptor()); // Register Sentry exception filter FIRST so it captures errors, // then the existing HTTP exception filter handles the response. diff --git a/app/backend/src/usernames/usernames.controller.ts b/app/backend/src/usernames/usernames.controller.ts index d98f2fa22..649e6dd36 100644 --- a/app/backend/src/usernames/usernames.controller.ts +++ b/app/backend/src/usernames/usernames.controller.ts @@ -33,6 +33,7 @@ import { RecentlyActiveResponseDto, PublicProfileDto, } from "../dto"; +import { ResponseDto } from "../common/decorators/response-dto.decorator"; import { UsernamesService } from "./usernames.service"; import { UsernameConflictError, @@ -50,6 +51,7 @@ export class UsernamesController { ) {} @Post() + @ResponseDto(CreateUsernameResponseDto) @ApiOperation({ summary: "Create a new username", description: @@ -116,6 +118,7 @@ export class UsernamesController { } @Get() + @ResponseDto(ListUsernamesResponseDto) @ApiOperation({ summary: "List usernames for a wallet", description: @@ -146,6 +149,7 @@ export class UsernamesController { @Get("search") @Throttle({ default: { limit: 20, ttl: 60000 } }) // 20 requests per minute + @ResponseDto(SearchUsernamesResponseDto) @ApiOperation({ summary: "Search public profiles", description: @@ -199,6 +203,7 @@ export class UsernamesController { @Get("trending") @Throttle({ default: { limit: 10, ttl: 60000 } }) // 10 requests per minute + @ResponseDto(TrendingCreatorsResponseDto) @ApiOperation({ summary: "Get trending creators", description: @@ -254,6 +259,7 @@ export class UsernamesController { @Get("recently-active") @Throttle({ default: { limit: 10, ttl: 60000 } }) // 10 requests per minute + @ResponseDto(RecentlyActiveResponseDto) @ApiOperation({ summary: "Get recently active users", description: