diff --git a/.idea/misc.xml b/.idea/misc.xml index 1af07c1..fd57316 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -3,4 +3,10 @@ + + + + + + \ No newline at end of file diff --git a/build.gradle b/build.gradle index 692c5f0..7016f02 100644 --- a/build.gradle +++ b/build.gradle @@ -20,7 +20,9 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-webmvc' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-docker-compose' runtimeOnly 'com.mysql:mysql-connector-j' diff --git a/src/main/java/ject/official_qr_checkin_server/common/exception/BusinessException.java b/src/main/java/ject/official_qr_checkin_server/common/exception/BusinessException.java new file mode 100644 index 0000000..b285c46 --- /dev/null +++ b/src/main/java/ject/official_qr_checkin_server/common/exception/BusinessException.java @@ -0,0 +1,23 @@ +package ject.official_qr_checkin_server.common.exception; + +import java.io.Serial; +import lombok.Getter; + +@Getter +public class BusinessException extends RuntimeException { + + @Serial + private static final long serialVersionUID = 1L; + + private final ErrorCode errorCode; + + public BusinessException(ErrorCode errorCode) { + this(errorCode, errorCode.getMessage()); + } + + public BusinessException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + +} diff --git a/src/main/java/ject/official_qr_checkin_server/common/exception/ErrorCode.java b/src/main/java/ject/official_qr_checkin_server/common/exception/ErrorCode.java new file mode 100644 index 0000000..e42dfba --- /dev/null +++ b/src/main/java/ject/official_qr_checkin_server/common/exception/ErrorCode.java @@ -0,0 +1,13 @@ +package ject.official_qr_checkin_server.common.exception; + +import java.io.Serializable; +import org.springframework.http.HttpStatus; + +public interface ErrorCode extends Serializable { + + HttpStatus getHttpStatus(); + + String getCode(); + + String getMessage(); +} diff --git a/src/main/java/ject/official_qr_checkin_server/common/exception/GlobalErrorCode.java b/src/main/java/ject/official_qr_checkin_server/common/exception/GlobalErrorCode.java new file mode 100644 index 0000000..f481db3 --- /dev/null +++ b/src/main/java/ject/official_qr_checkin_server/common/exception/GlobalErrorCode.java @@ -0,0 +1,67 @@ +package ject.official_qr_checkin_server.common.exception; + +import org.springframework.http.HttpStatus; + +public enum GlobalErrorCode implements ErrorCode { + + UNSUPPORTED_PARAMETER_TYPE( + HttpStatus.BAD_REQUEST, + "GLOBAL-001", + "요청 파라미터의 형식이 올바르지 않습니다." + ), + MISSING_REQUEST_PARAMETER( + HttpStatus.BAD_REQUEST, + "GLOBAL-002", + "필수 요청 파라미터가 누락되었습니다." + ), + MISSING_REQUEST_BODY( + HttpStatus.BAD_REQUEST, + "GLOBAL-003", + "요청 본문이 누락되었거나 형식이 올바르지 않습니다." + ), + VALIDATION_FAILED( + HttpStatus.BAD_REQUEST, + "GLOBAL-004", + "요청 값 검증에 실패했습니다." + ), + METHOD_NOT_ALLOWED( + HttpStatus.METHOD_NOT_ALLOWED, + "GLOBAL-005", + "지원하지 않는 HTTP 메서드입니다." + ), + RESOURCE_NOT_FOUND( + HttpStatus.NOT_FOUND, + "GLOBAL-006", + "요청한 리소스를 찾을 수 없습니다." + ), + INTERNAL_SERVER_ERROR( + HttpStatus.INTERNAL_SERVER_ERROR, + "GLOBAL-999", + "서버 내부 오류가 발생했습니다." + ); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + GlobalErrorCode(HttpStatus httpStatus, String code, String message) { + this.httpStatus = httpStatus; + this.code = code; + this.message = message; + } + + @Override + public HttpStatus getHttpStatus() { + return httpStatus; + } + + @Override + public String getCode() { + return code; + } + + @Override + public String getMessage() { + return message; + } +} diff --git a/src/main/java/ject/official_qr_checkin_server/common/exception/GlobalExceptionHandler.java b/src/main/java/ject/official_qr_checkin_server/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..7907976 --- /dev/null +++ b/src/main/java/ject/official_qr_checkin_server/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,126 @@ +package ject.official_qr_checkin_server.common.exception; + +import java.util.List; +import java.util.Objects; +import ject.official_qr_checkin_server.common.response.ApiResponse; +import ject.official_qr_checkin_server.common.response.ErrorResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.MessageSourceResolvable; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.HandlerMethodValidationException; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.servlet.resource.NoResourceFoundException; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(BusinessException.class) + ResponseEntity>> handleBusinessException(BusinessException exception) { + ErrorCode errorCode = exception.getErrorCode(); + logKnownException(exception, errorCode); + return createResponse(ErrorResponse.of(errorCode, exception.getMessage())); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + ResponseEntity>> handleMethodArgumentTypeMismatch( + MethodArgumentTypeMismatchException exception + ) { + return handleKnownException(exception, GlobalErrorCode.UNSUPPORTED_PARAMETER_TYPE); + } + + @ExceptionHandler(MissingServletRequestParameterException.class) + ResponseEntity>> handleMissingRequestParameter( + MissingServletRequestParameterException exception + ) { + return handleKnownException(exception, GlobalErrorCode.MISSING_REQUEST_PARAMETER); + } + + @ExceptionHandler(HttpMessageNotReadableException.class) + ResponseEntity>> handleMessageNotReadable( + HttpMessageNotReadableException exception + ) { + return handleKnownException(exception, GlobalErrorCode.MISSING_REQUEST_BODY); + } + + @ExceptionHandler({ + MethodArgumentNotValidException.class, + HandlerMethodValidationException.class + }) + ResponseEntity>> handleValidationException(Exception exception) { + GlobalErrorCode errorCode = GlobalErrorCode.VALIDATION_FAILED; + logKnownException(exception, errorCode); + + List messages = switch (exception) { + case MethodArgumentNotValidException methodArgumentNotValidException -> + extractMessages(methodArgumentNotValidException.getAllErrors(), errorCode); + case HandlerMethodValidationException handlerMethodValidationException -> + extractMessages(handlerMethodValidationException.getAllErrors(), errorCode); + default -> List.of(errorCode.getMessage()); + }; + + return createResponse(ErrorResponse.of(errorCode, messages)); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + ResponseEntity>> handleMethodNotSupported( + HttpRequestMethodNotSupportedException exception + ) { + return handleKnownException(exception, GlobalErrorCode.METHOD_NOT_ALLOWED); + } + + @ExceptionHandler(NoResourceFoundException.class) + ResponseEntity>> handleResourceNotFound(NoResourceFoundException exception) { + return handleKnownException(exception, GlobalErrorCode.RESOURCE_NOT_FOUND); + } + + @ExceptionHandler(Exception.class) + ResponseEntity>> handleUnexpectedException(Exception exception) { + GlobalErrorCode errorCode = GlobalErrorCode.INTERNAL_SERVER_ERROR; + log.error("Unhandled exception. responseCode={}", errorCode.getCode(), exception); + return createResponse(ErrorResponse.of(errorCode)); + } + + private ResponseEntity>> handleKnownException( + Exception exception, + ErrorCode errorCode + ) { + logKnownException(exception, errorCode); + return createResponse(ErrorResponse.of(errorCode)); + } + + private List extractMessages( + List errors, + ErrorCode fallbackErrorCode + ) { + List messages = errors.stream() + .map(MessageSourceResolvable::getDefaultMessage) + .filter(Objects::nonNull) + .distinct() + .toList(); + + return messages.isEmpty() ? List.of(fallbackErrorCode.getMessage()) : messages; + } + + private ResponseEntity>> createResponse(ErrorResponse errorResponse) { + return ResponseEntity + .status(errorResponse.httpStatus()) + .body(errorResponse.toApiResponse()); + } + + private void logKnownException(Exception exception, ErrorCode errorCode) { + log.warn( + "Handled exception. type={}, responseCode={}", + exception.getClass().getSimpleName(), + errorCode.getCode() + ); + } +} diff --git a/src/main/java/ject/official_qr_checkin_server/common/response/ApiResponse.java b/src/main/java/ject/official_qr_checkin_server/common/response/ApiResponse.java new file mode 100644 index 0000000..d6b8298 --- /dev/null +++ b/src/main/java/ject/official_qr_checkin_server/common/response/ApiResponse.java @@ -0,0 +1,20 @@ +package ject.official_qr_checkin_server.common.response; + +import java.time.Instant; + +public record ApiResponse( + String status, + T data, + Instant timestamp +) { + + private static final String SUCCESS_STATUS = "SUCCESS"; + + public static ApiResponse success(T data) { + return new ApiResponse<>(SUCCESS_STATUS, data, Instant.now()); + } + + public static ApiResponse error(String status, T data) { + return new ApiResponse<>(status, data, Instant.now()); + } +} diff --git a/src/main/java/ject/official_qr_checkin_server/common/response/ApiResponseBodyAdvice.java b/src/main/java/ject/official_qr_checkin_server/common/response/ApiResponseBodyAdvice.java new file mode 100644 index 0000000..f784d86 --- /dev/null +++ b/src/main/java/ject/official_qr_checkin_server/common/response/ApiResponseBodyAdvice.java @@ -0,0 +1,71 @@ +package ject.official_qr_checkin_server.common.response; + +import java.util.List; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ProblemDetail; +import org.springframework.http.converter.AbstractJacksonHttpMessageConverter; +import org.springframework.http.converter.HttpMessageConverter; +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; + +@RestControllerAdvice +public class ApiResponseBodyAdvice implements ResponseBodyAdvice { + + private static final List EXCLUDED_PATH_PREFIXES = List.of( + "/actuator", + "/v3/api-docs", + "/swagger-ui" + ); + + @Override + public boolean supports( + MethodParameter returnType, + Class> converterType + ) { + return AbstractJacksonHttpMessageConverter.class.isAssignableFrom(converterType); + } + + @Override + public Object beforeBodyWrite( + Object body, + MethodParameter returnType, + MediaType selectedContentType, + Class> selectedConverterType, + ServerHttpRequest request, + ServerHttpResponse response + ) { + if (shouldSkip(body, request, response)) { + return body; + } + + return ApiResponse.success(body); + } + + private boolean shouldSkip(Object body, ServerHttpRequest request, ServerHttpResponse response) { + return body instanceof ApiResponse + || body instanceof ProblemDetail + || isExcludedPath(request.getURI().getPath()) + || isNonSuccessfulOrEmptyResponse(response); + } + + private boolean isExcludedPath(String path) { + return EXCLUDED_PATH_PREFIXES.stream().anyMatch(path::startsWith); + } + + private boolean isNonSuccessfulOrEmptyResponse(ServerHttpResponse response) { + if (!(response instanceof ServletServerHttpResponse servletResponse)) { + return false; + } + + int status = servletResponse.getServletResponse().getStatus(); + return status < 200 + || status >= 300 + || status == HttpStatus.NO_CONTENT.value() + || status == HttpStatus.RESET_CONTENT.value(); + } +} diff --git a/src/main/java/ject/official_qr_checkin_server/common/response/ErrorResponse.java b/src/main/java/ject/official_qr_checkin_server/common/response/ErrorResponse.java new file mode 100644 index 0000000..9a49594 --- /dev/null +++ b/src/main/java/ject/official_qr_checkin_server/common/response/ErrorResponse.java @@ -0,0 +1,32 @@ +package ject.official_qr_checkin_server.common.response; + +import java.util.List; +import ject.official_qr_checkin_server.common.exception.ErrorCode; +import org.springframework.http.HttpStatus; + +public record ErrorResponse( + HttpStatus httpStatus, + String code, + List messages +) { + + public ErrorResponse { + messages = List.copyOf(messages); + } + + public static ErrorResponse of(ErrorCode errorCode) { + return of(errorCode, List.of(errorCode.getMessage())); + } + + public static ErrorResponse of(ErrorCode errorCode, String message) { + return of(errorCode, List.of(message)); + } + + public static ErrorResponse of(ErrorCode errorCode, List messages) { + return new ErrorResponse(errorCode.getHttpStatus(), errorCode.getCode(), messages); + } + + public ApiResponse> toApiResponse() { + return ApiResponse.error(code, messages); + } +} diff --git a/src/main/java/ject/official_qr_checkin_server/common/springdoc/ApiErrorResponse.java b/src/main/java/ject/official_qr_checkin_server/common/springdoc/ApiErrorResponse.java new file mode 100644 index 0000000..4e8f6d7 --- /dev/null +++ b/src/main/java/ject/official_qr_checkin_server/common/springdoc/ApiErrorResponse.java @@ -0,0 +1,20 @@ +package ject.official_qr_checkin_server.common.springdoc; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import ject.official_qr_checkin_server.common.exception.ErrorCode; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Repeatable(ApiErrorResponses.class) +public @interface ApiErrorResponse { + + Class value(); + + String name(); + + String description() default ""; +} diff --git a/src/main/java/ject/official_qr_checkin_server/common/springdoc/ApiErrorResponses.java b/src/main/java/ject/official_qr_checkin_server/common/springdoc/ApiErrorResponses.java new file mode 100644 index 0000000..306795c --- /dev/null +++ b/src/main/java/ject/official_qr_checkin_server/common/springdoc/ApiErrorResponses.java @@ -0,0 +1,13 @@ +package ject.official_qr_checkin_server.common.springdoc; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ApiErrorResponses { + + ApiErrorResponse[] value(); +} diff --git a/src/main/java/ject/official_qr_checkin_server/common/springdoc/ErrorResponseCustomizer.java b/src/main/java/ject/official_qr_checkin_server/common/springdoc/ErrorResponseCustomizer.java new file mode 100644 index 0000000..f010fd3 --- /dev/null +++ b/src/main/java/ject/official_qr_checkin_server/common/springdoc/ErrorResponseCustomizer.java @@ -0,0 +1,101 @@ +package ject.official_qr_checkin_server.common.springdoc; + +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.examples.Example; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.responses.ApiResponse; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; +import ject.official_qr_checkin_server.common.exception.ErrorCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springdoc.core.customizers.OperationCustomizer; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; + +@Component +public class ErrorResponseCustomizer implements OperationCustomizer { + + private static final Logger log = LoggerFactory.getLogger(ErrorResponseCustomizer.class); + private static final String EXAMPLE_TIMESTAMP = "2026-07-30T08:25:00Z"; + + @Override + public Operation customize(Operation operation, HandlerMethod handlerMethod) { + ApiErrorResponse[] annotations = handlerMethod.getMethod().getAnnotationsByType(ApiErrorResponse.class); + if (annotations.length == 0) { + return operation; + } + + Map> examplesByStatus = Arrays.stream(annotations) + .map(this::createExample) + .filter(Objects::nonNull) + .collect(Collectors.groupingBy( + example -> example.errorCode().getHttpStatus().value(), + LinkedHashMap::new, + Collectors.toList() + )); + + examplesByStatus.forEach((status, examples) -> addExamples(operation, status, examples)); + return operation; + } + + private ErrorExample createExample(ApiErrorResponse annotation) { + ErrorCode errorCode = findErrorCode(annotation); + if (errorCode == null) { + log.warn( + "Swagger error example ignored. type={}, name={}", + annotation.value().getSimpleName(), + annotation.name() + ); + return null; + } + + Map body = new LinkedHashMap<>(); + body.put("status", errorCode.getCode()); + body.put("data", List.of(errorCode.getMessage())); + body.put("timestamp", EXAMPLE_TIMESTAMP); + + Example example = new Example() + .summary(annotation.description()) + .value(body); + return new ErrorExample(annotation.name(), errorCode, example); + } + + private ErrorCode findErrorCode(ApiErrorResponse annotation) { + Class errorCodeType = annotation.value(); + if (!errorCodeType.isEnum()) { + return null; + } + + return Arrays.stream(errorCodeType.getEnumConstants()) + .filter(constant -> ((Enum) constant).name().equals(annotation.name())) + .findFirst() + .orElse(null); + } + + private void addExamples(Operation operation, int status, List examples) { + String statusCode = String.valueOf(status); + ApiResponse apiResponse = operation.getResponses().computeIfAbsent( + statusCode, + key -> new ApiResponse().description(examples.getFirst().errorCode().getHttpStatus().getReasonPhrase()) + ); + + if (apiResponse.getContent() == null) { + apiResponse.setContent(new Content()); + } + + MediaType mediaType = apiResponse.getContent().computeIfAbsent( + org.springframework.http.MediaType.APPLICATION_JSON_VALUE, + key -> new MediaType() + ); + examples.forEach(example -> mediaType.addExamples(example.name(), example.example())); + } + + private record ErrorExample(String name, ErrorCode errorCode, Example example) { + } +} diff --git a/src/main/java/ject/official_qr_checkin_server/common/springdoc/SpringdocConfig.java b/src/main/java/ject/official_qr_checkin_server/common/springdoc/SpringdocConfig.java new file mode 100644 index 0000000..b051034 --- /dev/null +++ b/src/main/java/ject/official_qr_checkin_server/common/springdoc/SpringdocConfig.java @@ -0,0 +1,39 @@ +package ject.official_qr_checkin_server.common.springdoc; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.servers.Server; +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SpringdocConfig { + + @Bean + OpenAPI checkInOpenApi() { + return new OpenAPI() + .info(new Info() + .title("젝트 행사 출석체크 API") + .description("젝트 공식 행사 QR 출석체크 서버 API 명세서입니다.") + .version("v1")) + .addServersItem(new Server().url("/")); + } + + @Bean + GroupedOpenApi checkInApi( + SuccessResponseCustomizer successResponseCustomizer, + ErrorResponseCustomizer errorResponseCustomizer + ) { + return GroupedOpenApi.builder() + .group("check-in-api") + .pathsToMatch("/**") + .pathsToExclude( + "/actuator/**", + "/error" + ) + .addOperationCustomizer(successResponseCustomizer) + .addOperationCustomizer(errorResponseCustomizer) + .build(); + } +} diff --git a/src/main/java/ject/official_qr_checkin_server/common/springdoc/SuccessResponseCustomizer.java b/src/main/java/ject/official_qr_checkin_server/common/springdoc/SuccessResponseCustomizer.java new file mode 100644 index 0000000..bf815c0 --- /dev/null +++ b/src/main/java/ject/official_qr_checkin_server/common/springdoc/SuccessResponseCustomizer.java @@ -0,0 +1,52 @@ +package ject.official_qr_checkin_server.common.springdoc; + +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.media.StringSchema; +import io.swagger.v3.oas.models.responses.ApiResponse; +import java.util.Map.Entry; +import org.springdoc.core.customizers.OperationCustomizer; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; + +@Component +public class SuccessResponseCustomizer implements OperationCustomizer { + + @Override + public Operation customize(Operation operation, HandlerMethod handlerMethod) { + operation.getResponses().entrySet().stream() + .filter(entry -> entry.getKey().startsWith("2") || entry.getKey().equals("default")) + .map(Entry::getValue) + .forEach(this::wrapResponseSchema); + return operation; + } + + private void wrapResponseSchema(ApiResponse response) { + if (response.getContent() == null) { + return; + } + + response.getContent().forEach((mediaType, content) -> { + Schema originalSchema = content.getSchema(); + if (originalSchema == null || isAlreadyWrapped(originalSchema)) { + return; + } + + Schema wrapperSchema = new Schema<>(); + wrapperSchema.addProperty("status", new StringSchema().example("SUCCESS")); + wrapperSchema.addProperty("data", originalSchema); + wrapperSchema.addProperty( + "timestamp", + new StringSchema().format("date-time").example("2026-07-30T08:25:00Z") + ); + content.setSchema(wrapperSchema); + }); + } + + private boolean isAlreadyWrapped(Schema schema) { + return schema.getProperties() != null + && schema.getProperties().containsKey("status") + && schema.getProperties().containsKey("data") + && schema.getProperties().containsKey("timestamp"); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 7b7e345..76f572a 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -12,6 +12,12 @@ management.endpoints.web.exposure.include=health management.endpoint.health.show-details=never management.endpoint.health.probes.enabled=true +springdoc.api-docs.path=/v3/api-docs +springdoc.swagger-ui.path=/swagger-ui.html +springdoc.swagger-ui.operations-sorter=method +springdoc.swagger-ui.tags-sorter=alpha +springdoc.swagger-ui.display-request-duration=true + server.forward-headers-strategy=framework server.shutdown=graceful spring.lifecycle.timeout-per-shutdown-phase=20s diff --git a/src/test/java/ject/official_qr_checkin_server/common/exception/GlobalExceptionHandlerTests.java b/src/test/java/ject/official_qr_checkin_server/common/exception/GlobalExceptionHandlerTests.java new file mode 100644 index 0000000..3c40033 --- /dev/null +++ b/src/test/java/ject/official_qr_checkin_server/common/exception/GlobalExceptionHandlerTests.java @@ -0,0 +1,156 @@ +package ject.official_qr_checkin_server.common.exception; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +class GlobalExceptionHandlerTests { + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + LocalValidatorFactoryBean validator = new LocalValidatorFactoryBean(); + validator.afterPropertiesSet(); + + mockMvc = MockMvcBuilders.standaloneSetup(new TestController()) + .setControllerAdvice(new GlobalExceptionHandler()) + .setMessageConverters(new JacksonJsonHttpMessageConverter()) + .setValidator(validator) + .build(); + } + + @Test + void handlesBusinessException() throws Exception { + mockMvc.perform(get("/test/business-error")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.status").value("TEST-001")) + .andExpect(jsonPath("$.data[0]").value("이미 처리되었습니다.")) + .andExpect(jsonPath("$.timestamp").isNotEmpty()); + } + + @Test + void handlesParameterTypeMismatch() throws Exception { + mockMvc.perform(get("/test/number").param("value", "not-a-number")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value("GLOBAL-001")) + .andExpect(jsonPath("$.data[0]").value("요청 파라미터의 형식이 올바르지 않습니다.")); + } + + @Test + void handlesMissingParameter() throws Exception { + mockMvc.perform(get("/test/number")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value("GLOBAL-002")) + .andExpect(jsonPath("$.data[0]").value("필수 요청 파라미터가 누락되었습니다.")); + } + + @Test + void handlesUnreadableRequestBody() throws Exception { + mockMvc.perform(post("/test/validation") + .contentType(MediaType.APPLICATION_JSON) + .content("{")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value("GLOBAL-003")) + .andExpect(jsonPath("$.data[0]").value("요청 본문이 누락되었거나 형식이 올바르지 않습니다.")); + } + + @Test + void handlesRequestBodyValidationFailure() throws Exception { + mockMvc.perform(post("/test/validation") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "name": "" + } + """)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value("GLOBAL-004")) + .andExpect(jsonPath("$.data[0]").value("이름은 필수입니다.")); + } + + @Test + void handlesUnsupportedMethod() throws Exception { + mockMvc.perform(post("/test/number").param("value", "1")) + .andExpect(status().isMethodNotAllowed()) + .andExpect(jsonPath("$.status").value("GLOBAL-005")) + .andExpect(jsonPath("$.data[0]").value("지원하지 않는 HTTP 메서드입니다.")); + } + + @Test + void hidesUnexpectedExceptionDetails() throws Exception { + mockMvc.perform(get("/test/unexpected-error")) + .andExpect(status().isInternalServerError()) + .andExpect(jsonPath("$.status").value("GLOBAL-999")) + .andExpect(jsonPath("$.data[0]").value("서버 내부 오류가 발생했습니다.")) + .andExpect(jsonPath("$.data[0]").value(org.hamcrest.Matchers.not( + org.hamcrest.Matchers.containsString("sensitive") + ))); + } + + @RestController + private static class TestController { + + @GetMapping("/test/business-error") + void businessError() { + throw new BusinessException(TestErrorCode.ALREADY_PROCESSED); + } + + @GetMapping("/test/number") + int number(@RequestParam int value) { + return value; + } + + @PostMapping("/test/validation") + void validate(@Valid @RequestBody TestRequest request) { + } + + @GetMapping("/test/unexpected-error") + void unexpectedError() { + throw new IllegalStateException("sensitive implementation detail"); + } + } + + private record TestRequest( + @NotBlank(message = "이름은 필수입니다.") + String name + ) { + } + + private enum TestErrorCode implements ErrorCode { + + ALREADY_PROCESSED; + + @Override + public HttpStatus getHttpStatus() { + return HttpStatus.CONFLICT; + } + + @Override + public String getCode() { + return "TEST-001"; + } + + @Override + public String getMessage() { + return "이미 처리되었습니다."; + } + } +} diff --git a/src/test/java/ject/official_qr_checkin_server/common/response/ApiResponseBodyAdviceTests.java b/src/test/java/ject/official_qr_checkin_server/common/response/ApiResponseBodyAdviceTests.java new file mode 100644 index 0000000..99062cf --- /dev/null +++ b/src/test/java/ject/official_qr_checkin_server/common/response/ApiResponseBodyAdviceTests.java @@ -0,0 +1,128 @@ +package ject.official_qr_checkin_server.common.response; + +import static org.hamcrest.Matchers.nullValue; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Map; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.ResponseEntity; +import org.springframework.http.converter.json.JacksonJsonHttpMessageConverter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +class ApiResponseBodyAdviceTests { + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(new TestController()) + .setControllerAdvice(new ApiResponseBodyAdvice()) + .setMessageConverters(new JacksonJsonHttpMessageConverter()) + .build(); + } + + @Test + void wrapsSuccessfulResponse() throws Exception { + mockMvc.perform(get("/test/success")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")) + .andExpect(jsonPath("$.data.id").value(1)) + .andExpect(jsonPath("$.data.name").value("JECT")) + .andExpect(jsonPath("$.timestamp").isNotEmpty()); + } + + @Test + void doesNotWrapApiResponseAgain() throws Exception { + mockMvc.perform(get("/test/wrapped")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")) + .andExpect(jsonPath("$.data").value("already wrapped")) + .andExpect(jsonPath("$.data.status").doesNotExist()); + } + + @Test + void wrapsNullData() throws Exception { + mockMvc.perform(get("/test/null")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("SUCCESS")) + .andExpect(jsonPath("$.data").value(nullValue())) + .andExpect(jsonPath("$.timestamp").isNotEmpty()); + } + + @Test + void doesNotWrapFailedResponse() throws Exception { + mockMvc.perform(get("/test/bad-request")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("invalid request")) + .andExpect(jsonPath("$.status").doesNotExist()); + } + + @Test + void preservesNoContentResponse() throws Exception { + mockMvc.perform(get("/test/no-content")) + .andExpect(status().isNoContent()) + .andExpect(content().string("")); + } + + @Test + void doesNotWrapActuatorResponse() throws Exception { + mockMvc.perform(get("/actuator/test")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("UP")) + .andExpect(jsonPath("$.data").doesNotExist()); + } + + @Test + void doesNotWrapApiDocumentationResponse() throws Exception { + mockMvc.perform(get("/v3/api-docs/test")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.openapi").value("3.1.0")) + .andExpect(jsonPath("$.data").doesNotExist()); + } + + @RestController + private static class TestController { + + @GetMapping("/test/success") + Map success() { + return Map.of("id", 1, "name", "JECT"); + } + + @GetMapping("/test/wrapped") + ApiResponse wrapped() { + return ApiResponse.success("already wrapped"); + } + + @GetMapping(value = "/test/null", produces = "application/json") + Object nullData() { + return null; + } + + @GetMapping("/test/bad-request") + ResponseEntity> badRequest() { + return ResponseEntity.badRequest().body(Map.of("message", "invalid request")); + } + + @GetMapping("/test/no-content") + ResponseEntity noContent() { + return ResponseEntity.noContent().build(); + } + + @GetMapping("/actuator/test") + Map actuator() { + return Map.of("status", "UP"); + } + + @GetMapping("/v3/api-docs/test") + Map apiDocs() { + return Map.of("openapi", "3.1.0"); + } + } +} diff --git a/src/test/java/ject/official_qr_checkin_server/common/springdoc/SpringdocIntegrationTests.java b/src/test/java/ject/official_qr_checkin_server/common/springdoc/SpringdocIntegrationTests.java new file mode 100644 index 0000000..32261e7 --- /dev/null +++ b/src/test/java/ject/official_qr_checkin_server/common/springdoc/SpringdocIntegrationTests.java @@ -0,0 +1,71 @@ +package ject.official_qr_checkin_server.common.springdoc; + +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.Map; +import ject.official_qr_checkin_server.common.exception.GlobalErrorCode; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Import; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@SpringBootTest +@AutoConfigureMockMvc +@Import(SpringdocIntegrationTests.TestController.class) +class SpringdocIntegrationTests { + + @Autowired + private MockMvc mockMvc; + + @Test + void exposesGroupedOpenApiDocumentWithCommonResponses() throws Exception { + mockMvc.perform(get("/v3/api-docs/check-in-api")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.info.title").value("젝트 행사 출석체크 API")) + .andExpect(jsonPath("$.paths['/swagger-test']").exists()) + .andExpect(jsonPath( + "$.paths['/swagger-test'].get.responses['200'].content['*/*'].schema.properties.status.example" + ).value("SUCCESS")) + .andExpect(jsonPath( + "$.paths['/swagger-test'].get.responses['404'].content['application/json'].examples.RESOURCE_NOT_FOUND.value.status" + ).value("GLOBAL-006")); + } + + @Test + void exposesSwaggerUi() throws Exception { + mockMvc.perform(get("/swagger-ui.html")) + .andExpect(status().is3xxRedirection()) + .andExpect(header().string("Location", containsString("/swagger-ui/index.html"))); + } + + @Test + void returnsCommonErrorResponseForUnknownResource() throws Exception { + mockMvc.perform(get("/unknown-resource")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value("GLOBAL-006")) + .andExpect(jsonPath("$.data[0]").value("요청한 리소스를 찾을 수 없습니다.")) + .andExpect(jsonPath("$.timestamp").isNotEmpty()); + } + + @RestController + static class TestController { + + @GetMapping("/swagger-test") + @ApiErrorResponse( + value = GlobalErrorCode.class, + name = "RESOURCE_NOT_FOUND", + description = "요청한 리소스가 없는 경우" + ) + Map swaggerTest() { + return Map.of("id", 1L); + } + } +}