Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions app/backend/src/common/decorators/response-dto.decorator.ts
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -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";

Check failure on line 6 in app/backend/src/common/interceptors/response-validation.interceptor.spec.ts

View workflow job for this annotation

GitHub Actions / Build and Test

'plainToInstance' is defined but never used
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 = {

Check failure on line 21 in app/backend/src/common/interceptors/response-validation.interceptor.spec.ts

View workflow job for this annotation

GitHub Actions / Build and Test

Unexpected any. Specify a different type
getHandler: () => handler,
getClass: () => ({}),
};

const result$ = interceptor.intercept(context as any, {

Check failure on line 26 in app/backend/src/common/interceptors/response-validation.interceptor.spec.ts

View workflow job for this annotation

GitHub Actions / Build and Test

Unexpected any. Specify a different type
handle: () => of({ name: "Alice" }),
} as any);

Check failure on line 28 in app/backend/src/common/interceptors/response-validation.interceptor.spec.ts

View workflow job for this annotation

GitHub Actions / Build and Test

Unexpected any. Specify a different type

const out = await lastValueFrom(result$);
expect(out).toBeInstanceOf(TestDto);
expect((out as any).name).toEqual("Alice");

Check failure on line 32 in app/backend/src/common/interceptors/response-validation.interceptor.spec.ts

View workflow job for this annotation

GitHub Actions / Build and Test

Unexpected any. Specify a different type
});

it("throws when response is missing required fields", async () => {
const handler = () => {};
Reflect.defineMetadata(RESPONSE_DTO_KEY, TestDto, handler);

const context: any = {

Check failure on line 39 in app/backend/src/common/interceptors/response-validation.interceptor.spec.ts

View workflow job for this annotation

GitHub Actions / Build and Test

Unexpected any. Specify a different type
getHandler: () => handler,
getClass: () => ({}),
};

const result$ = interceptor.intercept(context as any, {

Check failure on line 44 in app/backend/src/common/interceptors/response-validation.interceptor.spec.ts

View workflow job for this annotation

GitHub Actions / Build and Test

Unexpected any. Specify a different type
handle: () => of({}),
} as any);

Check failure on line 46 in app/backend/src/common/interceptors/response-validation.interceptor.spec.ts

View workflow job for this annotation

GitHub Actions / Build and Test

Unexpected any. Specify a different type

await expect(lastValueFrom(result$)).rejects.toThrow(InternalServerErrorException);
});
});
Original file line number Diff line number Diff line change
@@ -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<any> {

Check failure on line 19 in app/backend/src/common/interceptors/response-validation.interceptor.ts

View workflow job for this annotation

GitHub Actions / Build and Test

Unexpected any. Specify a different type
const handler = context.getHandler();
const controller = context.getClass();

const dto = this.reflector.get<any>(RESPONSE_DTO_KEY, handler) ||

Check failure on line 23 in app/backend/src/common/interceptors/response-validation.interceptor.ts

View workflow job for this annotation

GitHub Actions / Build and Test

Unexpected any. Specify a different type
this.reflector.get<any>(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;
}),
);
}
}
3 changes: 2 additions & 1 deletion app/backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions app/backend/src/usernames/usernames.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
RecentlyActiveResponseDto,
PublicProfileDto,
} from "../dto";
import { ResponseDto } from "../common/decorators/response-dto.decorator";
import { UsernamesService } from "./usernames.service";
import {
UsernameConflictError,
Expand All @@ -50,6 +51,7 @@ export class UsernamesController {
) {}

@Post()
@ResponseDto(CreateUsernameResponseDto)
@ApiOperation({
summary: "Create a new username",
description:
Expand Down Expand Up @@ -116,6 +118,7 @@ export class UsernamesController {
}

@Get()
@ResponseDto(ListUsernamesResponseDto)
@ApiOperation({
summary: "List usernames for a wallet",
description:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
Loading