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:
22
src/app.controller.spec.ts
Normal file
22
src/app.controller.spec.ts
Normal 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
14
src/app.controller.ts
Normal 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
28
src/app.module.ts
Normal 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
15
src/app.service.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
15
src/common/context/context.module.ts
Normal file
15
src/common/context/context.module.ts
Normal 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 {}
|
||||
51
src/common/context/context.service.ts
Normal file
51
src/common/context/context.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
src/common/context/storage.ts
Normal file
12
src/common/context/storage.ts
Normal 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>();
|
||||
23
src/common/decorators/response-message.decorator.ts
Normal file
23
src/common/decorators/response-message.decorator.ts
Normal 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);
|
||||
113
src/common/dto/api-error-response.dto.ts
Normal file
113
src/common/dto/api-error-response.dto.ts
Normal 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
|
||||
}
|
||||
}
|
||||
89
src/common/dto/api-response.dto.ts
Normal file
89
src/common/dto/api-response.dto.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
52
src/common/dto/pagination.dto.ts
Normal file
52
src/common/dto/pagination.dto.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
67
src/common/exceptions/base.exception.ts
Normal file
67
src/common/exceptions/base.exception.ts
Normal 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
|
||||
}
|
||||
}
|
||||
176
src/common/exceptions/http.exceptions.ts
Normal file
176
src/common/exceptions/http.exceptions.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
64
src/common/exceptions/prisma-error.helper.ts
Normal file
64
src/common/exceptions/prisma-error.helper.ts
Normal 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];
|
||||
}
|
||||
293
src/common/filters/all-exceptions.filter.ts
Normal file
293
src/common/filters/all-exceptions.filter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
31
src/common/middleware/request-id.middleware.ts
Normal file
31
src/common/middleware/request-id.middleware.ts
Normal 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
38
src/main.ts
Normal 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();
|
||||
Reference in New Issue
Block a user