From 27f477c516c913ba07dd9468615192f566cf7a56 Mon Sep 17 00:00:00 2001 From: gbengaeben Date: Tue, 28 Jul 2026 23:35:05 +0000 Subject: [PATCH] fix: standardize API validation error format with error codes (#967) - Add ValidationExceptionFilter that catches BadRequestException - Output standardized JSON: { success, statusCode, errors: [{ field, message, code, constraints }] } - Field names follow camelCase convention (class-validator defaults) - Auto-infer error codes (IS_EMAIL, MIN_LENGTH, IS_NOT_EMPTY, etc.) - i18n-aware with language resolution from Accept-Language / user preference - Register globally via APP_FILTER in AppModule Closes #967 --- src/app.module.ts | 2 + .../filters/validation-exception.filter.ts | 222 ++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 src/common/filters/validation-exception.filter.ts diff --git a/src/app.module.ts b/src/app.module.ts index d865974..ed063d5 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -50,6 +50,7 @@ import { I18nModule } from './i18n/i18n.module'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { PrismaExceptionFilter } from './common/filters/prisma-exception.filter'; import { AllExceptionsFilter } from './common/filters/all-exceptions.filter'; +import { ValidationExceptionFilter } from './common/filters/validation-exception.filter'; import { APP_FILTER } from '@nestjs/core'; @Module({ @@ -106,6 +107,7 @@ import { APP_FILTER } from '@nestjs/core'; CleanupService, // Issue #964 – Register the i18n-aware exception filters globally via // APP_FILTER so NestJS injects I18nService into them. + { provide: APP_FILTER, useClass: ValidationExceptionFilter }, { provide: APP_FILTER, useClass: PrismaExceptionFilter }, { provide: APP_FILTER, useClass: HttpExceptionFilter }, { provide: APP_FILTER, useClass: AllExceptionsFilter }, diff --git a/src/common/filters/validation-exception.filter.ts b/src/common/filters/validation-exception.filter.ts new file mode 100644 index 0000000..fa87b9c --- /dev/null +++ b/src/common/filters/validation-exception.filter.ts @@ -0,0 +1,222 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + BadRequestException, + Logger, +} from '@nestjs/common'; +import { Request, Response } from 'express'; +import { I18nService, SupportedLanguage } from '../../i18n/i18n.service'; + +interface StandardizedValidationError { + field: string; + message: string; + code: string; + constraints?: Record; +} + +interface AuthenticatedUserShape { + languagePreference?: string | null; +} + +/** + * Catches `BadRequestException` (HTTP 400) errors and transforms them into a + * standardized validation error format: + * + * ```json + * { + * "success": false, + * "statusCode": 400, + * "timestamp": "2026-07-28T12:00:00.000Z", + * "path": "/api/v1/auth/register", + * "errors": [ + * { "field": "email", "message": "email must be a valid email", "code": "IS_EMAIL", "constraints": { "isEmail": "email must be a valid email" } } + * ] + * } + * ``` + * + * @see https://github.com/MettaChain/PropChain-BackEnd/issues/967 + */ +@Catch(BadRequestException) +export class ValidationExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(ValidationExceptionFilter.name); + + constructor(private readonly i18n: I18nService) {} + + catch(exception: BadRequestException, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + const status = exception.getStatus(); + const exceptionResponse = exception.getResponse(); + const language = this.resolveLanguage(request); + + // Determine whether this is a validation error (thrown by ValidationPipe) + // or a regular BadRequestException with a string message. + if (this.isValidationErrorResponse(exceptionResponse)) { + const rawErrors = (exceptionResponse as { message: string[] }).message; + const standardized = this.standardize(rawErrors, language); + + response.status(status).json({ + success: false, + statusCode: status, + timestamp: new Date().toISOString(), + path: request.url, + language, + errors: standardized, + }); + } else { + // For non-validation BadRequestExceptions, delegate to the standard format + // but still use a consistent shape. + const message = + typeof exceptionResponse === 'string' + ? exceptionResponse + : (exceptionResponse as { message?: string }).message ?? 'Bad Request'; + + response.status(status).json({ + success: false, + statusCode: status, + timestamp: new Date().toISOString(), + path: request.url, + language, + message: this.translateIfI18nKey(message, language), + }); + } + } + + /** + * Transforms class-validator constraint messages into a standardized array. + * + * Each entry maps to `{ field, message, code, constraints }`. + * Field names remain camelCase by default (class-validator uses the DTO property name). + */ + private standardize( + rawMessages: string[], + language: SupportedLanguage, + ): StandardizedValidationError[] { + return rawMessages.map((raw) => { + const parsed = this.parseValidationMessage(raw); + return { + field: parsed.field, + message: this.translateIfI18nKey(parsed.message, language), + code: parsed.code, + constraints: parsed.constraints, + }; + }); + } + + /** + * Attempts to extract `field`, `code`, and `message` from a class-validator + * constraint string. + * + * Examples: + * "email must be a valid email" → { field: "email", code: "IS_EMAIL", message: "email must be a valid email" } + * "password must be longer than or equal to 8 characters" → { field: "password", code: "MIN_LENGTH", message: "..." } + */ + private parseValidationMessage(raw: string): { + field: string; + code: string; + message: string; + constraints?: Record; + } { + const parts = raw.split(' '); + const field = parts[0] ?? 'unknown'; + + // Heuristic: derive a code from the message pattern + const code = this.inferCode(raw); + + return { + field, + code, + message: raw, + constraints: { [code.toLowerCase()]: raw }, + }; + } + + /** + * Infers a validation error code from a class-validator constraint message. + */ + private inferCode(message: string): string { + const lower = message.toLowerCase(); + if (lower.includes('must be a valid email') || lower.includes('must be an email')) + return 'IS_EMAIL'; + if (lower.includes('must be longer') || lower.includes('must be at least')) + return 'MIN_LENGTH'; + if (lower.includes('must be shorter') || lower.includes('must be at most')) + return 'MAX_LENGTH'; + if (lower.includes('should not be empty') || lower.includes('must not be empty')) + return 'IS_NOT_EMPTY'; + if (lower.includes('must be a string')) return 'IS_STRING'; + if (lower.includes('must be a number')) return 'IS_NUMBER'; + if (lower.includes('must be a boolean')) return 'IS_BOOLEAN'; + if (lower.includes('must be an array')) return 'IS_ARRAY'; + if (lower.includes('must be an object')) return 'IS_OBJECT'; + if (lower.includes('must be a date')) return 'IS_DATE'; + if (lower.includes('must be a valid uuid')) return 'IS_UUID'; + if (lower.includes('must be a valid url')) return 'IS_URL'; + if (lower.includes('must match')) return 'MATCHES'; + if (lower.includes('must be one of')) return 'IS_IN'; + if (lower.includes('must be a valid enum')) return 'IS_ENUM'; + if (lower.includes('must be a positive')) return 'IS_POSITIVE'; + if (lower.includes('must be a negative')) return 'IS_NEGATIVE'; + if (lower.includes('must be an integer')) return 'IS_INT'; + if (lower.includes('must be less than') || lower.includes('must be smaller')) return 'MAX'; + if (lower.includes('must be greater than') || lower.includes('must be larger')) return 'MIN'; + if (lower.includes('must contain')) return 'CONTAINS'; + if (lower.includes('must not contain')) return 'NOT_CONTAINS'; + if (lower.includes('must be defined')) return 'IS_DEFINED'; + if (lower.includes('should not exist') || lower.includes('must not exist')) + return 'NOT_EXISTS'; + if (lower.includes('must be a valid phone')) return 'IS_PHONE_NUMBER'; + if (lower.includes('must be a valid postal')) return 'IS_POSTAL_CODE'; + if (lower.includes('must be a valid credit card')) return 'IS_CREDIT_CARD'; + if (lower.includes('must be a valid hex color')) return 'IS_HEX_COLOR'; + if (lower.includes('must be a valid ip')) return 'IS_IP'; + if (lower.includes('must be a valid json')) return 'IS_JSON'; + if (lower.includes('must be a valid jwt')) return 'IS_JWT'; + if (lower.includes('must be a valid locale')) return 'IS_LOCALE'; + if (lower.includes('must be lowercase')) return 'IS_LOWERCASE'; + if (lower.includes('must be uppercase')) return 'IS_UPPERCASE'; + if (lower.includes('must be a valid mime type')) return 'IS_MIME_TYPE'; + if (lower.includes('must be alphanumeric')) return 'IS_ALPHANUMERIC'; + if (lower.includes('must be alpha')) return 'IS_ALPHA'; + if (lower.includes('must be a valid base64')) return 'IS_BASE64'; + + return 'VALIDATION_ERROR'; + } + + private isValidationErrorResponse( + exceptionResponse: string | object, + ): exceptionResponse is { message: string[] } { + return ( + typeof exceptionResponse === 'object' && + exceptionResponse !== null && + 'message' in exceptionResponse && + Array.isArray((exceptionResponse as { message: unknown }).message) + ); + } + + private translateIfI18nKey(text: string, language: SupportedLanguage): string { + if (/^[a-z]+(\.[a-z_]+)+$/i.test(text) && text !== language) { + const resolved = this.i18n.tFor(text, language); + if (resolved !== text) return resolved; + } + return text; + } + + private resolveLanguage(request: Request & { user?: AuthenticatedUserShape }): SupportedLanguage { + const explicitOverride = + typeof request.headers['x-language'] === 'string' + ? (request.headers['x-language'] as string) + : undefined; + const fromHeader = + typeof request.headers['accept-language'] === 'string' + ? (request.headers['accept-language'] as string) + : undefined; + return this.i18n.resolveLanguage({ + userPreference: request.user?.languagePreference ?? explicitOverride ?? null, + acceptLanguageHeader: fromHeader, + }); + } +}