feat: initialize NestJS application with global error handling and response standardization

- Added nest-cli.json for NestJS configuration.
- Created package.json with dependencies and scripts for building, testing, and linting.
- Implemented AppController and AppService with a basic "Hello World!" response.
- Introduced ContextModule and ContextService for managing request-scoped context.
- Developed custom decorators for response messages and standardized error handling.
- Created global exception filter to handle various error types and format responses.
- Implemented response standardization interceptor to wrap successful responses.
- Added middleware for generating unique request IDs.
- Established DTOs for API responses and error handling.
- Configured TypeScript settings for the project.
- Set up Jest for testing with end-to-end test cases.
This commit is contained in:
2025-11-18 06:11:40 +06:00
commit e04b14265b
30 changed files with 3076 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

14
src/app.controller.ts Normal file
View File

@@ -0,0 +1,14 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
import { ResponseMessage } from './common/decorators/response-message.decorator';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
@ResponseMessage('Hello World!')
getHello(): string {
return this.appService.getHello();
}
}

28
src/app.module.ts Normal file
View File

@@ -0,0 +1,28 @@
/**
* app.module.ts
*/
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ContextModule } from './common/context/context.module';
import { RequestIdMiddleware } from './common/middleware/request-id.middleware';
import { APP_INTERCEPTOR, Reflector } from '@nestjs/core'; // <-- Import Reflector
import { ResponseStandardizationInterceptor } from './common/interceptors/response-standardization.interceptor';
@Module({
imports: [ContextModule],
controllers: [AppController],
providers: [
AppService,
{
provide: APP_INTERCEPTOR,
useClass: ResponseStandardizationInterceptor,
},
Reflector,
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(RequestIdMiddleware).forRoutes('*');
}
}

15
src/app.service.ts Normal file
View File

@@ -0,0 +1,15 @@
import { Injectable } from '@nestjs/common';
import { UnauthorizedException } from './common/exceptions/http.exceptions';
@Injectable()
export class AppService {
getHello(): string {
throw new UnauthorizedException(
'Your session has expired.',
'SESSION_EXPIRED',
undefined,
'LOGOUT_USER',
{ needOtpVerification: true },
);
}
}

View File

@@ -0,0 +1,15 @@
/**
* src/common/context/context.module.ts
* * Bundles and exports the ContextService.
* We make it @Global() so it can be injected anywhere
* without needing to import ContextModule in every feature module.
*/
import { Global, Module } from '@nestjs/common';
import { ContextService } from './context.service';
@Global() // Make ContextService available globally
@Module({
providers: [ContextService],
exports: [ContextService],
})
export class ContextModule {}

View File

@@ -0,0 +1,51 @@
/**
* src/common/context/context.service.ts
* * Provides a clean, injectable service to get/set values
* in the AsyncLocalStorage store.
*/
import { Injectable, Logger } from '@nestjs/common';
import { requestContext, RequestContextStore } from './storage';
// Define a constant key for our request ID
export const REQUEST_ID_KEY = 'requestId';
@Injectable()
export class ContextService {
private readonly logger = new Logger(ContextService.name);
private getStore(): RequestContextStore {
const store = requestContext.getStore();
if (!store) {
// This should not happen if the middleware is set up correctly
this.logger.warn(
'AsyncLocalStorage store not found. Middleware may be missing.',
);
// Return a dummy map to prevent crashes, though this indicates an issue.
return new Map();
}
return store;
}
/**
* Gets a value from the request context store.
* @param key The key to retrieve.
*/
get<T>(key: string): T | undefined {
return this.getStore().get(key) as T | undefined;
}
/**
* Sets a value in the request context store.
* @param key The key to set.
* @param value The value to store.
*/
set<T>(key: string, value: T): void {
this.getStore().set(key, value);
}
/**
* A convenience method to get the request ID.
*/
getRequestId(): string | undefined {
return this.get(REQUEST_ID_KEY);
}
}

View File

@@ -0,0 +1,12 @@
/**
* src/common/context/storage.ts
* * Creates and exports the AsyncLocalStorage instance.
* This will store our request-scoped context, like the request ID.
*/
import { AsyncLocalStorage } from 'async_hooks';
// The store will hold a Map, where we can store
// request-scoped values.
export type RequestContextStore = Map<string, any>;
export const requestContext = new AsyncLocalStorage<RequestContextStore>();

View File

@@ -0,0 +1,23 @@
/**
* src/common/decorators/response-message.decorator.ts
* * Defines a custom decorator to set a success message
* * for a controller route.
*/
import { SetMetadata } from '@nestjs/common';
export const RESPONSE_MESSAGE_KEY = 'responseMessage';
/**
* Decorator that sets a custom success message for a response.
* This message will be picked up by the ResponseStandardizationInterceptor.
*
* @example
* @ResponseMessage("User created successfully")
* @Post()
* createUser(@Body() user: CreateUserDto) {
* return this.userService.create(user);
* }
*/
export const ResponseMessage = (message: string) =>
SetMetadata(RESPONSE_MESSAGE_KEY, message);

View File

@@ -0,0 +1,113 @@
/**
* src/common/dto/api-error-response.dto.ts
* * This DTO defines the standard structure for all error responses.
* * UPDATED: Now includes optional 'instruction', 'details', and 'stack' fields.
*/
/**
* Defines the shape of a single error detail.
* This is particularly useful for validation errors,
* where 'field' can specify which part of the DTO failed.
*/
export class ErrorDetail {
/**
* An application-specific error code.
* @example "VALIDATION_ERROR"
*/
code: string;
/**
* A human-readable message describing the error.
* @example "email must be a valid email address"
*/
message: string;
/**
* The specific field that caused the error (optional).
* @example "email"
*/
field?: string;
}
export class ApiErrorResponseDto {
/**
* Indicates that the request was not successful.
* @example false
*/
readonly success: boolean = false;
/**
* The HTTP Status code.
* @example 404
*/
readonly statusCode: number; // <-- ADDED THIS
/**
* A high-level, human-readable summary of the error.
* @example "Validation Failed"
*/
readonly message: string;
/**
* The unique request ID, retrieved from the ContextService.
* @example "a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8"
*/
readonly requestId: string | null;
/**
* The server timestamp when the error was generated.
* @example "2025-11-16T03:55:00.00Z"
*/
readonly timestamp: string;
/**
* The API endpoint path that was called.
* @example "/v1/users"
*/
readonly path: string;
/**
* An array of one or more detailed errors.
*/
readonly errors: ErrorDetail[];
/**
* (Optional) A frontend-specific instruction or action code.
* @example "LOGOUT_USER"
*/
readonly instruction?: string; // <-- ADDED THIS
/**
* (Optional) An object containing extra details for the frontend.
* @example { "needOtpVerification": true, "remainingAttempts": 2 }
*/
readonly details?: unknown; // <-- CHANGED from any to unknown
/**
* (Optional) The error stack trace.
* Only included in non-production environments.
* @example "Error: Something went wrong..."
*/
readonly stack?: string; // <-- ADDED THIS
constructor(
statusCode: number, // <-- ADDED THIS
message: string,
errors: ErrorDetail[],
path: string,
requestId: string | null,
instruction?: string, // <-- ADDED THIS
details?: unknown, // <-- CHANGED from any to unknown
stack?: string, // <-- ADDED THIS
) {
this.statusCode = statusCode; // <-- ADDED THIS
this.message = message;
this.errors = errors;
this.path = path;
this.requestId = requestId;
this.timestamp = new Date().toISOString();
this.instruction = instruction; // <-- ADDED THIS
this.details = details; // <-- ADDED THIS
this.stack = stack; // <-- ADDED THIS
}
}

View File

@@ -0,0 +1,89 @@
/**
* api-response.dto.ts
* * This DTO defines the standard structure for all successful API responses.
*/
import { PaginationMetaDto } from './pagination.dto';
export class ApiResponseDto<T> {
/**
* Indicates if the request was successful.
* @example true
*/
readonly success: boolean;
/**
* The HTTP Status code.
* @example 200
*/
readonly statusCode: number;
/**
* A descriptive message about the response.
* @example "Users fetched successfully"
*/
readonly message: string;
/**
* Pagination metadata (only included for paginated responses).
*/
readonly meta?: PaginationMetaDto;
/**
* The unique request ID (will be implemented in Feature #2).
* @example "a1b2c3d4-e5f6-7890-g1h2-i3j4k5l6m7n8"
*/
readonly requestId: string | null; // Will be populated by Request ID middleware
/**
* The server timestamp when the response was generated.
* @example "2025-11-16T03:52:00.000Z"
*/
readonly timestamp: string;
/**
* The API endpoint path that was called.
* @example "/v1/users"
*/
readonly path: string;
/**
* The main data payload of the response.
*/
readonly data: T | null;
constructor(
success: boolean,
statusCode: number, // <-- ADDED STATUS CODE
message: string,
path: string,
requestId: string | null,
data: T | null,
meta?: PaginationMetaDto,
) {
this.success = success;
this.statusCode = statusCode; // <-- ASSIGNED STATUS CODE
this.message = message;
this.meta = meta;
this.requestId = requestId;
this.timestamp = new Date().toISOString();
// this.statusCode = success ? 200 : 400; // <-- REMOVED HARDCODED VALUE
this.path = path;
this.data = data;
}
}
/**
* A simple paginated response structure.
* All services will return data in this format
* for the interceptor to correctly format.
*/
export class PaginatedResponseDto<T> {
data: T[];
meta: PaginationMetaDto;
constructor(data: T[], total: number, page: number, limit: number) {
this.data = data;
this.meta = new PaginationMetaDto(total, page, limit);
}
}

View File

@@ -0,0 +1,52 @@
/**
* pagination.dto.ts
* * This DTO defines the structure for pagination metadata.
* It will be included in API responses for list endpoints.
*/
export class PaginationMetaDto {
/**
* The current page number.
* @example 1
*/
readonly page: number;
/**
* The number of items per page.
* @example 10
*/
readonly limit: number;
/**
* The total number of items available.
* @example 100
*/
readonly total: number;
/**
* The total number of pages.
* @example 10
*/
readonly totalPages: number;
/**
* A boolean indicating if there is a next page.
* @example true
*/
readonly hasNext: boolean;
/**
* A boolean indicating if there is a previous page.
* @example false
*/
readonly hasPrevious: boolean;
constructor(total: number, page: number, limit: number) {
this.total = total;
this.page = page;
this.limit = limit;
this.totalPages = Math.ceil(this.total / this.limit);
this.hasNext = this.page < this.totalPages;
this.hasPrevious = this.page > 1;
}
}

View File

@@ -0,0 +1,67 @@
/**
* src/common/exceptions/base.exception.ts
* * Defines a custom base exception class.
* * UPDATED: Now includes optional 'instruction' and 'details' fields.
*/
import { HttpException, HttpStatus } from '@nestjs/common';
import { ErrorDetail } from '../dto/api-error-response.dto';
/**
* A custom base exception that allows for a structured
* error response, including an application-specific error code.
*
* @example
* throw new BaseException(
* "Login failed, please verify OTP",
* HttpStatus.UNAUTHORIZED,
* "OTP_REQUIRED",
* [],
* "REQUIRE_OTP_VERIFICATION",
* { "needOtpVerification": true } // <-- New details field
* );
*/
export class BaseException extends HttpException {
public readonly code: string;
public readonly errors: ErrorDetail[];
public readonly instruction?: string; // <-- ADDED THIS
public readonly details?: unknown; // <-- CHANGED from any to unknown
/**
* @param message A high-level, human-readable summary of the error.
* @param status The HTTP status code.
* @param code An application-specific error code (e.g., "VALIDATION_ERROR").
* @param errors An optional array of detailed errors.
* @param instruction An optional frontend-specific action code.
* @param details An optional object with extra data for the frontend.
*/
constructor(
message: string,
status: HttpStatus,
code: string,
errors?: ErrorDetail[],
instruction?: string, // <-- ADDED THIS
details?: unknown, // <-- CHANGED from any to unknown
) {
// Call HttpException constructor with the message and status
super(
{
message,
code,
errors:
errors ||
(message ? [{ code, message: message, field: undefined }] : []),
instruction, // <-- ADDED THIS
details, // <-- ADDED THIS
},
status,
);
// Store custom properties
this.code = code;
this.errors =
errors || (message ? [{ code, message: message, field: undefined }] : []);
this.instruction = instruction; // <-- ADDED THIS
this.details = details; // <-- ADDED THIS
}
}

View File

@@ -0,0 +1,176 @@
/**
* src/common/exceptions/http.exceptions.ts
* * This file contains specific, reusable HTTP exceptions
* * that extend our custom BaseException.
* * UPDATED: All constructors now accept 'instruction' and 'details'
* * fields as the last parameters (details is unknown).
*/
import { HttpStatus } from '@nestjs/common';
import { BaseException } from './base.exception';
import { ErrorDetail } from '../dto/api-error-response.dto';
// --- 400 - Bad Request ---
export class BadRequestException extends BaseException {
constructor(
message = 'Bad request',
code = 'BAD_REQUEST',
errors?: ErrorDetail[],
instruction?: string, // <-- ADDED
details?: unknown, // <-- CHANGED to unknown
) {
super(message, HttpStatus.BAD_REQUEST, code, errors, instruction, details);
}
}
// --- 401 - Unauthorized ---
export class UnauthorizedException extends BaseException {
constructor(
message = 'Authentication required',
code = 'AUTHENTICATION_ERROR',
errors?: ErrorDetail[],
instruction?: string, // <-- ADDED
details?: unknown, // <-- CHANGED to unknown
) {
super(message, HttpStatus.UNAUTHORIZED, code, errors, instruction, details);
}
}
// --- 403 - Forbidden ---
export class ForbiddenException extends BaseException {
constructor(
message = 'Insufficient permissions',
code = 'AUTHORIZATION_ERROR',
errors?: ErrorDetail[],
instruction?: string, // <-- ADDED
details?: unknown, // <-- CHANGED to unknown
) {
super(message, HttpStatus.FORBIDDEN, code, errors, instruction, details);
}
}
// --- 404 - Not Found ---
export class NotFoundException extends BaseException {
constructor(
resource = 'Resource',
message?: string,
errors?: ErrorDetail[],
instruction?: string, // <-- ADDED
details?: unknown, // <-- CHANGED to unknown
) {
super(
message || `${resource} not found`,
HttpStatus.NOT_FOUND,
'NOT_FOUND',
errors,
instruction,
details,
);
}
}
// --- 409 - Conflict ---
export class ConflictException extends BaseException {
constructor(
message = 'Resource conflict',
code = 'CONFLICT_ERROR',
errors?: ErrorDetail[],
instruction?: string, // <-- ADDED
details?: unknown, // <-- CHANGED to unknown
) {
super(message, HttpStatus.CONFLICT, code, errors, instruction, details);
}
}
// --- 413 - Payload Too Large ---
export class PayloadTooLargeException extends BaseException {
constructor(
message = 'Payload too large',
code = 'PAYLOAD_TOO_LARGE',
errors?: ErrorDetail[],
instruction?: string, // <-- ADDED
details?: unknown, // <-- CHANGED to unknown
) {
super(
message,
HttpStatus.PAYLOAD_TOO_LARGE,
code,
errors,
instruction,
details,
);
}
}
// --- 429 - Too Many Requests ---
export class RateLimitException extends BaseException {
constructor(
message = 'Too many requests',
code = 'RATE_LIMIT_ERROR',
errors?: ErrorDetail[],
instruction?: string, // <-- ADDED
details?: unknown, // <-- CHANGED to unknown
) {
super(
message,
HttpStatus.TOO_MANY_REQUESTS,
code,
errors,
instruction,
details,
);
}
}
// --- 500 - Internal Server ---
export class InternalServerException extends BaseException {
constructor(
message = 'Internal server error',
code = 'INTERNAL_ERROR',
errors?: ErrorDetail[],
instruction?: string, // <-- ADDED
details?: unknown, // <-- CHANGED to unknown
) {
super(
message,
HttpStatus.INTERNAL_SERVER_ERROR,
code,
errors,
instruction,
details,
);
}
}
// --- 502 - Bad Gateway ---
export class ExternalServiceException extends BaseException {
constructor(
message = 'External service error',
code = 'EXTERNAL_SERVICE_ERROR',
errors?: ErrorDetail[],
instruction?: string, // <-- ADDED
details?: unknown, // <-- CHANGED to unknown
) {
super(message, HttpStatus.BAD_GATEWAY, code, errors, instruction, details);
}
}
// --- 504 - Gateway Timeout ---
export class TimeoutException extends BaseException {
constructor(
message = 'Request timeout',
code = 'TIMEOUT_ERROR',
errors?: ErrorDetail[],
instruction?: string, // <-- ADDED
details?: unknown, // <-- CHANGED to unknown
) {
super(
message,
HttpStatus.REQUEST_TIMEOUT,
code,
errors,
instruction,
details,
);
}
}

View File

@@ -0,0 +1,64 @@
/**
* src/common/exceptions/prisma-error.helper.ts
* * This file maps Prisma error codes to HTTP status codes and messages.
* This allows our exception filter to handle database errors gracefully.
*
* Full list of Prisma error codes:
* https://www.prisma.io/docs/reference/api-reference/error-reference#error-codes
*/
import { HttpStatus } from '@nestjs/common';
// Define a structure for our error mapping
interface PrismaError {
status: HttpStatus;
message: string;
}
// Map Prisma error codes to our custom error structure
export const PRISMA_ERROR_MAP: Record<string, PrismaError> = {
// Common constraint-related errors
P2000: {
status: HttpStatus.BAD_REQUEST,
message: 'The provided value for the column is too long.',
},
P2002: {
status: HttpStatus.CONFLICT,
message:
'A record with this value already exists (unique constraint failed).',
},
P2003: {
status: HttpStatus.CONFLICT,
message: 'Foreign key constraint failed.',
},
// Record not found errors
P2014: {
status: HttpStatus.NOT_FOUND,
message: 'The related record could not be found.',
},
P2018: {
status: HttpStatus.NOT_FOUND,
message: 'The required connected records were not found.',
},
P2025: {
status: HttpStatus.NOT_FOUND,
message: 'The record you tried to operate on could not be found.',
},
// Other errors
P2001: {
status: HttpStatus.NOT_FOUND,
message: 'The record searched for in the where condition does not exist.',
},
// Add more Prisma error codes as needed by your application
};
/**
* Checks if a given error code is a known Prisma error code.
* @param code The error code to check.
* @returns A PrismaError object if found, otherwise undefined.
*/
export function getPrismaError(code: string): PrismaError | undefined {
return PRISMA_ERROR_MAP[code];
}

View File

@@ -0,0 +1,293 @@
/**
* src/common/filters/all-exceptions.filter.ts
* * This is the global exception filter. It catches ALL exceptions
* * UPDATED: Now passes 'instruction', 'details',
* * and conditionally 'stack'.
*/
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Injectable,
Logger,
BadRequestException,
} from '@nestjs/common';
import { HttpAdapterHost } from '@nestjs/core';
import { Request } from 'express';
import { ContextService } from '../context/context.service';
import {
ApiErrorResponseDto,
ErrorDetail,
} from '../dto/api-error-response.dto';
import { getPrismaError } from '../exceptions/prisma-error.helper';
import { BaseException } from '../exceptions/base.exception'; // <-- Import BaseException
// --- Type definitions for better type safety ---
/**
* Interface for the object returned by *our BaseException's*
* getResponse() method.
*/
interface HttpExceptionResponse {
message: string | string[];
code?: string;
errors?: ErrorDetail[];
instruction?: string; // <-- Support instruction
details?: unknown; // <-- Support details
// <-- REMOVED the [key: string]: unknown index signature
}
/**
* A simplified interface for class-validator's ValidationError.
*/
interface SimpleValidationError {
property: string;
constraints?: { [type: string]: string };
children?: SimpleValidationError[];
}
/**
* Shape of a PrismaClientKnownRequestError.
*/
interface PrismaErrorInterface {
code: string;
message: string;
stack?: string;
meta?: { target?: string[] };
}
/**
* Type guard to check if an exception is a Prisma error.
* This is more robust than a dynamic import.
*/
function isPrismaError(exception: unknown): exception is PrismaErrorInterface {
return (
typeof exception === 'object' &&
exception !== null &&
(exception as Error).constructor?.name ===
'PrismaClientKnownRequestError' &&
typeof (exception as PrismaErrorInterface).code === 'string'
);
}
@Catch() // <-- Catches ALL exceptions
@Injectable()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
constructor(
private readonly httpAdapterHost: HttpAdapterHost,
private readonly contextService: ContextService,
) {}
catch(exception: unknown, host: ArgumentsHost): void {
const { httpAdapter } = this.httpAdapterHost;
const ctx = host.switchToHttp();
const request = ctx.getRequest<Request>();
const path = request.url;
const requestId = this.contextService.getRequestId() || null;
const stack = (exception as Error)?.stack; // <-- Get stack trace early
let httpStatus: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR;
let message: string = 'Internal Server Error';
let errors: ErrorDetail[] = [];
let instruction: string | undefined = undefined; // <-- ADDED THIS
let details: unknown = undefined; // <-- CHANGED to just 'unknown'
// --- Logic to handle different exception types ---
if (exception instanceof BaseException) {
// 1. (NEW) Handle our custom BaseException
// This gives us full control over the error response
httpStatus = exception.getStatus();
const response = exception.getResponse() as HttpExceptionResponse; // This cast is safe
message = response.message as string;
errors =
response.errors ||
(response.message
? [
{
code: response.code || exception.name,
message: response.message as string,
},
]
: []);
instruction = response.instruction; // <-- CAPTURE INSTRUCTION
details = response.details; // <-- CAPTURE DETAILS
} else if (exception instanceof HttpException) {
// 2. Handle standard NestJS HTTP Exceptions
httpStatus = exception.getStatus();
// Get response without casting. Type is (string | object)
const response = exception.getResponse();
// --- Type guard for class-validator response ---
const isClassValidatorResponse = (
res: unknown,
): res is { message: unknown[] } =>
typeof res === 'object' &&
res !== null &&
'message' in res &&
Array.isArray((res as { message: unknown[] }).message);
// --- Type guard for generic object response ---
const isGenericErrorResponse = (
res: unknown,
): res is Record<string, unknown> =>
typeof res === 'object' && res !== null;
if (
exception instanceof BadRequestException &&
isClassValidatorResponse(response)
) {
// 2a. Handle class-validator Validation Errors
message = 'Validation Failed';
errors = this.buildValidationErrors(
response.message as unknown as SimpleValidationError[],
);
} else if (typeof response === 'string') {
// 2b. Handle simple string HTTP exceptions
message = response;
errors = [{ code: exception.name, message: response }];
} else if (isGenericErrorResponse(response)) {
// 2c. Handle other object-based HTTP exceptions
// Safely access properties from the 'unknown' record
const responseMessage = response.message;
if (Array.isArray(responseMessage)) {
message = responseMessage.join(', ');
} else if (typeof responseMessage === 'string') {
message = responseMessage;
} else {
message = exception.name;
}
const responseCode =
typeof response.code === 'string' ? response.code : exception.name;
const errorMessage = Array.isArray(responseMessage)
? responseMessage.join(', ')
: typeof responseMessage === 'string'
? responseMessage
: 'An unexpected error occurred';
errors = [
{
code: responseCode,
message: errorMessage,
},
];
}
} else if (isPrismaError(exception)) {
// 3. Handle Prisma (Database) Errors
const prismaError = getPrismaError(exception.code);
if (prismaError) {
httpStatus = prismaError.status;
message = prismaError.message;
errors = [
{
code: `DB_${exception.code}`,
message: prismaError.message, // Use the friendly message
field: exception.meta?.target?.join('.'),
},
];
} else {
// Unhandled Prisma error
httpStatus = HttpStatus.INTERNAL_SERVER_ERROR;
message = 'An unhandled database error occurred.';
errors = [
{ code: `DB_UNKNOWN_${exception.code}`, message: exception.message },
];
}
this.logger.error(
`Prisma Error: ${exception.code} | Message: ${exception.message}`,
exception.stack,
`RequestID: ${requestId}`,
);
} else if (exception instanceof Error) {
// 4. Handle generic Javascript Errors
message = exception.message;
errors = [
{ code: exception.name || 'Error', message: exception.message },
];
this.logger.error(
`Generic Error: ${message}`,
exception.stack,
`RequestID: ${requestId}`,
);
} else {
// 5. Handle all other unknown exceptions
message = 'An unknown error occurred.';
errors = [
{ code: 'UNKNOWN_ERROR', message: 'An unknown error occurred.' },
];
this.logger.error(
'Unknown exception caught',
exception,
`RequestID: ${requestId}`,
);
}
// --- Log the error (except for 404s, which are common) ---
if (httpStatus !== HttpStatus.NOT_FOUND) {
this.logger.error(
`[${requestId}] ${httpStatus} ${message} - ${path}`,
stack ?? JSON.stringify(exception), // Use the captured stack
);
}
// --- Build and send the final response ---
const responseBody = new ApiErrorResponseDto(
httpStatus,
message,
errors,
path,
requestId,
instruction, // <-- PASS INSTRUCTION
details, // <-- PASS DETAILS
process.env.NODE_ENV !== 'production' ? stack : undefined, // <-- PASS CONDITIONAL STACK
);
httpAdapter.reply(ctx.getResponse(), responseBody, httpStatus);
}
/**
* Helper to transform class-validator errors into our ErrorDetail format.
*/
private buildValidationErrors(
validationErrors: SimpleValidationError[],
): ErrorDetail[] {
const errors: ErrorDetail[] = [];
const traverseErrors = (
err: SimpleValidationError,
parentField?: string,
) => {
const field = parentField
? `${parentField}.${err.property}`
: err.property;
if (err.constraints) {
for (const key of Object.keys(err.constraints)) {
errors.push({
code: 'VALIDATION_ERROR',
message: err.constraints[key],
field: field,
});
}
}
if (err.children && err.children.length > 0) {
for (const child of err.children) {
traverseErrors(child, field);
}
}
};
for (const error of validationErrors) {
traverseErrors(error);
}
return errors;
}
}

