diff --git a/src/auth/presentation/auth.controller.ts b/src/auth/presentation/auth.controller.ts index 2aa1334..c874183 100644 --- a/src/auth/presentation/auth.controller.ts +++ b/src/auth/presentation/auth.controller.ts @@ -3,6 +3,7 @@ import { AuthService } from '../application/auth.service'; import { AuthDto, OtpRequestDto, OtpResponseDto } from './auth.dto'; import { ApiCreatedResponse, + ApiOkResponse, ApiOperation, ApiParam, ApiResponse, @@ -14,6 +15,7 @@ import { GroupValidation } from '../../common/validation/validation.decorator'; import { UnauthorizedException } from '@nestjs/common/exceptions'; import { Builder } from 'builder-pattern'; import { CrudGroup } from '../../common/validation/validation.data'; +import { ResponseEntity } from '../../common/dto/response.entity'; @ApiTags('인증 관련 API') @Controller('/v1/auth') @@ -25,22 +27,28 @@ export class AuthController { description: `Resource Server에서 제공한 식별자를 통해 고객의 정보를 확인 하고 토큰을 발급합니다. (고객의 정보가 없을 경우 신규 고객으로 등록함)`, }) - @ApiCreatedResponse({ type: AuthDto, description: '로그인 성공' }) + @ApiCreatedResponse({ + type: AuthDto, + description: '로그인 성공', + }) @ApiResponse({ status: 401, description: `Unauthorized / 요청한 Access Token이 만료되었습니다. 토큰을 갱신하세요`, }) @Post('/login') @GroupValidation([UserGroup.login]) - async login(@Body() dto: UserDto): Promise { - return await this.authService.login(dto); + async login(@Body() dto: UserDto): Promise> { + return ResponseEntity.OK(await this.authService.login(dto)); } @ApiOperation({ summary: '토큰 갱신', description: `Refresh Token을 통해 Access Token을 재발급합니다.`, }) - @ApiCreatedResponse({ type: AuthDto, description: '갱신 성공' }) + @ApiCreatedResponse({ + type: AuthDto, + description: '갱신 성공', + }) @ApiResponse({ status: 401, description: @@ -55,7 +63,7 @@ export class AuthController { authorization?: string; }; }, - ): Promise { + ): Promise> { const token = req.headers.authorization?.replace('Bearer ', ''); if (!token) { @@ -64,7 +72,10 @@ export class AuthController { ); } - return await this.authService.tokenRefresh(token); + return ResponseEntity.CREATED( + await this.authService.tokenRefresh(token), + 'refresh token 발급 완료', + ); } @ApiOperation({ @@ -74,7 +85,10 @@ export class AuthController { sendType을 보내면 해당 수단으로 OTP를 전송합니다. ex) sms, email\n OTP는 10분동안 유효합니다.`, }) - @ApiCreatedResponse({ type: OtpResponseDto, description: 'OTP 발급 성공' }) + @ApiCreatedResponse({ + type: OtpResponseDto, + description: 'OTP 발급 성공', + }) @ApiResponse({ status: 401, description: 'Unauthorized / 요청한 고객이 없습니다.', @@ -91,19 +105,23 @@ export class AuthController { async generatedOtpAndSend( @Body() dto: OtpRequestDto, @Query('sendType') sendType?: string, - ): Promise { + ): Promise> { if (sendType) { const generatedOtpNumber = await this.authService.sendOtp( sendType, dto.secret, ); - return Builder().otp(generatedOtpNumber).build(); + return ResponseEntity.CREATED( + Builder().otp(generatedOtpNumber).build(), + ); } - return Builder() - .otp(await this.authService.generateOtp(dto.secret)) - .build(); + return ResponseEntity.CREATED( + Builder() + .otp(await this.authService.generateOtp(dto.secret)) + .build(), + ); } @ApiOperation({ @@ -115,19 +133,25 @@ export class AuthController { status: 401, description: 'Unauthorized / 요청한 고객이 없습니다.', }) + @ApiOkResponse({ + type: OtpResponseDto, + description: 'OTP 검증 성공', + }) @GroupValidation([CrudGroup.update]) @Auth(HttpStatus.OK) @Post('/otp/verification') async otpVerify( @CurrentUser() user: UserDto, @Body() dto: OtpRequestDto, - ): Promise { + ): Promise> { const validated = await this.authService.otpVerifyAndUserUpdate( user, dto.secret, dto.otp, ); - return Builder().otp(dto.otp).verified(validated).build(); + return ResponseEntity.OK( + Builder().otp(dto.otp).verified(validated).build(), + ); } } diff --git a/src/business/presentation/business.controller.ts b/src/business/presentation/business.controller.ts index 88f4ac8..7b376d6 100644 --- a/src/business/presentation/business.controller.ts +++ b/src/business/presentation/business.controller.ts @@ -5,6 +5,8 @@ import { Auth, CurrentBusiness } from '../../auth/decorator/auth.decorator'; import { BusinessEntity } from '../../schemas/business.entity'; import { BusinessService } from '../application/business.service'; import { BusinessDto } from './business.dto'; +import { ResponseEntity } from '../../common/dto/response.entity'; +import { Builder } from 'builder-pattern'; @ApiTags('업체 관련 API') @Controller('/v1/business') @@ -20,17 +22,19 @@ export class BusinessController { @Get('my') async getMyBusinessInfo( @CurrentBusiness() business: BusinessEntity, - ): Promise { - return { - uuid: business.uuid, - authProvider: business.authProvider, - openingDate: business.openingDate, - businessId: business.businessId, - businessName: business.businessName, - businessRule: business.businessRule, - businessLocation: business.businessLocation, - businessPriceGuide: business.businessPriceGuide, - businessPhoneNumber: business.businessPhoneNumber, - }; + ): Promise> { + return ResponseEntity.OK( + Builder(BusinessDto) + .uuid(business.uuid) + .name(business.businessName) + .authProvider(business.authProvider) + .businessId(business.businessId) + .businessName(business.businessName) + .businessRule(business.businessRule) + .businessLocation(business.businessLocation) + .businessPriceGuide(business.businessPriceGuide) + .businessPhoneNumber(business.businessPhoneNumber) + .build(), + ); } } diff --git a/src/chat/presentation/chat.controller.ts b/src/chat/presentation/chat.controller.ts index 8e44003..12fd955 100644 --- a/src/chat/presentation/chat.controller.ts +++ b/src/chat/presentation/chat.controller.ts @@ -1,4 +1,12 @@ -import { Body, Controller, Get, HttpStatus, Param, Post, Query } from '@nestjs/common'; +import { + Body, + Controller, + Get, + HttpStatus, + Param, + Post, + Query, +} from '@nestjs/common'; import { ChatService } from '../application/chat.service'; import { Auth, CurrentCustomer } from '../../auth/decorator/auth.decorator'; import { CustomerEntity } from '../../schemas/customer.entity'; @@ -6,7 +14,13 @@ import { CrudGroup } from '../../common/validation/validation.data'; import { GroupValidation } from '../../common/validation/validation.decorator'; import { CursorDto } from '../../common/dto/cursor.dto'; import { ChatMessageDto, ChatRoomDto } from './chat.dto'; -import { ApiCreatedResponse, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + ApiCreatedResponse, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; +import { ResponseEntity } from '../../common/dto/response.entity'; @ApiTags('채팅방 API') @Controller('v1/chat/room') @@ -29,8 +43,10 @@ export class ChatController { async createChat( @Body() chatRoom: ChatRoomDto, @CurrentCustomer() customer: CustomerEntity, - ): Promise { - return await this.chatService.createChatRoom(chatRoom, customer); + ): Promise> { + return ResponseEntity.CREATED( + await this.chatService.createChatRoom(chatRoom, customer), + ); } @ApiOperation({ @@ -48,11 +64,13 @@ export class ChatController { @Get() async findChatRooms( @CurrentCustomer() customer: CustomerEntity, - ): Promise { - return await this.chatService.findChatRooms({ - userId: customer.customerId, - userType: 'customer', - }); + ): Promise> { + return ResponseEntity.OK( + await this.chatService.findChatRooms({ + userId: customer.customerId, + userType: 'customer', + }), + ); } @ApiOperation({ @@ -73,7 +91,9 @@ export class ChatController { @Param('chatRoomId') chatRoomId: number, @Query() cursor: CursorDto, @CurrentCustomer() customer: CustomerEntity, - ): Promise> { - return await this.chatService.findMessages(chatRoomId, cursor, customer); + ): Promise>> { + return ResponseEntity.OK( + await this.chatService.findMessages(chatRoomId, cursor, customer), + ); } } diff --git a/src/common/dto/response.entity.ts b/src/common/dto/response.entity.ts index 8f8fd73..a212fb0 100644 --- a/src/common/dto/response.entity.ts +++ b/src/common/dto/response.entity.ts @@ -1,14 +1,21 @@ import { HttpStatus } from '@nestjs/common'; export class ResponseEntity { - statusCode: number; - message: string; data?: T; + message: string; + statusCode: number; + secretMessage?: string; - private constructor(statusCode: number, message: string, data: T) { + private constructor( + statusCode: number, + message: string, + data: T, + secretMessage: string | undefined = undefined, + ) { this.statusCode = statusCode; this.message = message; this.data = data; + this.secretMessage = secretMessage; } static SUCCESS( @@ -19,12 +26,27 @@ export class ResponseEntity { return new ResponseEntity(statusCode, message, data); } + static CREATED(data: T, message: string = 'Created'): ResponseEntity { + return ResponseEntity.SUCCESS(HttpStatus.CREATED, message, data); + } + static OK(data: T, message: string = 'OK'): ResponseEntity { return ResponseEntity.SUCCESS(HttpStatus.OK, message, data); } - static ERROR(statusCode: number, message: string): ResponseEntity { - return new ResponseEntity(statusCode, message, null); + static ERROR( + statusCode: number, + message: string, + secretMessage: string | undefined = undefined, + ): ResponseEntity { + return new ResponseEntity(statusCode, message, null, secretMessage); + } + + static BAD_REQUEST( + message: string = 'Bad Request', + secretMessage: string | undefined = undefined, + ): ResponseEntity { + return ResponseEntity.ERROR(HttpStatus.BAD_REQUEST, message, secretMessage); } static NOT_FOUND(message: string = 'Not Found'): ResponseEntity { diff --git a/src/common/filters/entity-not-found.filter.ts b/src/common/filters/entity-not-found.filter.ts deleted file mode 100644 index 087eda0..0000000 --- a/src/common/filters/entity-not-found.filter.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { - ExceptionFilter, - Catch, - ArgumentsHost, - HttpException, - HttpStatus, -} from '@nestjs/common'; -import { Request, Response } from 'express'; -import { EntityNotFoundError } from 'typeorm'; - -@Catch(EntityNotFoundError) -export class EntityNotFoundExceptionFilter implements ExceptionFilter { - catch(exception: HttpException, host: ArgumentsHost) { - const ctx = host.switchToHttp(); - const response = ctx.getResponse(); - const request = ctx.getRequest(); - const status = HttpStatus.NOT_FOUND; - - response.status(status).json({ - statusCode: status, - timestamp: new Date().toISOString(), - path: request.url, - }); - } -} diff --git a/src/common/filters/exception.filters.ts b/src/common/filters/exception.filters.ts new file mode 100644 index 0000000..dcf2852 --- /dev/null +++ b/src/common/filters/exception.filters.ts @@ -0,0 +1,150 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + ForbiddenException, + HttpException, + HttpStatus, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { ResponseEntity } from '../dto/response.entity'; +import { Response } from 'express'; +import { + BadRequestException, + UnauthorizedException, +} from '@nestjs/common/exceptions'; +import { EntityNotFoundError } from 'typeorm'; + +@Catch() +export class AllExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(AllExceptionFilter.name); + + catch(exception: Error, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const httpStatus = + exception instanceof HttpException + ? exception.getStatus() + : HttpStatus.INTERNAL_SERVER_ERROR; + const request = ctx.getRequest(); + const response = ctx.getResponse(); + + const responseBody = ResponseEntity.ERROR( + httpStatus, + '오류가 발생했습니다.', + ); + this.logger.error( + `Http Status: ${httpStatus}, path: ${request.url}, message: ${exception.message}`, + exception.stack, + ); + + response.status(httpStatus).json(responseBody); + } +} + +@Catch(HttpException) +export class HttpExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(HttpExceptionFilter.name); + catch(exception: HttpException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + const status = exception.getStatus(); + const responseEntity = ResponseEntity.ERROR(status, exception.message); + this.logger.error( + `Http Status: ${status}, path: ${request.url}, message: ${exception.message}`, + exception.stack, + ); + + response.status(status).json(responseEntity); + } +} + +@Catch(ForbiddenException) +export class ForbiddenExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(ForbiddenExceptionFilter.name); + catch(exception: ForbiddenException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const request = ctx.getResponse(); + const response = ctx.getResponse(); + const status = HttpStatus.FORBIDDEN; + const responseEntity = ResponseEntity.ERROR(status, '권한이 없습니다.'); + + this.logger.warn( + `Http Status: ${status}, path: ${request.url}, message: ${exception.message}`, + exception.stack, + ); + + response.status(status).json(responseEntity); + } +} + +@Catch(UnauthorizedException) +export class UnauthorizedExceptionFilter implements ExceptionFilter { + catch(exception: UnauthorizedException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const status = HttpStatus.UNAUTHORIZED; + + const responseEntity = ResponseEntity.ERROR( + HttpStatus.UNAUTHORIZED, + '로그인이 필요합니다.', + ); + response.status(status).json(responseEntity); + } +} + +@Catch(BadRequestException) +export class BadRequestExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(BadRequestExceptionFilter.name); + + catch(exception: BadRequestException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const request = ctx.getResponse(); + const response = ctx.getResponse(); + const status = HttpStatus.BAD_REQUEST; + const responseEntity = ResponseEntity.BAD_REQUEST( + '요청이 잘못되었습니다.', + exception.message, + ); + + this.logger.warn( + `Http Status: ${status}, path: ${request.url}, message: ${exception.message}`, + exception.stack, + ); + + response.status(status).json(responseEntity); + } +} + +@Catch(NotFoundException) +export class NotFoundExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger(NotFoundExceptionFilter.name); + + catch(exception: Error, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const request = ctx.getResponse(); + const response = ctx.getResponse(); + const status = HttpStatus.NOT_FOUND; + const responseEntity = ResponseEntity.NOT_FOUND('찾을 수 없습니다.'); + + this.logger.warn( + `Http Status: ${status}, path: ${request.url}, message: ${exception.message}`, + exception.stack, + ); + + response.status(status).json(responseEntity); + } +} + +@Catch(EntityNotFoundError) +export class EntityNotFoundExceptionFilter implements ExceptionFilter { + catch(exception: HttpException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const status = HttpStatus.NOT_FOUND; + const responseEntity = ResponseEntity.NOT_FOUND('찾을 수 없습니다.'); + + response.status(status).json(responseEntity); + } +} diff --git a/src/common/image/presentation/image.controller.ts b/src/common/image/presentation/image.controller.ts index c251330..fe8de27 100644 --- a/src/common/image/presentation/image.controller.ts +++ b/src/common/image/presentation/image.controller.ts @@ -4,7 +4,14 @@ import { Auth, CurrentCustomer } from '../../../auth/decorator/auth.decorator'; import { ImageMetaDataDto } from './image.dto'; import { CustomerEntity } from '../../../schemas/customer.entity'; import { PresignedUrlDto } from '../../cloud/aws/s3/presentation/presigned-url.dto'; -import { ApiBody, ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { + ApiBody, ApiCreatedResponse, + ApiOperation, + ApiQuery, + ApiResponse, + ApiTags +} from '@nestjs/swagger'; +import { ResponseEntity } from '../../dto/response.entity'; @ApiTags('이미지 관련 API') @Controller('/v1/image') @@ -27,8 +34,7 @@ export class ImageController { '발급받은 url로 PUT 메소드로 이미지를 전송하면 S3에 이미지가 게시됩니다.\n' + '이미지 업로드시에는 Body에 Binary로 이미지를 전송해야 합니다.', }) - @ApiResponse({ - status: 201, + @ApiCreatedResponse({ type: [PresignedUrlDto], description: 'Generated presigned URL', }) @@ -38,18 +44,20 @@ export class ImageController { @CurrentCustomer() customer: CustomerEntity, @Query('key') key: string, @Body() metadata: ImageMetaDataDto[], - ): Promise { - return await this.imageService - .generatePreSignedUrls(key ?? customer.uuid, metadata) - .then((dtos) => - dtos.map((v) => { - return { - url: v.url, - expiredTime: v.expiredTime, - fileName: v.fileName, - fileSize: v.fileSize, - }; - }), - ); + ): Promise> { + return ResponseEntity.CREATED( + await this.imageService + .generatePreSignedUrls(key ?? customer.uuid, metadata) + .then((dtos) => + dtos.map((v) => { + return { + url: v.url, + expiredTime: v.expiredTime, + fileName: v.fileName, + fileSize: v.fileSize, + }; + }), + ), + ); } } diff --git a/src/common/validation/validation.data.ts b/src/common/validation/validation.data.ts index 7ca7aa7..8aad24f 100644 --- a/src/common/validation/validation.data.ts +++ b/src/common/validation/validation.data.ts @@ -1,4 +1,6 @@ import { UserGroup } from '../../auth/presentation/user.dto'; +import { ValidationError } from 'class-validator'; +import { BadRequestException } from '@nestjs/common/exceptions'; export enum CrudGroup { create = 'create', @@ -21,4 +23,11 @@ export const ValidationDefaultOption = { transform: true, transformOptions: { enableImplicitConversion: true }, groups: undefined, + exceptionFactory: (errors: ValidationError[]) => { + const errorMessages = errors.map( + (error) => + `${error.property} has wrong value ${error.value}, ${Object.values(error.constraints as { string: string }).join(', ')}`, + ); + return new BadRequestException(errorMessages.join(', ')); + }, }; diff --git a/src/customer/presentation/customer.controller.ts b/src/customer/presentation/customer.controller.ts index 29f86b0..13457e5 100644 --- a/src/customer/presentation/customer.controller.ts +++ b/src/customer/presentation/customer.controller.ts @@ -5,6 +5,7 @@ import { Auth, CurrentCustomer } from '../../auth/decorator/auth.decorator'; import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { Builder } from 'builder-pattern'; import { Customer } from '../customer.domain'; +import { ResponseEntity } from '../../common/dto/response.entity'; @ApiTags('고객 관련 API') @Controller('/v1/customer') @@ -20,25 +21,29 @@ export class CustomerController { @Get('my') async getMyCustomer( @CurrentCustomer() customer: Customer, - ): Promise> { - return await this.customerService - .getOne({ userId: customer.customerId }, true) - .then((v) => { - return Builder() - .uuid(v.uuid) - .userType('customer') - .userId(v.customerId) - .name(v.customerName) - .customerId(v.customerId) - .customerName(v.customerName) - .customerPhoneNumber(v.customerPhoneNumber) - .customerLocation(v.customerLocation) - .customerAddress(v.customerAddress) - .customerDetailAddress(v.customerDetailAddress) - .authProvider(v.authProvider) - .profileImageUrl(v.profileImage?.imageUrl) - .build(); - }); + ): Promise< + ResponseEntity> + > { + return ResponseEntity.OK( + await this.customerService + .getOne({ userId: customer.customerId }, true) + .then((v) => { + return Builder() + .uuid(v.uuid) + .userType('customer') + .userId(v.customerId) + .name(v.customerName) + .customerId(v.customerId) + .customerName(v.customerName) + .customerPhoneNumber(v.customerPhoneNumber) + .customerLocation(v.customerLocation) + .customerAddress(v.customerAddress) + .customerDetailAddress(v.customerDetailAddress) + .authProvider(v.authProvider) + .profileImageUrl(v.profileImage?.imageUrl) + .build(); + }), + ); } @ApiOperation({ @@ -51,29 +56,31 @@ export class CustomerController { async updateProfile( @CurrentCustomer() customer: Customer, @Body() dto: CustomerDto, - ): Promise> { + ): Promise>> { dto.userId = customer.customerId; - return await this.customerService - .update({ - ...customer, - ...dto, - }) - .then((v) => { - return Builder() - .uuid(v.uuid) - .userType('customer') - .userId(v.customerId) - .name(v.customerName) - .customerId(v.customerId) - .customerName(v.customerName) - .customerPhoneNumber(v.customerPhoneNumber) - .customerLocation(v.customerLocation) - .customerAddress(v.customerAddress) - .customerDetailAddress(v.customerDetailAddress) - .authProvider(v.authProvider) - .profileImageUrl(v?.profileImage?.imageUrl) - .build(); - }); + return ResponseEntity.OK( + await this.customerService + .update({ + ...customer, + ...dto, + }) + .then((v) => { + return Builder(CustomerDto) + .uuid(v.uuid) + .userType('customer') + .userId(v.customerId) + .name(v.customerName) + .customerId(v.customerId) + .customerName(v.customerName) + .customerPhoneNumber(v.customerPhoneNumber) + .customerLocation(v.customerLocation) + .customerAddress(v.customerAddress) + .customerDetailAddress(v.customerDetailAddress) + .authProvider(v.authProvider) + .profileImageUrl(v?.profileImage?.imageUrl) + .build(); + }), + ); } } diff --git a/src/main.ts b/src/main.ts index 62e845a..555d96f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,7 +7,15 @@ import { LoggerService } from './config/logger/logger.config'; import { ValidationDefaultOption } from './common/validation/validation.data'; import { RedisIoAdapter } from './config/socket/socket.adapter'; import { RedisService } from '@liaoliaots/nestjs-redis'; -import { EntityNotFoundExceptionFilter } from './common/filters/entity-not-found.filter'; + +import { + AllExceptionFilter, + BadRequestExceptionFilter, EntityNotFoundExceptionFilter, + ForbiddenExceptionFilter, + HttpExceptionFilter, + NotFoundExceptionFilter, + UnauthorizedExceptionFilter +} from './common/filters/exception.filters'; export const serviceWebUrls = [ 'https://mgmg.life', @@ -27,7 +35,14 @@ async function bootstrap() { app.useLogger(app.get(LoggerService)); app.useGlobalPipes(new ValidationPipe(ValidationDefaultOption)); + app.useGlobalFilters(new AllExceptionFilter()); + app.useGlobalFilters(new HttpExceptionFilter()); + app.useGlobalFilters(new ForbiddenExceptionFilter()); + app.useGlobalFilters(new UnauthorizedExceptionFilter()); + app.useGlobalFilters(new BadRequestExceptionFilter()); + app.useGlobalFilters(new NotFoundExceptionFilter()); app.useGlobalFilters(new EntityNotFoundExceptionFilter()); + app.useGlobalInterceptors( new ClassSerializerInterceptor(app.get(Reflector)), ); diff --git a/src/pet/presentation/pet.controller.ts b/src/pet/presentation/pet.controller.ts index 16da334..69bf005 100644 --- a/src/pet/presentation/pet.controller.ts +++ b/src/pet/presentation/pet.controller.ts @@ -1,30 +1,55 @@ import { PetService } from '../application/pet.service'; -import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + Param, + Post, + Put, + Query, +} from '@nestjs/common'; import { PetChecklistAnswerDto, PetChecklistDto, PetDto } from './pet.dto'; -import { Pet } from '../../schemas/pets.entity'; -import { ApiBody, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + ApiBody, + ApiCreatedResponse, + ApiOkResponse, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; import { GroupValidation } from 'src/common/validation/validation.decorator'; import { CrudGroup } from 'src/common/validation/validation.data'; import { CustomerEntity } from 'src/schemas/customer.entity'; -// eslint-disable-next-line prettier/prettier import { Auth, CurrentCustomer } from 'src/auth/decorator/auth.decorator'; -import { ChecklistType, PetChecklistCategory } from '../../schemas/pet-checklist.entity'; +import { + ChecklistType, + PetChecklistCategory, +} from '../../schemas/pet-checklist.entity'; +import { ResponseEntity } from '../../common/dto/response.entity'; @ApiTags('반려동물 관련 API') @Controller('/v1/pet') export class PetController { constructor(private readonly petService: PetService) {} + @ApiOkResponse({ + type: [PetChecklistDto], + }) @Get('/checklist') @Auth() async getChecklist( @Query('category') category: PetChecklistCategory, @Query('type') type: ChecklistType, @CurrentCustomer() customer: CustomerEntity, - ): Promise { - return await this.petService.findCheckList(category, type, null, customer); + ): Promise> { + return ResponseEntity.OK( + await this.petService.findCheckList(category, type, null, customer), + ); } + @ApiOkResponse({ + type: [PetChecklistDto], + }) @Get('/:petId/checklist') @Auth() async getPetChecklist( @@ -32,8 +57,10 @@ export class PetController { @Query('category') category: PetChecklistCategory, @Query('type') type: ChecklistType, @CurrentCustomer() customer: CustomerEntity, - ): Promise { - return await this.petService.findCheckList(category, type, petId, customer); + ): Promise> { + return ResponseEntity.OK( + await this.petService.findCheckList(category, type, petId, customer), + ); } @ApiOperation({ @@ -41,6 +68,7 @@ export class PetController { description: '반려동물 체크리스트 답변을 등록합니다.', }) @ApiOkResponse({ + type: [PetChecklistAnswerDto], description: '반려동물 체크리스트 답변 성공', }) @ApiBody({ type: [PetChecklistAnswerDto] }) @@ -51,25 +79,32 @@ export class PetController { @Param('petId') petId: number, @Body() dto: PetChecklistAnswerDto[], @CurrentCustomer() customer: CustomerEntity, - ): Promise { - return await this.petService - .answerChecklist(petId, dto, customer) - .then(() => dto); + ): Promise> { + return ResponseEntity.OK( + await this.petService + .answerChecklist(petId, dto, customer) + .then(() => dto), + ); } @ApiOperation({ summary: '반려동물 정보 생성', description: '새로운 반려동물 정보를 생성합니다.', }) - @ApiOkResponse({ type: PetDto, description: '반려동물 정보 생성 성공' }) + @ApiCreatedResponse({ + type: PetDto, + description: '반려동물 정보 생성 성공', + }) @GroupValidation([CrudGroup.create]) @Post() @Auth() async create( @Body() dto: PetDto, @CurrentCustomer() customer: CustomerEntity, - ): Promise { - return await this.petService.create(dto, customer); + ): Promise> { + return ResponseEntity.CREATED( + PetDto.from(await this.petService.create(dto, customer)), + ); } @ApiOperation({ @@ -77,39 +112,58 @@ export class PetController { description: '고객의 모든 반려동물 정보를 조회합니다.', }) @Auth() - @ApiOkResponse({ type: PetDto, description: '반려동물 정보 조회 성공' }) + @ApiOkResponse({ + type: PetDto, + description: '반려동물 정보 조회 성공', + }) @Get('/my') - async getAll(@CurrentCustomer() customer: CustomerEntity): Promise { - return await this.petService.findAll(customer); + async getAll( + @CurrentCustomer() customer: CustomerEntity, + ): Promise> { + return ResponseEntity.OK( + await this.petService + .findAll(customer) + .then((pets) => pets.map(PetDto.from)), + ); } @ApiOperation({ summary: '반려동물 정보 조회', description: '반려동물 ID를 통해 반려동물 정보를 조회합니다.', }) - @ApiOkResponse({ type: PetDto, description: '반려동물 정보 조회 성공' }) + @ApiOkResponse({ + type: PetDto, + description: '반려동물 정보 조회 성공', + }) @Get(':id') @GroupValidation([CrudGroup.read]) async getOne( @Param('id') id: number, @CurrentCustomer() customer: CustomerEntity, - ): Promise { - return await this.petService.findOne(id, customer); + ): Promise> { + return ResponseEntity.OK( + PetDto.from(await this.petService.findOne(id, customer)), + ); } @ApiOperation({ summary: '반려동물 정보 수정', description: '반려동물 정보를 수정합니다.', }) - @ApiOkResponse({ type: PetDto, description: '반려동물 정보 수정 성공' }) + @ApiOkResponse({ + type: PetDto, + description: '반려동물 정보 수정 성공', + }) @Put(':id') @GroupValidation([CrudGroup.update]) async update( @Param('id') id: number, @Body() dto: Omit, @CurrentCustomer() customer: CustomerEntity, - ): Promise { - return await this.petService.update(id, dto, customer); + ): Promise> { + return ResponseEntity.OK( + PetDto.from(await this.petService.update(id, dto, customer)), + ); } @ApiOperation({ @@ -122,7 +176,7 @@ export class PetController { async delete( @Param('id') id: number, @CurrentCustomer() customer: CustomerEntity, - ): Promise { - return await this.petService.delete(id, customer); + ): Promise> { + return ResponseEntity.OK(await this.petService.delete(id, customer)); } } diff --git a/src/pet/presentation/pet.dto.ts b/src/pet/presentation/pet.dto.ts index 5b8511c..661d3ac 100644 --- a/src/pet/presentation/pet.dto.ts +++ b/src/pet/presentation/pet.dto.ts @@ -10,12 +10,13 @@ import { ValidateIf, } from 'class-validator'; import { ApiProperty } from '@nestjs/swagger'; -import { Gender } from '../../schemas/pets.entity'; +import { Gender, Pet } from '../../schemas/pets.entity'; import { ChecklistType, PetChecklistCategory, } from '../../schemas/pet-checklist.entity'; import { CrudGroup } from '../../common/validation/validation.data'; +import { Builder } from 'builder-pattern'; export class PetDto { @ApiProperty({ @@ -115,6 +116,20 @@ export class PetDto { }) @IsNumber() public breedId: number; + + static from(pet: Pet): PetDto { + return Builder(PetDto) + .petId(pet.petId) + .petName(pet.petName) + .petGender(Gender.MALE == pet.petGender ? Gender.MALE : Gender.FEMALE) + .petBirthdate(pet.petBirthdate) + .petWeight(pet.petWeight) + .neuteredYn(pet.neuteredYn) + .personality(pet.personality) + .vaccinationStatus(pet.vaccinationStatus) + .breedId(pet.breed.breedId) + .build(); + } } export class PetChecklistDto { diff --git a/src/pre-registration-servey/presentation/pre-registration-survey-controller.ts b/src/pre-registration-servey/presentation/pre-registration-survey-controller.ts index 59d7496..b415f4e 100644 --- a/src/pre-registration-servey/presentation/pre-registration-survey-controller.ts +++ b/src/pre-registration-servey/presentation/pre-registration-survey-controller.ts @@ -1,9 +1,10 @@ import { Body, Controller, Post } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; +import { ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { PreRegistrationSurveyBody } from './pre-registration-survey-body'; import { PreRegistrationSurveyRequest } from '../application/pre-registration-survey-request'; import { PreRegistrationSurveyUseCase } from '../application/pre-registration-survey-use-case'; +import { ResponseEntity } from '../../common/dto/response.entity'; @ApiTags('사전 등록 설문') @Controller('pre-registration-survey') @@ -12,8 +13,14 @@ export class PreRegistrationSurveyController { private readonly preRegistrationSurveyUseCase: PreRegistrationSurveyUseCase, ) {} + @ApiOkResponse({ + type: ResponseEntity, + description: '사전 등록 설문 등록 성공', + }) @Post() - async register(@Body() body: PreRegistrationSurveyBody): Promise { + async register( + @Body() body: PreRegistrationSurveyBody, + ): Promise> { const request: PreRegistrationSurveyRequest = { name: body.name, email: body.email, @@ -24,6 +31,8 @@ export class PreRegistrationSurveyController { snsContact: body.snsContact, phoneInterview: body.phoneInterview, }; - await this.preRegistrationSurveyUseCase.execute(request); + return ResponseEntity.OK( + await this.preRegistrationSurveyUseCase.execute(request), + ); } } diff --git a/src/system/matrics/presentation/metrics.controller.ts b/src/system/matrics/presentation/metrics.controller.ts index 1ae2034..64bc231 100644 --- a/src/system/matrics/presentation/metrics.controller.ts +++ b/src/system/matrics/presentation/metrics.controller.ts @@ -1,5 +1,5 @@ -import { Controller, Get, Header, Logger } from "@nestjs/common"; -import { MetricsService } from "../application/metrics.service"; +import { Controller, Get, Header, Logger } from '@nestjs/common'; +import { MetricsService } from '../application/metrics.service'; @Controller('metrics') export class MetricsController { diff --git a/test/medium/exception.filters.test.ts b/test/medium/exception.filters.test.ts new file mode 100644 index 0000000..a4a35c6 --- /dev/null +++ b/test/medium/exception.filters.test.ts @@ -0,0 +1,346 @@ +import { + AllExceptionFilter, + BadRequestExceptionFilter, + EntityNotFoundExceptionFilter, + ForbiddenExceptionFilter, + HttpExceptionFilter, + NotFoundExceptionFilter, + UnauthorizedExceptionFilter, +} from '../../src/common/filters/exception.filters'; +import { Test, TestingModule } from '@nestjs/testing'; +import { + ArgumentsHost, + ForbiddenException, + HttpException, + HttpStatus, + NotFoundException, +} from '@nestjs/common'; +import { + BadRequestException, + UnauthorizedException, +} from '@nestjs/common/exceptions'; + +describe('ExceptionFilter 테스트', () => { + describe('AllExceptionFilter 테스트', () => { + let filter: AllExceptionFilter; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [AllExceptionFilter], + }).compile(); + + filter = module.get(AllExceptionFilter); + }); + + test('should be defined', () => { + expect(filter).toBeDefined(); + }); + + test('정의된 ExceptionFilter를 제외한 나머지 Exception에 대해선 AllExceptionFilter가 위임받는다.', () => { + const mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as unknown as Response; + + const mockRequest = { + url: '/test-url', + } as Request; + + const mockHost = { + switchToHttp: jest.fn().mockReturnValue({ + getResponse: () => mockResponse, + getRequest: () => mockRequest, + }), + } as unknown as ArgumentsHost; + + const exception = new HttpException( + 'BAD_GATEWAY', + HttpStatus.BAD_GATEWAY, + ); + + filter.catch(exception, mockHost); + + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_GATEWAY); + expect(mockResponse.json).toHaveBeenCalledWith({ + statusCode: HttpStatus.BAD_GATEWAY, + data: null, + message: '오류가 발생했습니다.', + }); + }); + }); + + describe('HttpExceptionFilter 테스트', () => { + let filter: HttpExceptionFilter; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [HttpExceptionFilter], + }).compile(); + + filter = module.get(HttpExceptionFilter); + }); + + test('should be defined', () => { + expect(filter).toBeDefined(); + }); + + test('정의된 ExceptionFilter를 제외한 나머지 Exception에 대해선 HttpExceptionFilter가 위임받는다.', () => { + const mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as unknown as Response; + + const mockRequest = { + url: '/test-url', + } as Request; + + const mockHost = { + switchToHttp: jest.fn().mockReturnValue({ + getResponse: () => mockResponse, + getRequest: () => mockRequest, + }), + } as unknown as ArgumentsHost; + + const exception = new HttpException('Conflict', HttpStatus.CONFLICT); + + filter.catch(exception, mockHost); + + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.CONFLICT); + expect(mockResponse.json).toHaveBeenCalledWith({ + statusCode: HttpStatus.CONFLICT, + data: null, + message: 'Conflict', + }); + }); + }); + + describe('ForbiddenExceptionFilter 테스트', () => { + let filter: ForbiddenExceptionFilter; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ForbiddenExceptionFilter], + }).compile(); + + filter = module.get(ForbiddenExceptionFilter); + }); + + test('should be defined', () => { + expect(filter).toBeDefined(); + }); + + test('ForbiddenException을 처리하고 지정한 Response 포멧으로 리턴해야한다.', () => { + const mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as unknown as Response; + + const mockRequest = { + url: '/test-url', + } as Request; + + const mockHost = { + switchToHttp: jest.fn().mockReturnValue({ + getResponse: () => mockResponse, + getRequest: () => mockRequest, + }), + } as unknown as ArgumentsHost; + + const exception = new ForbiddenException(); + + filter.catch(exception, mockHost); + + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.FORBIDDEN); + expect(mockResponse.json).toHaveBeenCalledWith({ + statusCode: HttpStatus.FORBIDDEN, + data: null, + message: '권한이 없습니다.', + }); + }); + }); + + describe('UnauthorizedExceptionFilter 테스트', () => { + let filter: UnauthorizedExceptionFilter; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [UnauthorizedExceptionFilter], + }).compile(); + + filter = module.get( + UnauthorizedExceptionFilter, + ); + }); + + test('should be defined', () => { + expect(filter).toBeDefined(); + }); + + test('UnauthorizedException을 처리하고 지정한 Response 포멧으로 리턴해야한다.', () => { + const mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as unknown as Response; + + const mockRequest = { + url: '/test-url', + } as Request; + + const mockHost = { + switchToHttp: jest.fn().mockReturnValue({ + getResponse: () => mockResponse, + getRequest: () => mockRequest, + }), + } as unknown as ArgumentsHost; + + const exception = new UnauthorizedException(); + + filter.catch(exception, mockHost); + + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.UNAUTHORIZED); + expect(mockResponse.json).toHaveBeenCalledWith({ + statusCode: HttpStatus.UNAUTHORIZED, + data: null, + message: '로그인이 필요합니다.', + }); + }); + }); + + describe('BadRequestExceptionFilter 테스트', () => { + let filter: BadRequestExceptionFilter; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [BadRequestExceptionFilter], + }).compile(); + + filter = module.get(BadRequestExceptionFilter); + }); + + test('should be defined', () => { + expect(filter).toBeDefined(); + }); + + test('BadRequestException을 처리하고 지정한 Response 포멧으로 리턴해야한다.', () => { + const mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as unknown as Response; + + const mockRequest = { + url: '/test-url', + } as Request; + + const mockHost = { + switchToHttp: jest.fn().mockReturnValue({ + getResponse: () => mockResponse, + getRequest: () => mockRequest, + }), + } as unknown as ArgumentsHost; + + const exception = new BadRequestException(); + + filter.catch(exception, mockHost); + + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST); + expect(mockResponse.json).toHaveBeenCalledWith({ + statusCode: HttpStatus.BAD_REQUEST, + data: null, + message: '요청이 잘못되었습니다.', + secretMessage: 'Bad Request', + }); + }); + }); + + describe('NotFoundExceptionFilter 테스트', () => { + let filter: NotFoundExceptionFilter; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [NotFoundExceptionFilter], + }).compile(); + + filter = module.get(NotFoundExceptionFilter); + }); + + test('should be defined', () => { + expect(filter).toBeDefined(); + }); + + test('NotFoundException을 처리하고 지정한 Response 포멧으로 리턴해야한다.', () => { + const mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as unknown as Response; + + const mockRequest = { + url: '/test-url', + } as Request; + + const mockHost = { + switchToHttp: jest.fn().mockReturnValue({ + getResponse: () => mockResponse, + getRequest: () => mockRequest, + }), + } as unknown as ArgumentsHost; + + const exception = new NotFoundException(); + + filter.catch(exception, mockHost); + + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND); + expect(mockResponse.json).toHaveBeenCalledWith({ + statusCode: HttpStatus.NOT_FOUND, + data: null, + message: '찾을 수 없습니다.', + }); + }); + }); + + describe('EntityNotFoundExceptionFilter 테스트', () => { + let filter: EntityNotFoundExceptionFilter; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [EntityNotFoundExceptionFilter], + }).compile(); + + filter = module.get( + EntityNotFoundExceptionFilter, + ); + }); + + test('should be defined', () => { + expect(filter).toBeDefined(); + }); + + test('EntityNotFoundException을 처리하고 지정한 Response 포멧으로 리턴해야한다.', () => { + const mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + } as unknown as Response; + + const mockRequest = { + url: '/test-url', + } as Request; + + const mockHost = { + switchToHttp: jest.fn().mockReturnValue({ + getResponse: () => mockResponse, + getRequest: () => mockRequest, + }), + } as unknown as ArgumentsHost; + + const exception = new HttpException('Not Found', HttpStatus.NOT_FOUND); + + filter.catch(exception, mockHost); + + expect(mockResponse.status).toHaveBeenCalledWith(HttpStatus.NOT_FOUND); + expect(mockResponse.json).toHaveBeenCalledWith({ + statusCode: HttpStatus.NOT_FOUND, + data: null, + message: '찾을 수 없습니다.', + }); + }); + }); +});