diff --git a/build.gradle b/build.gradle index 8ba4755..855fde4 100644 --- a/build.gradle +++ b/build.gradle @@ -9,7 +9,7 @@ version = '0.0.1-SNAPSHOT' java { toolchain { - languageVersion = JavaLanguageVersion.of(21) + languageVersion = JavaLanguageVersion.of(17) } } @@ -24,11 +24,12 @@ repositories { } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' +// implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'jakarta.validation:jakarta.validation-api:3.0.2' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' - runtimeOnly 'com.mysql:mysql-connector-j' +// runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.assertj:assertj-core' diff --git a/src/main/java/com/example/phrasebe/common/exception/ExceptionAdvice.java b/src/main/java/com/example/phrasebe/common/exception/ExceptionAdvice.java new file mode 100644 index 0000000..ca32f9c --- /dev/null +++ b/src/main/java/com/example/phrasebe/common/exception/ExceptionAdvice.java @@ -0,0 +1,65 @@ +package com.example.phrasebe.common.exception; + +import com.example.phrasebe.common.response.ApiResponse; +import com.example.phrasebe.common.status.ErrorStatus; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +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.servlet.resource.NoResourceFoundException; + +@Slf4j +@RestControllerAdvice +public class ExceptionAdvice { + @ExceptionHandler + public ResponseEntity validation(ConstraintViolationException e) { + String errorMessage = e.getConstraintViolations().stream() + .map(ConstraintViolation::getMessage) + .findFirst() + .orElseThrow(() -> new RuntimeException("ConstraintViolationException 추출 도중 에러 발생")); + return ApiResponse.onFailure(ErrorStatus.VALIDATION_ERROR, errorMessage); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException e) { + e.printStackTrace(); + + String errorMessage = e.getBindingResult().getFieldError().getDefaultMessage(); + return ApiResponse.onFailure(ErrorStatus.VALIDATION_ERROR, errorMessage); + } + + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity handleMissingServletRequestParameterException( + MissingServletRequestParameterException e + ) { + e.printStackTrace(); + + String errorMessage = e.getParameterType() + " 타입의 " + e.getParameterName() + " 파라미터가 없습니다."; + return ApiResponse.onFailure(ErrorStatus.VALIDATION_ERROR, errorMessage); + } + + @ExceptionHandler(NoResourceFoundException.class) + public ResponseEntity handleNoResourceFoundException(NoResourceFoundException e) { + e.printStackTrace(); + + return ApiResponse.onFailure(ErrorStatus._NOT_FOUND); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception e) { + e.printStackTrace(); + + return ApiResponse.onFailure(ErrorStatus._INTERNAL_SERVER_ERROR); + } + + @ExceptionHandler(GeneralException.class) + public ResponseEntity handleGeneralException(GeneralException e) { + e.printStackTrace(); + + return ApiResponse.onFailure(e.getErrorStatus(), e.getMessage()); + } +} diff --git a/src/main/java/com/example/phrasebe/common/exception/GeneralException.java b/src/main/java/com/example/phrasebe/common/exception/GeneralException.java new file mode 100644 index 0000000..12510a9 --- /dev/null +++ b/src/main/java/com/example/phrasebe/common/exception/GeneralException.java @@ -0,0 +1,32 @@ +package com.example.phrasebe.common.exception; + +import com.example.phrasebe.common.status.ErrorStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class GeneralException extends RuntimeException{ + + private ErrorStatus errorStatus; + + public GeneralException() { + super(ErrorStatus._INTERNAL_SERVER_ERROR.getMessage()); + this.errorStatus = ErrorStatus._INTERNAL_SERVER_ERROR; + } + + public GeneralException(String message) { + super(message); + this.errorStatus = ErrorStatus._INTERNAL_SERVER_ERROR; + } + + public GeneralException(String message, Throwable cause) { + super(message, cause); + this.errorStatus = ErrorStatus._INTERNAL_SERVER_ERROR; + } + + public GeneralException(Throwable cause) { + super(cause.getMessage(), cause); + this.errorStatus = ErrorStatus._INTERNAL_SERVER_ERROR; + } +} diff --git a/src/main/java/com/example/phrasebe/common/response/ApiResponse.java b/src/main/java/com/example/phrasebe/common/response/ApiResponse.java new file mode 100644 index 0000000..938ef49 --- /dev/null +++ b/src/main/java/com/example/phrasebe/common/response/ApiResponse.java @@ -0,0 +1,73 @@ +package com.example.phrasebe.common.response; + +import com.example.phrasebe.common.status.ErrorStatus; +import com.example.phrasebe.common.status.SuccessStatus; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +//import org.springframework.data.domain.Page; +//import org.springframework.data.domain.Slice; +import org.springframework.http.ResponseEntity; + +@Getter +@RequiredArgsConstructor +@JsonPropertyOrder({"isSuccess", "code", "message", "result"}) +public class ApiResponse { + + private final Boolean isSuccess; + private final String code; + private final String message; + + @JsonInclude(Include.NON_NULL) + private final PageInfo pageInfo; + @JsonInclude(Include.NON_NULL) + private final Object result; + + + // 성공한 경우 응답 생성 + public static ResponseEntity onSuccess(SuccessStatus status, PageInfo pageInfo, Object result) { + return new ResponseEntity<>( + new ApiResponse(true, status.getCode(), status.getMessage(), pageInfo, result), + status.getHttpStatus() + ); + } + + // 성공 - 기본 응답 + public static ResponseEntity onSuccess(SuccessStatus status) { + return onSuccess(status, null, null); + } + + // 성공 - 데이터가 포함된 응답 + public static ResponseEntity onSuccess(SuccessStatus status, Object result) { + return onSuccess(status, null, result); + } + + // 성공 - 페이지네이션에 대한 응답 +// public static ResponseEntity onSuccess(SuccessStatus status, Page page) { +// PageInfo pageInfo = new PageInfo(page.getNumber(), page.getSize(), page.hasNext()); +// return onSuccess(status, pageInfo, page.getContent()); +// } +// +// public static ResponseEntity onSuccess(SuccessStatus status, Slice page) { +// PageInfo pageInfo = new PageInfo(page.getNumber(), page.getSize(), page.hasNext()); +// return onSuccess(status, pageInfo, page.getContent()); +// } + + + // 실패한 경우 응답 생성 + public static ResponseEntity onFailure(ErrorStatus error) { + return new ResponseEntity<>( + new ApiResponse(false, error.getCode(), error.getMessage(), null, null), + error.getHttpStatus() + ); + } + + public static ResponseEntity onFailure(ErrorStatus error, String message) { + return new ResponseEntity<>( + new ApiResponse(false, error.getCode(), error.getMessage(message), null, null), + error.getHttpStatus() + ); + } +} diff --git a/src/main/java/com/example/phrasebe/common/response/PageInfo.java b/src/main/java/com/example/phrasebe/common/response/PageInfo.java new file mode 100644 index 0000000..50b6205 --- /dev/null +++ b/src/main/java/com/example/phrasebe/common/response/PageInfo.java @@ -0,0 +1,12 @@ +package com.example.phrasebe.common.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class PageInfo { + private Integer page; + private Integer size; + private Boolean hasNext; +} diff --git a/src/main/java/com/example/phrasebe/common/status/ErrorStatus.java b/src/main/java/com/example/phrasebe/common/status/ErrorStatus.java new file mode 100644 index 0000000..d3c36b6 --- /dev/null +++ b/src/main/java/com/example/phrasebe/common/status/ErrorStatus.java @@ -0,0 +1,38 @@ +package com.example.phrasebe.common.status; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +import java.util.Optional; +import java.util.function.Predicate; + +@Getter +@AllArgsConstructor +public enum ErrorStatus { + + // 가장 일반적인 응답 + _INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500", "서버 에러, 관리자에게 문의 바랍니다."), + _BAD_REQUEST(HttpStatus.BAD_REQUEST,"COMMON400","잘못된 요청입니다."), + _UNAUTHORIZED(HttpStatus.UNAUTHORIZED,"COMMON401","인증이 필요합니다."), + _FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "금지된 요청입니다."), + _NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON404", "페이지를 찾을 수 없습니다."), + + // 입력값 검증 관련 에러 + VALIDATION_ERROR(HttpStatus.BAD_REQUEST, "VALID401", "입력값이 올바르지 않습니다."), + + + // 멤버 관려 에러 + MEMBER_NOT_FOUND(HttpStatus.BAD_REQUEST, "MEMBER4001", "사용자가 없습니다."), + NICKNAME_NOT_EXIST(HttpStatus.BAD_REQUEST, "MEMBER4002", "닉네임은 필수 입니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + public String getMessage(String message) { + return Optional.ofNullable(message) + .filter(Predicate.not(String::isBlank)) + .orElse(this.getMessage()); + } +} diff --git a/src/main/java/com/example/phrasebe/common/status/SuccessStatus.java b/src/main/java/com/example/phrasebe/common/status/SuccessStatus.java new file mode 100644 index 0000000..d92666f --- /dev/null +++ b/src/main/java/com/example/phrasebe/common/status/SuccessStatus.java @@ -0,0 +1,17 @@ +package com.example.phrasebe.common.status; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum SuccessStatus { + + // 일반적인 응답 + _OK(HttpStatus.OK, "COMMON200", "성공입니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/src/main/java/com/example/phrasebe/test/TestController.java b/src/main/java/com/example/phrasebe/test/TestController.java new file mode 100644 index 0000000..dd999e9 --- /dev/null +++ b/src/main/java/com/example/phrasebe/test/TestController.java @@ -0,0 +1,26 @@ +package com.example.phrasebe.test; + +import com.example.phrasebe.common.response.ApiResponse; +import com.example.phrasebe.common.status.SuccessStatus; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class TestController { + @Autowired + private TestService testService; + + @GetMapping("/data") + public ResponseEntity getDataTest() { + String result = testService.testData(); + return ApiResponse.onSuccess(SuccessStatus._OK, result); + } + + @GetMapping("/error") + public ResponseEntity errorTest() { + testService.testError(); + return ApiResponse.onSuccess(SuccessStatus._OK); + } +} diff --git a/src/main/java/com/example/phrasebe/test/TestService.java b/src/main/java/com/example/phrasebe/test/TestService.java new file mode 100644 index 0000000..14bd596 --- /dev/null +++ b/src/main/java/com/example/phrasebe/test/TestService.java @@ -0,0 +1,16 @@ +package com.example.phrasebe.test; + +import com.example.phrasebe.common.exception.GeneralException; +import com.example.phrasebe.common.status.ErrorStatus; +import org.springframework.stereotype.Service; + +@Service +public class TestService { + public String testData() { + return "테스트 성공!"; + } + + public void testError() { + throw new GeneralException(ErrorStatus.MEMBER_NOT_FOUND); + } +} diff --git a/src/test/java/com/example/phrasebe/test/TestControllerTest.java b/src/test/java/com/example/phrasebe/test/TestControllerTest.java new file mode 100644 index 0000000..1c94832 --- /dev/null +++ b/src/test/java/com/example/phrasebe/test/TestControllerTest.java @@ -0,0 +1,47 @@ +package com.example.phrasebe.test; + +import com.example.phrasebe.common.status.ErrorStatus; +import com.example.phrasebe.common.status.SuccessStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@AutoConfigureMockMvc +class TestControllerTest { + @Autowired + private MockMvc mockMvc; + + @Test + @DisplayName("getData(): 데이터가 포함된 ApiResponse 객체가 Http Response body로 설정된다.") + void getDataTest() throws Exception { + String expectResult = "테스트 성공!"; + + mockMvc.perform(MockMvcRequestBuilders.get("/data") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value(SuccessStatus._OK.getCode())) + .andExpect(jsonPath("$.message").value(SuccessStatus._OK.getMessage())) + .andExpect(jsonPath("$.result").value(expectResult)); + } + + @Test + @DisplayName("errorTest(): GeneralException 발생 시 에러 응답이 반환된다.") + void errorTest() throws Exception { + mockMvc.perform(MockMvcRequestBuilders.get("/error") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().is4xxClientError()) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value(ErrorStatus.MEMBER_NOT_FOUND.getCode())) + .andExpect(jsonPath("$.message").value(ErrorStatus.MEMBER_NOT_FOUND.getMessage())); + } +} \ No newline at end of file