View File

@@ -0,0 +1,83 @@
/**
* response-standardization.interceptor.ts
* * This interceptor wraps all successful (non-error) responses
* in the standard ApiResponseDto structure.
* * UPDATED: Now uses Reflector to check for a custom
* * success message from the @ResponseMessage decorator.
*/
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { HttpArgumentsHost } from '@nestjs/common/interfaces';
import { Request, Response } from 'express'; // <-- IMPORT RESPONSE
import { ApiResponseDto, PaginatedResponseDto } from '../dto/api-response.dto';
import { ContextService } from '../context/context.service';
import { Reflector } from '@nestjs/core'; // <-- Import Reflector
import { RESPONSE_MESSAGE_KEY } from '../decorators/response-message.decorator'; // <-- Import key
@Injectable()
export class ResponseStandardizationInterceptor<T>
implements NestInterceptor<T, ApiResponseDto<T | T[]>>
{
// --- Inject ContextService and Reflector ---
constructor(
private readonly contextService: ContextService,
private readonly reflector: Reflector, // <-- Inject Reflector
) {}
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<ApiResponseDto<T | T[]>> {
const ctx: HttpArgumentsHost = context.switchToHttp();
const request: Request = ctx.getRequest();
const response: Response = ctx.getResponse(); // <-- GET RESPONSE OBJECT
const path: string = request.url;
const handler = context.getHandler(); // Get the route handler
const requestId = this.contextService.getRequestId() || null;
// --- Get custom message from decorator ---
const customMessage = this.reflector.get<string>(
RESPONSE_MESSAGE_KEY,
handler, // Check the handler for the metadata
);
return next.handle().pipe(
map((data) => {
const statusCode = response.statusCode; // <-- GET ACTUAL STATUS CODE
// Check if the controller response is already in our paginated format
if (data instanceof PaginatedResponseDto) {
return new ApiResponseDto<T[]>(
true,
statusCode, // <-- PASS STATUS CODE
// Use custom message or default
customMessage || 'Data fetched successfully (paginated)',
path,
requestId,
data.data,
data.meta,
);
}
// Handle non-paginated, standard successful responses
return new ApiResponseDto<T>(
true,
statusCode, // <-- PASS STATUS CODE
// Use custom message or default
customMessage || 'Operation successful',
path,
requestId,
data as T,
);
}),
);
}
}

