diff --git a/src/main/java/com/zimdugo/common/entrypoint/GlobalExceptionHandler.java b/src/main/java/com/zimdugo/common/entrypoint/GlobalExceptionHandler.java index 035df30..3c05788 100644 --- a/src/main/java/com/zimdugo/common/entrypoint/GlobalExceptionHandler.java +++ b/src/main/java/com/zimdugo/common/entrypoint/GlobalExceptionHandler.java @@ -1,76 +1,115 @@ package com.zimdugo.common.entrypoint; -import com.zimdugo.core.exception.BusinessException; +import com.zimdugo.core.exception.CustomException; import com.zimdugo.core.exception.ErrorCode; -import com.zimdugo.core.exception.ErrorResponse; -import jakarta.servlet.http.HttpServletRequest; -import java.util.Map; +import com.zimdugo.core.exception.ExternalApiException; +import com.zimdugo.core.response.RestResponse; +import com.zimdugo.core.response.ValidationError; +import jakarta.validation.ConstraintViolationException; +import java.util.List; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatusCode; import org.springframework.http.ResponseEntity; -import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.context.request.ServletWebRequest; -import org.springframework.web.context.request.WebRequest; -import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; @Slf4j @RestControllerAdvice -public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { +public class GlobalExceptionHandler { - @ExceptionHandler(BusinessException.class) - public ResponseEntity> handleBusinessException( - BusinessException e, - HttpServletRequest request - ) { + @ExceptionHandler(ExternalApiException.class) + public ResponseEntity> handleExternalApiException(ExternalApiException e) { ErrorCode errorCode = e.getErrorCode(); - log.warn("BusinessException: code={}, message={}", errorCode.code(), e.getMessage()); + log.error("ExternalApiException 발생: code={}, message={}", errorCode.code(), e.getMessage(), e); return ResponseEntity.status(errorCode.httpStatus()) - .body(ErrorResponse.of(errorCode, request.getRequestURI(), e.getMessage())); + .body(RestResponse.error(errorCode)); + } + + @ExceptionHandler(CustomException.class) + public ResponseEntity> handleCustomException(CustomException e) { + ErrorCode errorCode = e.getErrorCode(); + log.warn("CustomException 발생: code={}, message={}", errorCode.code(), e.getMessage()); + return ResponseEntity.status(errorCode.httpStatus()) + .body(RestResponse.error(errorCode)); } @ExceptionHandler(IllegalArgumentException.class) - public ResponseEntity> handleIllegalArgument( - IllegalArgumentException e, - HttpServletRequest request - ) { - log.warn("IllegalArgumentException: {}", e.getMessage()); + public ResponseEntity> handleIllegalArgumentException(IllegalArgumentException e) { + log.warn("IllegalArgumentException 발생: {}", e.getMessage()); + return ResponseEntity.status(ErrorCode.BAD_REQUEST.httpStatus()) + .body(RestResponse.error(ErrorCode.BAD_REQUEST)); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity> handleConstraintViolationException(ConstraintViolationException e) { + List validationErrors = e.getConstraintViolations().stream() + .map(violation -> new ValidationError( + extractLastPathSegment(violation.getPropertyPath().toString()), + toValidationMessage( + violation.getConstraintDescriptor().getAnnotation().annotationType().getSimpleName() + ) + )) + .toList(); + + log.warn("ConstraintViolationException 발생: {}", e.getMessage()); return ResponseEntity.status(ErrorCode.BAD_REQUEST.httpStatus()) - .body(ErrorResponse.of(ErrorCode.BAD_REQUEST, request.getRequestURI(), e.getMessage())); + .body(RestResponse.error( + ErrorCode.BAD_REQUEST, + validationErrors + )); } @ExceptionHandler(Exception.class) - public ResponseEntity> handleException( - Exception e, - HttpServletRequest request - ) { + public ResponseEntity> handleException(Exception e) { log.error("Unhandled exception", e); return ResponseEntity.status(ErrorCode.INTERNAL_SERVER_ERROR.httpStatus()) - .body(ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR, request.getRequestURI())); + .body(RestResponse.error(ErrorCode.INTERNAL_SERVER_ERROR)); } - @Override - protected ResponseEntity handleMethodArgumentNotValid( - MethodArgumentNotValidException ex, - HttpHeaders headers, - HttpStatusCode status, - WebRequest request - ) { - String message = ex.getBindingResult().getFieldErrors().stream() - .findFirst() - .map(this::toFieldMessage) - .orElse("validation failed"); + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleMethodArgumentNotValid(MethodArgumentNotValidException ex) { + List validationErrors = ex.getBindingResult().getFieldErrors().stream() + .map(fieldError -> new ValidationError( + fieldError.getField(), + toValidationMessage(fieldError.getCode()) + )) + .collect(java.util.stream.Collectors.toCollection(java.util.ArrayList::new)); + ex.getBindingResult().getGlobalErrors().stream() + .map(objectError -> new ValidationError( + "global", + toValidationMessage(objectError.getCode()) + )) + .forEach(validationErrors::add); - String path = ((ServletWebRequest) request).getRequest().getRequestURI(); - log.warn("ValidationException: {}", message); + log.warn("MethodArgumentNotValidException 발생"); return ResponseEntity.status(ErrorCode.BAD_REQUEST.httpStatus()) - .body(ErrorResponse.of(ErrorCode.BAD_REQUEST, path, message)); + .body(RestResponse.error( + ErrorCode.BAD_REQUEST, + validationErrors + )); + } + + private String extractLastPathSegment(String path) { + if (path == null || path.isBlank()) { + return "global"; + } + + int lastDotIndex = path.lastIndexOf('.'); + if (lastDotIndex >= 0 && lastDotIndex < path.length() - 1) { + return path.substring(lastDotIndex + 1); + } + return path; } - private String toFieldMessage(FieldError fieldError) { - return fieldError.getField() + ": " + fieldError.getDefaultMessage(); + private String toValidationMessage(String rawCode) { + if (rawCode == null || rawCode.isBlank()) { + return "validation.invalid"; + } + + String snake = rawCode + .replaceAll("([a-z0-9])([A-Z])", "$1_$2") + .replace('-', '_') + .toLowerCase(); + return "validation." + snake; } } diff --git a/src/main/java/com/zimdugo/common/security/CustomAccessDeniedHandler.java b/src/main/java/com/zimdugo/common/security/CustomAccessDeniedHandler.java index d917c2e..cc6d71e 100644 --- a/src/main/java/com/zimdugo/common/security/CustomAccessDeniedHandler.java +++ b/src/main/java/com/zimdugo/common/security/CustomAccessDeniedHandler.java @@ -2,11 +2,10 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.zimdugo.core.exception.ErrorCode; -import com.zimdugo.core.exception.ErrorResponse; +import com.zimdugo.core.response.RestResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; -import java.util.Map; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.web.access.AccessDeniedHandler; import org.springframework.stereotype.Component; @@ -28,7 +27,7 @@ public void handle( response.setContentType(APPLICATION_JSON); response.setCharacterEncoding(UTF_8); - Map body = ErrorResponse.of(ErrorCode.FORBIDDEN, request.getRequestURI()); + RestResponse body = RestResponse.error(ErrorCode.FORBIDDEN); response.getWriter().write(objectMapper.writeValueAsString(body)); } diff --git a/src/main/java/com/zimdugo/common/security/CustomAuthenticationEntryPoint.java b/src/main/java/com/zimdugo/common/security/CustomAuthenticationEntryPoint.java index 48d2463..6dcdd26 100644 --- a/src/main/java/com/zimdugo/common/security/CustomAuthenticationEntryPoint.java +++ b/src/main/java/com/zimdugo/common/security/CustomAuthenticationEntryPoint.java @@ -2,11 +2,10 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.zimdugo.core.exception.ErrorCode; -import com.zimdugo.core.exception.ErrorResponse; +import com.zimdugo.core.response.RestResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; -import java.util.Map; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; @@ -28,7 +27,7 @@ public void commence( response.setContentType(APPLICATION_JSON); response.setCharacterEncoding(UTF_8); - Map body = ErrorResponse.of(ErrorCode.UNAUTHORIZED, request.getRequestURI()); + RestResponse body = RestResponse.error(ErrorCode.UNAUTHORIZED); response.getWriter().write(objectMapper.writeValueAsString(body)); } diff --git a/src/main/java/com/zimdugo/core/exception/BusinessException.java b/src/main/java/com/zimdugo/core/exception/BusinessException.java index 4b6db4f..c135e73 100644 --- a/src/main/java/com/zimdugo/core/exception/BusinessException.java +++ b/src/main/java/com/zimdugo/core/exception/BusinessException.java @@ -1,15 +1,16 @@ package com.zimdugo.core.exception; -public class BusinessException extends RuntimeException { - - private final ErrorCode errorCode; +public class BusinessException extends CustomException { public BusinessException(ErrorCode errorCode) { - super(errorCode.message()); - this.errorCode = errorCode; + super(errorCode); + } + + public BusinessException(ErrorCode errorCode, String message) { + super(errorCode, message); } - public ErrorCode getErrorCode() { - return errorCode; + public BusinessException(ErrorCode errorCode, String message, Throwable cause) { + super(errorCode, message, cause); } } diff --git a/src/main/java/com/zimdugo/core/exception/CustomException.java b/src/main/java/com/zimdugo/core/exception/CustomException.java new file mode 100644 index 0000000..7b7a89b --- /dev/null +++ b/src/main/java/com/zimdugo/core/exception/CustomException.java @@ -0,0 +1,25 @@ +package com.zimdugo.core.exception; + +import lombok.Getter; + +@Getter +public class CustomException extends RuntimeException { + + private final ErrorCode errorCode; + + public CustomException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public CustomException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + public CustomException(ErrorCode errorCode, String message, Throwable cause) { + super(message, cause); + this.errorCode = errorCode; + } + +} diff --git a/src/main/java/com/zimdugo/core/exception/ErrorCode.java b/src/main/java/com/zimdugo/core/exception/ErrorCode.java index 5fe7136..97beed5 100644 --- a/src/main/java/com/zimdugo/core/exception/ErrorCode.java +++ b/src/main/java/com/zimdugo/core/exception/ErrorCode.java @@ -1,24 +1,36 @@ package com.zimdugo.core.exception; +import com.zimdugo.core.response.BaseCode; +import lombok.Getter; import org.springframework.http.HttpStatus; -public enum ErrorCode { - BAD_REQUEST("C400", "bad request", HttpStatus.BAD_REQUEST), - UNAUTHORIZED("C401", "unauthorized", HttpStatus.UNAUTHORIZED), - FORBIDDEN("C403", "forbidden", HttpStatus.FORBIDDEN), - NOT_FOUND("C404", "resource not found", HttpStatus.NOT_FOUND), - INTERNAL_SERVER_ERROR("C500", "internal server error", HttpStatus.INTERNAL_SERVER_ERROR), - - REFRESH_TOKEN_NOT_FOUND("A4001", "refresh token not found", HttpStatus.BAD_REQUEST), - INVALID_REFRESH_TOKEN("A4002", "invalid refresh token", HttpStatus.BAD_REQUEST), - REFRESH_TOKEN_MISMATCH("A4003", "refresh token mismatch", HttpStatus.BAD_REQUEST), - REFRESH_TOKEN_REVOKED("A4004", "refresh token revoked", HttpStatus.BAD_REQUEST), - USER_NOT_FOUND("U4041", "user not found", HttpStatus.NOT_FOUND), - USER_ALREADY_WITHDRAWN("U4002", "user already withdrawn", HttpStatus.BAD_REQUEST), - UNSUPPORTED_SOCIAL_LOGIN("A4005", "unsupported social login provider", HttpStatus.BAD_REQUEST), - AUTHENTICATED_USER_NOT_FOUND("A4011", "authenticated user not found", HttpStatus.UNAUTHORIZED); +public enum ErrorCode implements BaseCode { + BAD_REQUEST("C400", "common.bad_request", HttpStatus.BAD_REQUEST), + UNAUTHORIZED("C401", "common.unauthorized", HttpStatus.UNAUTHORIZED), + FORBIDDEN("C403", "common.forbidden", HttpStatus.FORBIDDEN), + NOT_FOUND("C404", "common.not_found", HttpStatus.NOT_FOUND), + INTERNAL_SERVER_ERROR("C500", "common.internal_server_error", HttpStatus.INTERNAL_SERVER_ERROR), + + REFRESH_TOKEN_NOT_FOUND("A4001", "auth.refresh_token_not_found", HttpStatus.BAD_REQUEST), + INVALID_REFRESH_TOKEN("A4002", "auth.invalid_refresh_token", HttpStatus.BAD_REQUEST), + REFRESH_TOKEN_MISMATCH("A4003", "auth.refresh_token_mismatch", HttpStatus.BAD_REQUEST), + REFRESH_TOKEN_REVOKED("A4004", "auth.refresh_token_revoked", HttpStatus.BAD_REQUEST), + + + EXTERNAL_API_ERROR("E5021", "external.api_error", HttpStatus.BAD_GATEWAY), + + + USER_NOT_FOUND("U4041", "user.not_found", HttpStatus.NOT_FOUND), + USER_ALREADY_WITHDRAWN("U4002", "user.already_withdrawn", HttpStatus.BAD_REQUEST), + + + UNSUPPORTED_SOCIAL_LOGIN("A4005", "auth.unsupported_social_login", HttpStatus.BAD_REQUEST), + AUTHENTICATED_USER_NOT_FOUND("A4011", "auth.authenticated_user_not_found", HttpStatus.UNAUTHORIZED); + + @Getter private final String code; + @Getter private final String message; private final HttpStatus httpStatus; @@ -39,4 +51,8 @@ public String message() { public HttpStatus httpStatus() { return httpStatus; } + + public HttpStatus getStatus() { + return httpStatus; + } } diff --git a/src/main/java/com/zimdugo/core/exception/ErrorResponse.java b/src/main/java/com/zimdugo/core/exception/ErrorResponse.java deleted file mode 100644 index f53b176..0000000 --- a/src/main/java/com/zimdugo/core/exception/ErrorResponse.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.zimdugo.core.exception; - -import java.time.OffsetDateTime; -import java.util.LinkedHashMap; -import java.util.Map; - -public class ErrorResponse { - - private ErrorResponse() { - } - - public static Map of(ErrorCode errorCode, String path) { - Map body = new LinkedHashMap<>(); - body.put("status", errorCode.httpStatus().value()); - body.put("code", errorCode.code()); - body.put("message", errorCode.message()); - body.put("path", path); - body.put("timestamp", OffsetDateTime.now().toString()); - return body; - } - - public static Map of(ErrorCode errorCode, String path, String overrideMessage) { - Map body = of(errorCode, path); - body.put("message", overrideMessage); - return body; - } -} diff --git a/src/main/java/com/zimdugo/core/exception/ExternalApiException.java b/src/main/java/com/zimdugo/core/exception/ExternalApiException.java new file mode 100644 index 0000000..e480cdb --- /dev/null +++ b/src/main/java/com/zimdugo/core/exception/ExternalApiException.java @@ -0,0 +1,12 @@ +package com.zimdugo.core.exception; + +public class ExternalApiException extends CustomException { + + public ExternalApiException(String message) { + super(ErrorCode.EXTERNAL_API_ERROR, message); + } + + public ExternalApiException(String message, Throwable cause) { + super(ErrorCode.EXTERNAL_API_ERROR, message, cause); + } +} diff --git a/src/main/java/com/zimdugo/core/response/BaseCode.java b/src/main/java/com/zimdugo/core/response/BaseCode.java new file mode 100644 index 0000000..f72dcd3 --- /dev/null +++ b/src/main/java/com/zimdugo/core/response/BaseCode.java @@ -0,0 +1,11 @@ +package com.zimdugo.core.response; + +import org.springframework.http.HttpStatus; + +public interface BaseCode { + String getCode(); + + String getMessage(); + + HttpStatus getStatus(); +} diff --git a/src/main/java/com/zimdugo/core/response/RestResponse.java b/src/main/java/com/zimdugo/core/response/RestResponse.java new file mode 100644 index 0000000..a76504c --- /dev/null +++ b/src/main/java/com/zimdugo/core/response/RestResponse.java @@ -0,0 +1,56 @@ +package com.zimdugo.core.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.zimdugo.core.exception.ErrorCode; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import lombok.Getter; + +@Getter +@JsonInclude(JsonInclude.Include.NON_NULL) +public class RestResponse { + + private final String code; + private final String message; + private final int status; + private final T data; + private final OffsetDateTime timestamp; + private final List validationErrors; + + private RestResponse(BaseCode baseCode, T data, List validationErrors) { + this.code = baseCode.getCode(); + this.message = baseCode.getMessage(); + this.status = baseCode.getStatus().value(); + this.data = data; + this.timestamp = OffsetDateTime.now(ZoneOffset.UTC); + this.validationErrors = validationErrors; + } + + public static RestResponse ok(SuccessCode code, T data) { + return success(code, data); + } + + public static RestResponse ok(SuccessCode code) { + return success(code, null); + } + + public static RestResponse error(ErrorCode code) { + return failure(code, null); + } + + public static RestResponse error(ErrorCode code, List validationErrors) { + return failure(code, validationErrors); + } + + private static RestResponse success(SuccessCode code, T data) { + return new RestResponse<>(code, data, null); + } + + private static RestResponse failure( + ErrorCode code, + List validationErrors + ) { + return new RestResponse<>(code, null, validationErrors); + } +} diff --git a/src/main/java/com/zimdugo/core/response/SuccessCode.java b/src/main/java/com/zimdugo/core/response/SuccessCode.java new file mode 100644 index 0000000..9d6d8f7 --- /dev/null +++ b/src/main/java/com/zimdugo/core/response/SuccessCode.java @@ -0,0 +1,20 @@ +package com.zimdugo.core.response; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public enum SuccessCode implements BaseCode { + OK("S200", "common.ok", HttpStatus.OK); + + private final String code; + private final String message; + private final HttpStatus status; + + SuccessCode(String code, String message, HttpStatus status) { + this.code = code; + this.message = message; + this.status = status; + } + +} diff --git a/src/main/java/com/zimdugo/core/response/ValidationError.java b/src/main/java/com/zimdugo/core/response/ValidationError.java new file mode 100644 index 0000000..d820be0 --- /dev/null +++ b/src/main/java/com/zimdugo/core/response/ValidationError.java @@ -0,0 +1,7 @@ +package com.zimdugo.core.response; + +public record ValidationError( + String field, + String message +) { +}