From e6487141c1e3284313506a40e09e1ef1d0053538 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=9E=AC=EC=9B=90?= Date: Tue, 23 Jul 2024 23:30:41 +0900 Subject: [PATCH] =?UTF-8?q?Feat:=20API=20=EA=B3=B5=ED=86=B5=20=ED=95=B8?= =?UTF-8?q?=EB=93=A4=EB=9F=AC=20=EC=B6=94=EA=B0=80=20(#17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feat: API 공통 핸들러 추가 * Feat: ErrorType 추가 * Feat: ObjectMapper bean 추가 * Feat: api response 형식을 swagger에 적용 * Feat: null인 응답도 ApiRepsonse 형식으로 반환하도록 수정 --- .../runus/global/config/SwaggerConfig.java | 69 ++++++++++++++++++ .../runus/global/exception/BaseException.java | 21 ++++++ .../global/exception/BusinessException.java | 9 +++ .../global/exception/NotFoundException.java | 9 +++ .../global/exception/type/ApiErrorType.java | 13 ++++ .../global/exception/type/ErrorType.java | 30 ++++++++ .../config/ObjectMapperConfig.java | 16 +++++ .../runus/presentation/config/WebConfig.java | 18 +++++ .../dto/response/ApiErrorDto.java | 34 +++++++++ .../dto/response/ApiResponse.java | 21 ++++++ .../handler/ApiResponseHandler.java | 60 ++++++++++++++++ .../handler/ExceptionRestHandler.java | 71 +++++++++++++++++++ .../handler/NullResponseHandler.java | 28 ++++++++ 13 files changed, 399 insertions(+) create mode 100644 src/main/java/com/dnd/runus/global/exception/BaseException.java create mode 100644 src/main/java/com/dnd/runus/global/exception/BusinessException.java create mode 100644 src/main/java/com/dnd/runus/global/exception/NotFoundException.java create mode 100644 src/main/java/com/dnd/runus/global/exception/type/ApiErrorType.java create mode 100644 src/main/java/com/dnd/runus/global/exception/type/ErrorType.java create mode 100644 src/main/java/com/dnd/runus/presentation/config/ObjectMapperConfig.java create mode 100644 src/main/java/com/dnd/runus/presentation/config/WebConfig.java create mode 100644 src/main/java/com/dnd/runus/presentation/dto/response/ApiErrorDto.java create mode 100644 src/main/java/com/dnd/runus/presentation/dto/response/ApiResponse.java create mode 100644 src/main/java/com/dnd/runus/presentation/handler/ApiResponseHandler.java create mode 100644 src/main/java/com/dnd/runus/presentation/handler/ExceptionRestHandler.java create mode 100644 src/main/java/com/dnd/runus/presentation/handler/NullResponseHandler.java diff --git a/src/main/java/com/dnd/runus/global/config/SwaggerConfig.java b/src/main/java/com/dnd/runus/global/config/SwaggerConfig.java index d359286b..b3a9ca3a 100644 --- a/src/main/java/com/dnd/runus/global/config/SwaggerConfig.java +++ b/src/main/java/com/dnd/runus/global/config/SwaggerConfig.java @@ -1,18 +1,24 @@ package com.dnd.runus.global.config; +import com.dnd.runus.global.exception.type.ApiErrorType; +import com.dnd.runus.global.exception.type.ErrorType; import com.fasterxml.jackson.databind.ObjectMapper; import io.swagger.v3.core.jackson.ModelResolver; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.Operation; import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; import io.swagger.v3.oas.models.media.Schema; import io.swagger.v3.oas.models.media.StringSchema; import io.swagger.v3.oas.models.parameters.Parameter; import io.swagger.v3.oas.models.parameters.QueryParameter; +import io.swagger.v3.oas.models.responses.ApiResponse; import io.swagger.v3.oas.models.security.SecurityRequirement; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.ObjectUtils; import org.springdoc.core.customizers.OpenApiCustomizer; +import org.springdoc.core.customizers.OperationCustomizer; import org.springframework.boot.info.BuildProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -21,8 +27,14 @@ import java.time.Instant; import java.time.ZoneId; import java.time.format.DateTimeFormatter; +import java.util.Comparator; import java.util.List; +import java.util.Map; import java.util.function.BiConsumer; +import java.util.function.Function; +import java.util.stream.Stream; + +import static org.springframework.util.MimeTypeUtils.APPLICATION_JSON_VALUE; @RequiredArgsConstructor @Configuration @@ -48,6 +60,63 @@ OpenApiCustomizer projectPageableCustomizer() { }); } + @Bean + OperationCustomizer successResponseBodyWrapper() { + return (operation, handlerMethod) -> { + if (!operation.getResponses().containsKey("200")) { + return operation; + } + Content content = operation.getResponses().get("200").getContent(); + if (content == null) { + return operation; + } + content.forEach((mediaTypeKey, mediaType) -> { + Schema originalSchema = mediaType.getSchema(); + Schema wrappedSchema = new Schema<>(); + wrappedSchema.addProperty("success", new Schema<>().type("bool").example(true)); + wrappedSchema.addProperty("data", originalSchema); + mediaType.setSchema(wrappedSchema); + }); + return operation; + }; + } + + @Bean + OperationCustomizer apiErrorTypeCustomizer() { + Function> getErrorSchema = type -> { + Schema errorSchema = new Schema<>(); + errorSchema.properties(Map.of( + "statusCode", + new Schema<>().type("int").example(type.httpStatus().value()), + "code", new Schema<>().type("string").example(type.code()), + "message", new Schema<>().type("string").example(type.message()))); + return errorSchema; + }; + + return (operation, handlerMethod) -> { + ApiErrorType apiErrorType = handlerMethod.getMethodAnnotation(ApiErrorType.class); + if (apiErrorType == null) { + return operation; + } + + Stream.of(apiErrorType.value()) + .sorted(Comparator.comparingInt(t -> t.httpStatus().value())) + .forEach(type -> { + Content content = new Content() + .addMediaType( + APPLICATION_JSON_VALUE, new MediaType().schema(getErrorSchema.apply(type))); + operation + .getResponses() + .put( + type.httpStatus().value() + " " + type.name(), + new ApiResponse() + .description(type.message()) + .content(content)); + }); + return operation; + }; + } + private Info info() { String activeProfile = ObjectUtils.isEmpty(environment.getActiveProfiles()) ? environment.getDefaultProfiles()[0] diff --git a/src/main/java/com/dnd/runus/global/exception/BaseException.java b/src/main/java/com/dnd/runus/global/exception/BaseException.java new file mode 100644 index 00000000..021b3f66 --- /dev/null +++ b/src/main/java/com/dnd/runus/global/exception/BaseException.java @@ -0,0 +1,21 @@ +package com.dnd.runus.global.exception; + +import com.dnd.runus.global.exception.type.ErrorType; +import lombok.Getter; + +@Getter +public abstract class BaseException extends RuntimeException { + private final ErrorType type; + private final String message; + + public BaseException(ErrorType type, String message) { + super(message); + this.type = type; + this.message = message; + } + + @Override + public String toString() { + return "에러 타입: " + type + ", 사유: " + message; + } +} diff --git a/src/main/java/com/dnd/runus/global/exception/BusinessException.java b/src/main/java/com/dnd/runus/global/exception/BusinessException.java new file mode 100644 index 00000000..f2b54fdd --- /dev/null +++ b/src/main/java/com/dnd/runus/global/exception/BusinessException.java @@ -0,0 +1,9 @@ +package com.dnd.runus.global.exception; + +import com.dnd.runus.global.exception.type.ErrorType; + +public class BusinessException extends BaseException { + public BusinessException(ErrorType type, String message) { + super(type, message); + } +} diff --git a/src/main/java/com/dnd/runus/global/exception/NotFoundException.java b/src/main/java/com/dnd/runus/global/exception/NotFoundException.java new file mode 100644 index 00000000..c53c60c9 --- /dev/null +++ b/src/main/java/com/dnd/runus/global/exception/NotFoundException.java @@ -0,0 +1,9 @@ +package com.dnd.runus.global.exception; + +import com.dnd.runus.global.exception.type.ErrorType; + +public class NotFoundException extends BaseException { + public NotFoundException(String message) { + super(ErrorType.ENTITY_NOT_FOUND, message); + } +} diff --git a/src/main/java/com/dnd/runus/global/exception/type/ApiErrorType.java b/src/main/java/com/dnd/runus/global/exception/type/ApiErrorType.java new file mode 100644 index 00000000..2cdd0005 --- /dev/null +++ b/src/main/java/com/dnd/runus/global/exception/type/ApiErrorType.java @@ -0,0 +1,13 @@ +package com.dnd.runus.global.exception.type; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.METHOD; + +@Target({METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ApiErrorType { + ErrorType[] value(); +} diff --git a/src/main/java/com/dnd/runus/global/exception/type/ErrorType.java b/src/main/java/com/dnd/runus/global/exception/type/ErrorType.java new file mode 100644 index 00000000..945d3d03 --- /dev/null +++ b/src/main/java/com/dnd/runus/global/exception/type/ErrorType.java @@ -0,0 +1,30 @@ +package com.dnd.runus.global.exception.type; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; +import org.springframework.http.HttpStatus; + +import static lombok.AccessLevel.PRIVATE; +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.*; + +@Getter +@Accessors(fluent = true) +@RequiredArgsConstructor(access = PRIVATE) +public enum ErrorType { + // WebErrorType + UNHANDLED_EXCEPTION(INTERNAL_SERVER_ERROR, "WEB_001", "직접적으로 처리되지 않은 예외, 문의해주세요"), + FAILED_VALIDATION(BAD_REQUEST, "WEB_002", "Request 요청에서 올바르지 않은 값이 있습니다"), + FAILED_PARSING(BAD_REQUEST, "WEB_003", "Request JSON body를 파싱하지 못했습니다"), + UNSUPPORTED_API(BAD_REQUEST, "WEB_004", "지원하지 않는 API입니다"), + COOKIE_NOT_FOND(BAD_REQUEST, "WEB_005", "요청에 쿠키가 필요합니다"), + + // DatabaseErrorType + ENTITY_NOT_FOUND(NOT_FOUND, "DB_001", "해당 엔티티를 찾을 수 없습니다"), + VIOLATION_OCCURRED(NOT_ACCEPTABLE, "DB_002", "저장할 수 없는 값입니다"), + ; + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/com/dnd/runus/presentation/config/ObjectMapperConfig.java b/src/main/java/com/dnd/runus/presentation/config/ObjectMapperConfig.java new file mode 100644 index 00000000..9ef5ba3c --- /dev/null +++ b/src/main/java/com/dnd/runus/presentation/config/ObjectMapperConfig.java @@ -0,0 +1,16 @@ +package com.dnd.runus.presentation.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class ObjectMapperConfig { + @Bean + ObjectMapper objectMapper() { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + return objectMapper; + } +} diff --git a/src/main/java/com/dnd/runus/presentation/config/WebConfig.java b/src/main/java/com/dnd/runus/presentation/config/WebConfig.java new file mode 100644 index 00000000..e30bcfb6 --- /dev/null +++ b/src/main/java/com/dnd/runus/presentation/config/WebConfig.java @@ -0,0 +1,18 @@ +package com.dnd.runus.presentation.config; + +import com.dnd.runus.presentation.handler.NullResponseHandler; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + private final NullResponseHandler nullResponseHandler; + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(nullResponseHandler); + } +} diff --git a/src/main/java/com/dnd/runus/presentation/dto/response/ApiErrorDto.java b/src/main/java/com/dnd/runus/presentation/dto/response/ApiErrorDto.java new file mode 100644 index 00000000..1da919b0 --- /dev/null +++ b/src/main/java/com/dnd/runus/presentation/dto/response/ApiErrorDto.java @@ -0,0 +1,34 @@ +package com.dnd.runus.presentation.dto.response; + +import com.dnd.runus.global.exception.type.ErrorType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +import static lombok.AccessLevel.PRIVATE; + +@Schema(description = "API 애러 응답 형식") +@Getter +@ToString +@Builder(access = PRIVATE) +public class ApiErrorDto { + @Schema(description = "응답 상태 코드", example = "400") + private final int statusCode; + @Schema(description = "응답 코드 이름 (디버깅용)", example = "FAILED_AUTHENTICATION") + private final String code; + @Schema(description = "응답 메시지 (디버깅용)", example = "인증에 실패했습니다") + private final String message; + + public static ApiErrorDto of( + @NotNull ErrorType type, + String message + ) { + return ApiErrorDto.builder() + .statusCode(type.httpStatus().value()) + .code(type.code()) + .message(type.message() + ", " + message) + .build(); + } +} diff --git a/src/main/java/com/dnd/runus/presentation/dto/response/ApiResponse.java b/src/main/java/com/dnd/runus/presentation/dto/response/ApiResponse.java new file mode 100644 index 00000000..663aef8d --- /dev/null +++ b/src/main/java/com/dnd/runus/presentation/dto/response/ApiResponse.java @@ -0,0 +1,21 @@ +package com.dnd.runus.presentation.dto.response; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import static java.util.Collections.emptyMap; +import static java.util.Objects.requireNonNullElse; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record ApiResponse( + boolean success, + T data, + ApiErrorDto error +) { + public static ApiResponse success(T data) { + return new ApiResponse<>(true, requireNonNullElse(data, emptyMap()), null); + } + + public static ApiResponse fail(ApiErrorDto error) { + return new ApiResponse<>(false, null, error); + } +} diff --git a/src/main/java/com/dnd/runus/presentation/handler/ApiResponseHandler.java b/src/main/java/com/dnd/runus/presentation/handler/ApiResponseHandler.java new file mode 100644 index 00000000..75d535e1 --- /dev/null +++ b/src/main/java/com/dnd/runus/presentation/handler/ApiResponseHandler.java @@ -0,0 +1,60 @@ +package com.dnd.runus.presentation.handler; + +import com.dnd.runus.presentation.dto.response.ApiErrorDto; +import com.dnd.runus.presentation.dto.response.ApiResponse; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Nonnull; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; + +@Slf4j +@RequiredArgsConstructor +@RestControllerAdvice(basePackages = "com.dnd.runus") +public class ApiResponseHandler implements ResponseBodyAdvice { + private final ObjectMapper objectMapper; + + @Override + public boolean supports(@Nonnull MethodParameter returnType, @Nonnull Class converterType) { + return true; + } + + @Override + public Object beforeBodyWrite(Object body, @Nonnull MethodParameter returnType, + @Nonnull MediaType selectedContentType, @Nonnull Class selectedConverterType, + @Nonnull ServerHttpRequest request, @Nonnull ServerHttpResponse response) { + HttpServletResponse servletResponse = ((ServletServerHttpResponse) response).getServletResponse(); + + HttpStatus resolve = HttpStatus.resolve(servletResponse.getStatus()); + if (resolve == null || !resolve.is2xxSuccessful()) { + if (body instanceof ApiErrorDto error) { + return ApiResponse.fail(error); + } + log.error("Unreachable response handling! request: {}, response: {}, body: {}", request, response, body); + throw new UnsupportedOperationException("Unreachable response handling!" + body); + } + + if (body instanceof String) { + // String 타입을 Wrapper로 감싸면 StringConverter에서 처리할 수 없음 + // 따라서 ObjectMapper를 통해 직렬화하여 반환 + ApiResponse res = ApiResponse.success(body); + try { + String stringRes = objectMapper.writeValueAsString(res); + response.getHeaders().setContentType(MediaType.APPLICATION_JSON); + return stringRes; + } catch (JsonProcessingException err){ + throw new RuntimeException("Failed to convert BaseResponse to JSON"); + } + } + return ApiResponse.success(body); + } +} diff --git a/src/main/java/com/dnd/runus/presentation/handler/ExceptionRestHandler.java b/src/main/java/com/dnd/runus/presentation/handler/ExceptionRestHandler.java new file mode 100644 index 00000000..fa1a8e77 --- /dev/null +++ b/src/main/java/com/dnd/runus/presentation/handler/ExceptionRestHandler.java @@ -0,0 +1,71 @@ +package com.dnd.runus.presentation.handler; + +import com.dnd.runus.global.exception.BaseException; +import com.dnd.runus.global.exception.type.ErrorType; +import com.dnd.runus.presentation.dto.response.ApiErrorDto; +import jakarta.validation.ConstraintViolationException; +import jakarta.validation.constraints.NotNull; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +@Slf4j +@RestControllerAdvice +public class ExceptionRestHandler { + @ExceptionHandler(BaseException.class) + public ResponseEntity handleBaseException(BaseException e) { + log.warn(e.getMessage(), e); + return toResponseEntity(e.getType(), e.getMessage()); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception e) { + log.error(e.getMessage(), e); + return toResponseEntity(ErrorType.UNHANDLED_EXCEPTION, e.getMessage()); + } + + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handleNoResourceFoundException(NoResourceFoundException e) { + return toResponseEntity(ErrorType.UNSUPPORTED_API, e.getMessage()); + } + + ////////////////// 직렬화 / 역직렬화 예외 + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) { + log.warn(ex.getBindingResult().getAllErrors().toString()); + return toResponseEntity( + ErrorType.FAILED_VALIDATION, + ex.getBindingResult().getAllErrors().toString()); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException ex) { + return toResponseEntity(ErrorType.FAILED_PARSING, ex); + } + + ////////////////// Database 관련 예외 + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity handleDataIntegrityViolationException(DataIntegrityViolationException ex) { + log.warn(ex.getMessage(), ex); + return toResponseEntity(ErrorType.VIOLATION_OCCURRED, ex); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolationException(ConstraintViolationException ex) { + log.warn(ex.getMessage(), ex); + return toResponseEntity(ErrorType.VIOLATION_OCCURRED, ex); + } + + private static ResponseEntity toResponseEntity(@NotNull ErrorType type, Exception exception) { + return toResponseEntity(type, exception.getMessage()); + } + + private static ResponseEntity toResponseEntity(@NotNull ErrorType type, String message) { + return ResponseEntity.status(type.httpStatus().value()).body(ApiErrorDto.of(type, message)); + } +} diff --git a/src/main/java/com/dnd/runus/presentation/handler/NullResponseHandler.java b/src/main/java/com/dnd/runus/presentation/handler/NullResponseHandler.java new file mode 100644 index 00000000..e60b2c67 --- /dev/null +++ b/src/main/java/com/dnd/runus/presentation/handler/NullResponseHandler.java @@ -0,0 +1,28 @@ +package com.dnd.runus.presentation.handler; + +import com.dnd.runus.presentation.dto.response.ApiResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Nonnull; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.ModelAndView; + +import static org.springframework.util.MimeTypeUtils.APPLICATION_JSON_VALUE; + +@Component +@RequiredArgsConstructor +public class NullResponseHandler implements HandlerInterceptor { + private final ObjectMapper objectMapper; + + @Override + public void postHandle(@Nonnull HttpServletRequest request, HttpServletResponse response, @Nonnull Object handler, + ModelAndView modelAndView) throws Exception { + if (response.getContentType() == null && response.getStatus() == HttpServletResponse.SC_OK) { + response.setContentType(APPLICATION_JSON_VALUE); + response.getWriter().write(objectMapper.writeValueAsString(ApiResponse.success(null))); + } + } +}