View File

@@ -0,0 +1,31 @@
/**
* src/common/middleware/request-id.middleware.ts
* * This middleware generates a unique request ID (using crypto)
* * and sets it in the AsyncLocalStorage context for every request.
*/
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
import { requestContext, RequestContextStore } from '../context/storage';
import { REQUEST_ID_KEY } from '../context/context.service';
import { randomUUID } from 'crypto'; // Built-in Node.js module
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
// Attempt to get ID from header (for tracing across services)
// Otherwise, generate a new unique ID
const requestId = (req.headers['x-request-id'] as string) || randomUUID();
// Create a new Map to store context data for this request
const store: RequestContextStore = new Map();
store.set(REQUEST_ID_KEY, requestId);
// Also add the request ID to the response header
res.setHeader('x-request-id', requestId);
// Run the rest of the request chain *within* the async context
requestContext.run(store, () => {
next();
});
}
}

38
src/main.ts Normal file
View File

@@ -0,0 +1,38 @@
import { HttpAdapterHost, NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
import { ContextService } from './common/context/context.service';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// --- 1. Enable Global Validation Pipe ---
// This is part of Feature #5, but required for the filter to catch
// class-validator errors.
app.useGlobalPipes(
new ValidationPipe({
whitelist: true, // Strip properties not in DTO
transform: true, // Automatically transform payloads to DTO instances
forbidNonWhitelisted: true, // Throw error on non-whitelisted properties
transformOptions: {
enableImplicitConversion: true, // Convert query/path params
},
}),
);
// --- 2. Register Global Exception Filter ---
// We MUST get these dependencies from the app instance
// *after* it has been created.
const httpAdapterHost = app.get(HttpAdapterHost);
const contextService = app.get(ContextService);
app.useGlobalFilters(
new AllExceptionsFilter(httpAdapterHost, contextService),
);
// Note: We are instantiating it here instead of using APP_FILTER
// because the filter *depends* on other providers (HttpAdapterHost)
// that are only available *after* the app is created.
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();