diff --git a/src/common/dto/api-response.dto.ts b/src/common/dto/api-response.dto.ts new file mode 100644 index 0000000..95104ff --- /dev/null +++ b/src/common/dto/api-response.dto.ts @@ -0,0 +1,70 @@ +import { applyDecorators, Type } from '@nestjs/common'; +import { + ApiExtraModels, + ApiOkResponse, + ApiProperty, + getSchemaPath, +} from '@nestjs/swagger'; + +export class ApiSuccessResponseDto { + @ApiProperty({ example: true }) + success: true; + + @ApiProperty({ required: false, example: 'Operation completed successfully' }) + message?: string; + + @ApiProperty() + data: T; + + @ApiProperty({ required: false, type: Object, additionalProperties: true }) + meta?: Record; +} + +export class ApiErrorResponseDto { + @ApiProperty({ example: false }) + success: false; + + @ApiProperty({ example: 400 }) + statusCode: number; + + @ApiProperty({ example: 'Validation failed' }) + message: string; + + @ApiProperty({ + required: false, + type: [String], + example: ['mobile must be a valid phone number'], + }) + errors?: string[]; + + @ApiProperty({ example: '2026-07-02T12:00:00.000Z' }) + timestamp: string; + + @ApiProperty({ example: '/api/users' }) + path: string; +} + +export const ApiStandardOkResponse = >( + model: TModel, + options?: { isArray?: boolean }, +) => + applyDecorators( + ApiExtraModels(ApiSuccessResponseDto, model), + ApiOkResponse({ + schema: { + allOf: [ + { $ref: getSchemaPath(ApiSuccessResponseDto) }, + { + properties: { + data: options?.isArray + ? { + type: 'array', + items: { $ref: getSchemaPath(model) }, + } + : { $ref: getSchemaPath(model) }, + }, + }, + ], + }, + }), + ); diff --git a/src/common/filters/api-exception.filter.ts b/src/common/filters/api-exception.filter.ts new file mode 100644 index 0000000..875f38e --- /dev/null +++ b/src/common/filters/api-exception.filter.ts @@ -0,0 +1,89 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + HttpStatus, + Logger, +} from '@nestjs/common'; +import { Request, Response } from 'express'; +import { ApiErrorResponse } from '../interfaces/api-response.interface'; + +const SWAGGER_PATH_PREFIXES = ['/docs', '/docs-json', '/docs-yaml']; + +@Catch() +export class ApiExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(ApiExceptionFilter.name); + + catch(exception: unknown, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + if (SWAGGER_PATH_PREFIXES.some((prefix) => request.url.startsWith(prefix))) { + if (exception instanceof HttpException) { + response.status(exception.getStatus()).json(exception.getResponse()); + return; + } + + response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + message: 'Internal server error', + }); + return; + } + + const errorResponse = this.buildErrorResponse(exception, request.url); + + if (errorResponse.statusCode >= HttpStatus.INTERNAL_SERVER_ERROR) { + this.logger.error( + `${request.method} ${request.url}`, + exception instanceof Error ? exception.stack : String(exception), + ); + } + + response.status(errorResponse.statusCode).json(errorResponse); + } + + private buildErrorResponse( + exception: unknown, + path: string, + ): ApiErrorResponse { + let statusCode = HttpStatus.INTERNAL_SERVER_ERROR; + let message = 'Internal server error'; + let errors: string[] | undefined; + + if (exception instanceof HttpException) { + statusCode = exception.getStatus(); + const exceptionResponse = exception.getResponse(); + + if (typeof exceptionResponse === 'string') { + message = exceptionResponse; + } else if (typeof exceptionResponse === 'object' && exceptionResponse) { + const responseBody = exceptionResponse as Record; + const responseMessage = responseBody.message; + + if (Array.isArray(responseMessage)) { + errors = responseMessage.filter( + (item): item is string => typeof item === 'string', + ); + message = + errors.length > 0 ? 'Validation failed' : 'Bad Request'; + } else if (typeof responseMessage === 'string') { + message = responseMessage; + } else if (typeof responseBody.error === 'string') { + message = responseBody.error; + } + } + } + + return { + success: false, + statusCode, + message, + ...(errors?.length ? { errors } : {}), + timestamp: new Date().toISOString(), + path, + }; + } +} diff --git a/src/common/interceptors/transform-response.interceptor.ts b/src/common/interceptors/transform-response.interceptor.ts new file mode 100644 index 0000000..602865d --- /dev/null +++ b/src/common/interceptors/transform-response.interceptor.ts @@ -0,0 +1,91 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from '@nestjs/common'; +import { Request } from 'express'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { + ApiSuccessResponse, + PaginatedResult, +} from '../interfaces/api-response.interface'; + +const SWAGGER_PATH_PREFIXES = ['/docs', '/docs-json', '/docs-yaml']; + +function isApiResponse(value: unknown): value is ApiSuccessResponse { + return ( + typeof value === 'object' && + value !== null && + 'success' in value && + (value as ApiSuccessResponse).success === true + ); +} + +function isPaginatedResult(value: unknown): value is PaginatedResult { + return ( + typeof value === 'object' && + value !== null && + 'items' in value && + 'meta' in value && + Array.isArray((value as PaginatedResult).items) + ); +} + +function isMessageOnlyResponse( + value: unknown, +): value is { message: string } { + return ( + typeof value === 'object' && + value !== null && + 'message' in value && + typeof (value as { message: unknown }).message === 'string' && + Object.keys(value).length === 1 + ); +} + +@Injectable() +export class TransformResponseInterceptor + implements NestInterceptor> +{ + intercept( + context: ExecutionContext, + next: CallHandler, + ): Observable> { + const request = context.switchToHttp().getRequest(); + + if (SWAGGER_PATH_PREFIXES.some((prefix) => request.url.startsWith(prefix))) { + return next.handle() as Observable>; + } + + return next.handle().pipe( + map((data): ApiSuccessResponse => { + if (isApiResponse(data)) { + return data as ApiSuccessResponse; + } + + if (isPaginatedResult(data)) { + return { + success: true, + data: data.items as T, + meta: data.meta, + }; + } + + if (isMessageOnlyResponse(data)) { + return { + success: true, + message: data.message, + data: null as T, + }; + } + + return { + success: true, + data: (data ?? null) as T, + }; + }), + ); + } +} diff --git a/src/common/interfaces/api-response.interface.ts b/src/common/interfaces/api-response.interface.ts new file mode 100644 index 0000000..806d761 --- /dev/null +++ b/src/common/interfaces/api-response.interface.ts @@ -0,0 +1,22 @@ +export interface ApiSuccessResponse { + success: true; + message?: string; + data: T; + meta?: Record; +} + +export interface ApiErrorResponse { + success: false; + statusCode: number; + message: string; + errors?: string[]; + timestamp: string; + path: string; +} + +export type ApiResponse = ApiSuccessResponse | ApiErrorResponse; + +export interface PaginatedResult { + items: T[]; + meta: Record; +} diff --git a/src/main.ts b/src/main.ts index eec0364..99e0bed 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,11 +1,16 @@ import { ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; +import { ApiExceptionFilter } from './common/filters/api-exception.filter'; +import { TransformResponseInterceptor } from './common/interceptors/transform-response.interceptor'; import { setupSwagger } from './swagger/swagger.setup'; async function bootstrap() { const app = await NestFactory.create(AppModule); + app.useGlobalInterceptors(new TransformResponseInterceptor()); + app.useGlobalFilters(new ApiExceptionFilter()); + // Global validation pipe app.useGlobalPipes( new ValidationPipe({