This commit is contained in:
2026-07-02 20:18:09 +03:30
parent 6b3327ee16
commit 8e2e43e12b
5 changed files with 277 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
import { applyDecorators, Type } from '@nestjs/common';
import {
ApiExtraModels,
ApiOkResponse,
ApiProperty,
getSchemaPath,
} from '@nestjs/swagger';
export class ApiSuccessResponseDto<T = unknown> {
@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<string, unknown>;
}
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 = <TModel extends Type<unknown>>(
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) },
},
},
],
},
}),
);
@@ -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<Response>();
const request = ctx.getRequest<Request>();
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<string, unknown>;
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,
};
}
}
@@ -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<T>(value: unknown): value is PaginatedResult<T> {
return (
typeof value === 'object' &&
value !== null &&
'items' in value &&
'meta' in value &&
Array.isArray((value as PaginatedResult<T>).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<T>
implements NestInterceptor<T, ApiSuccessResponse<T>>
{
intercept(
context: ExecutionContext,
next: CallHandler<T>,
): Observable<ApiSuccessResponse<T>> {
const request = context.switchToHttp().getRequest<Request>();
if (SWAGGER_PATH_PREFIXES.some((prefix) => request.url.startsWith(prefix))) {
return next.handle() as Observable<ApiSuccessResponse<T>>;
}
return next.handle().pipe(
map((data): ApiSuccessResponse<T> => {
if (isApiResponse(data)) {
return data as ApiSuccessResponse<T>;
}
if (isPaginatedResult<T>(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,
};
}),
);
}
}
@@ -0,0 +1,22 @@
export interface ApiSuccessResponse<T = unknown> {
success: true;
message?: string;
data: T;
meta?: Record<string, unknown>;
}
export interface ApiErrorResponse {
success: false;
statusCode: number;
message: string;
errors?: string[];
timestamp: string;
path: string;
}
export type ApiResponse<T = unknown> = ApiSuccessResponse<T> | ApiErrorResponse;
export interface PaginatedResult<T> {
items: T[];
meta: Record<string, unknown>;
}
+5
View File
@@ -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({