diff --git a/spring-reactive-modules/spring-reactive-exceptions/pom.xml b/spring-reactive-modules/spring-reactive-exceptions/pom.xml index fc08e07659..940ae90de3 100644 --- a/spring-reactive-modules/spring-reactive-exceptions/pom.xml +++ b/spring-reactive-modules/spring-reactive-exceptions/pom.xml @@ -20,7 +20,10 @@ org.springframework.boot spring-boot-starter-webflux - + + org.springframework.boot + spring-boot-starter-web + org.projectlombok lombok @@ -35,6 +38,11 @@ reactor-test test + + org.springframework + spring-test + test + @@ -42,8 +50,12 @@ org.springframework.boot spring-boot-maven-plugin + + com.baeldung.spring.reactive.customexception.CustomExceptionApplication + JAR + - \ No newline at end of file + diff --git a/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/CustomExceptionApplication.java b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/CustomExceptionApplication.java new file mode 100644 index 0000000000..336347a50b --- /dev/null +++ b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/CustomExceptionApplication.java @@ -0,0 +1,13 @@ +package com.baeldung.spring.reactive.customexception; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class CustomExceptionApplication { + + public static void main(String[] args) { + SpringApplication.run(CustomExceptionApplication.class, args); + } + +} diff --git a/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/config/ErrorDetailsSerializer.java b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/config/ErrorDetailsSerializer.java new file mode 100644 index 0000000000..232226bd2c --- /dev/null +++ b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/config/ErrorDetailsSerializer.java @@ -0,0 +1,21 @@ +package com.baeldung.spring.reactive.customexception.config; + +import java.io.IOException; + +import com.baeldung.spring.reactive.customexception.model.ErrorDetails; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; + +public class ErrorDetailsSerializer extends JsonSerializer { + @Override + public void serialize(ErrorDetails value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + gen.writeStartObject(); + gen.writeStringField("code", value.getErrorCode() + .toString()); + gen.writeStringField("message", value.getErrorMessage()); + gen.writeStringField("reference", value.getReferenceUrl()); + gen.writeEndObject(); + } +} + diff --git a/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/config/ProblemDetailsSerializer.java b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/config/ProblemDetailsSerializer.java new file mode 100644 index 0000000000..2947058fac --- /dev/null +++ b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/config/ProblemDetailsSerializer.java @@ -0,0 +1,25 @@ +package com.baeldung.spring.reactive.customexception.config; + +import java.io.IOException; + +import org.springframework.http.ProblemDetail; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; + +public class ProblemDetailsSerializer extends JsonSerializer { + + @Override + public void serialize(ProblemDetail value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + gen.writeStartObject(); + gen.writeObjectField("type", value.getType()); + gen.writeObjectField("title", value.getTitle()); + gen.writeObjectField("status", value.getStatus()); + gen.writeObjectField("detail", value.getDetail()); + gen.writeObjectField("instance", value.getInstance()); + gen.writeObjectField("errors", value.getProperties().get("errors")); + gen.writeEndObject(); + } +} + diff --git a/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/controller/UserController.java b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/controller/UserController.java new file mode 100644 index 0000000000..c71180f750 --- /dev/null +++ b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/controller/UserController.java @@ -0,0 +1,57 @@ +package com.baeldung.spring.reactive.customexception.controller; + +import java.time.OffsetDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +import com.baeldung.spring.reactive.customexception.exception.CustomErrorException; +import com.baeldung.spring.reactive.customexception.exception.UserNotFoundException; +import com.baeldung.spring.reactive.customexception.model.CustomErrorResponse; +import com.baeldung.spring.reactive.customexception.model.ErrorDetails; +import com.baeldung.spring.reactive.customexception.model.User; + +import reactor.core.publisher.Mono; + +@RestController +public class UserController { + + private Map userMap = new HashMap<>(); + + @GetMapping("/v1/users/{userId}") + public Mono> getV1UserById(@PathVariable Long userId) { + return Mono.fromCallable(() -> { + User user = userMap.get(userId); + if (user == null) { + throw new UserNotFoundException("User not found with ID: " + userId); + } + return new ResponseEntity<>(user, HttpStatus.OK); + }); + } + + @GetMapping("/v2/users/{userId}") + public Mono> getV2UserById(@PathVariable Long userId) { + return Mono.fromCallable(() -> { + User user = userMap.get(userId); + if (user == null) { + CustomErrorResponse customErrorResponse = CustomErrorResponse + .builder() + .traceId(UUID.randomUUID().toString()) + .timestamp(OffsetDateTime.now().now()) + .status(HttpStatus.NOT_FOUND) + .errors(List.of(ErrorDetails.API_USER_NOT_FOUND)) + .build(); + throw new CustomErrorException("User not found", customErrorResponse); + } + return new ResponseEntity<>(user, HttpStatus.OK); + }); + } + +} diff --git a/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/exception/CustomErrorException.java b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/exception/CustomErrorException.java new file mode 100644 index 0000000000..3ec4f6099e --- /dev/null +++ b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/exception/CustomErrorException.java @@ -0,0 +1,16 @@ +package com.baeldung.spring.reactive.customexception.exception; + +import com.baeldung.spring.reactive.customexception.model.CustomErrorResponse; + +import lombok.Getter; + + +public class CustomErrorException extends RuntimeException { + @Getter + private CustomErrorResponse errorResponse; + + public CustomErrorException(String message, CustomErrorResponse errorResponse) { + super(message); + this.errorResponse = errorResponse; + } +} diff --git a/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/exception/GlobalExceptionHandler.java b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000000..33d6655e79 --- /dev/null +++ b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/exception/GlobalExceptionHandler.java @@ -0,0 +1,34 @@ +package com.baeldung.spring.reactive.customexception.exception; + +import java.net.URI; +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.reactive.result.method.annotation.ResponseEntityExceptionHandler; + +import com.baeldung.spring.reactive.customexception.model.CustomErrorResponse; +import com.baeldung.spring.reactive.customexception.model.ErrorDetails; + +@RestControllerAdvice +public class GlobalExceptionHandler extends ResponseEntityExceptionHandler { + @ExceptionHandler(UserNotFoundException.class) + protected ProblemDetail handleNotFound(RuntimeException ex) { + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); + problemDetail.setTitle("User not found"); + problemDetail.setType(URI.create("https://example.com/problems/user-not-found")); + problemDetail.setProperty("errors", List.of(ErrorDetails.API_USER_NOT_FOUND)); + return problemDetail; + } + + @ExceptionHandler(CustomErrorException.class) + protected ResponseEntity handleCustomError(RuntimeException ex) { + CustomErrorException customErrorException = (CustomErrorException) ex; + return ResponseEntity.status(customErrorException.getErrorResponse().getStatus()) + .body(customErrorException.getErrorResponse()); + } + +} diff --git a/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/exception/UserNotFoundException.java b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/exception/UserNotFoundException.java new file mode 100644 index 0000000000..7306491bad --- /dev/null +++ b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/exception/UserNotFoundException.java @@ -0,0 +1,9 @@ +package com.baeldung.spring.reactive.customexception.exception; + +public class UserNotFoundException extends RuntimeException { + + public UserNotFoundException(String message) { + super(message); + } + +} diff --git a/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/model/CustomErrorResponse.java b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/model/CustomErrorResponse.java new file mode 100644 index 0000000000..2159725a2d --- /dev/null +++ b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/model/CustomErrorResponse.java @@ -0,0 +1,22 @@ +package com.baeldung.spring.reactive.customexception.model; + +import java.time.OffsetDateTime; +import java.util.List; + +import org.springframework.http.HttpStatus; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CustomErrorResponse { + private String traceId; + private OffsetDateTime timestamp; + private HttpStatus status; + private List errors; +} diff --git a/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/model/Error.java b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/model/Error.java new file mode 100644 index 0000000000..2c3f5c4024 --- /dev/null +++ b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/model/Error.java @@ -0,0 +1,20 @@ +package com.baeldung.spring.reactive.customexception.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class Error { + + private int code; + + private String message; + + protected String reference; + +} diff --git a/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/model/ErrorDetails.java b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/model/ErrorDetails.java new file mode 100644 index 0000000000..a4ca566c71 --- /dev/null +++ b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/model/ErrorDetails.java @@ -0,0 +1,25 @@ +package com.baeldung.spring.reactive.customexception.model; + + +import com.baeldung.spring.reactive.customexception.config.ErrorDetailsSerializer; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +import lombok.Getter; + +@JsonSerialize(using = ErrorDetailsSerializer.class) +public enum ErrorDetails { + + API_USER_NOT_FOUND(123, "User not found", "http://example.com/123"); + @Getter + private Integer errorCode; + @Getter + private String errorMessage; + @Getter + private String referenceUrl; + + ErrorDetails(Integer errorCode, String errorMessage, String referenceUrl) { + this.errorCode = errorCode; + this.errorMessage = errorMessage; + this.referenceUrl = referenceUrl; + } +} diff --git a/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/model/User.java b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/model/User.java new file mode 100644 index 0000000000..218b1ddad5 --- /dev/null +++ b/spring-reactive-modules/spring-reactive-exceptions/src/main/java/com/baeldung/spring/reactive/customexception/model/User.java @@ -0,0 +1,20 @@ +package com.baeldung.spring.reactive.customexception.model; + +import java.time.LocalDate; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class User { + private Long id; + private String firstName; + private String lastName; + private String email; + private LocalDate dateOfBirth; +} diff --git a/spring-reactive-modules/spring-reactive-exceptions/src/test/java/com/baeldung/spring/reactive/customexception/controller/UserControllerUnitTest.java b/spring-reactive-modules/spring-reactive-exceptions/src/test/java/com/baeldung/spring/reactive/customexception/controller/UserControllerUnitTest.java new file mode 100644 index 0000000000..150d0b08a6 --- /dev/null +++ b/spring-reactive-modules/spring-reactive-exceptions/src/test/java/com/baeldung/spring/reactive/customexception/controller/UserControllerUnitTest.java @@ -0,0 +1,84 @@ +package com.baeldung.spring.reactive.customexception.controller; + +import static org.junit.Assert.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.regex.Pattern; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.ProblemDetail; +import org.springframework.test.web.reactive.server.WebTestClient; + +import com.baeldung.spring.reactive.customexception.exception.GlobalExceptionHandler; +import com.baeldung.spring.reactive.customexception.model.ErrorDetails; + +@SpringBootTest(webEnvironment = RANDOM_PORT) +class UserControllerUnitTest { + + @Autowired + private WebTestClient webClient; + + @InjectMocks + private UserController userController; + + @Autowired + private GlobalExceptionHandler globalExceptionHandler; + + @BeforeEach + void setupTests() { + // Given + webClient = WebTestClient.bindToController(userController) + .controllerAdvice(globalExceptionHandler) + .configureClient() + .build(); + } + + @Test + void givenGetUserV1Endpoint_whenHitWithInvalidUser_thenResponseShouldContainProblemDetailWithCustomAttributes() throws Exception { + // When + ProblemDetail problemDetail = webClient.get() + .uri("/v1/users/123") + .exchange() + .expectStatus() + .isNotFound() + .expectBody(ProblemDetail.class) + .returnResult() + .getResponseBody(); + // Then + assertNotNull(problemDetail); + assertNotNull(problemDetail.getProperties().get("errors")); + List errors = (List) problemDetail.getProperties().get("errors"); + assertEquals(ErrorDetails.API_USER_NOT_FOUND.getErrorCode().toString(), + errors.get(0).get("code")); + assertEquals(ErrorDetails.API_USER_NOT_FOUND.getErrorMessage().toString(), + errors.get(0).get("message")); + assertEquals(ErrorDetails.API_USER_NOT_FOUND.getReferenceUrl().toString(), + errors.get(0).get("reference")); + } + + @Test + void givenGetUserV2Endpoint_whenHitWithInvalidUser_thenResponseShouldContainCustomException() throws Exception { + // When + String regex = "^\\{\\s*\"traceId\":\\s*\"[^\"]*\",\\s*\"timestamp\":\\d+\\.\\d+,\\s*\"status\":\\s*\"[^\"]*\",\\s*\"errors\":\\s*\\[\\s*\\{\\s*\"code\":\\s*\"[^\"]*\",\\s*\"message\":\\s*\"[^\"]*\",\\s*\"reference\":\\s*\"[^\"]*\"\\s*\\}\\s*\\]\\s*\\}$"; + Pattern errorResponseSampleFormat = Pattern.compile(regex); + String errorResponse = webClient.get() + .uri("/v2/users/123") + .exchange() + .expectStatus() + .isNotFound() + .expectBody(String.class) + .returnResult() + .getResponseBody(); + // Then + assertTrue(errorResponseSampleFormat.matcher(errorResponse).matches()); + } + +}