BAEL-6086: Customize WebFlux Exceptions in Spring Boot 3 (#13948)

* BAEL-6086: Added code for webflux default and custom exception along with Unit Tests

* BAEL-6086: added missing import

* BAEL-6086: reformatting

* BAEL-6086: reformatted code

---------

Co-authored-by: balasr3 <balamurugan.radhakrishnan@imgarena.com>
This commit is contained in:
Balamurugan 2023-05-20 04:48:40 +01:00 committed by GitHub
parent 7317f0816d
commit 50faedd2bc
13 changed files with 360 additions and 2 deletions

View File

@ -20,7 +20,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
@ -35,6 +38,11 @@
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
@ -42,8 +50,12 @@
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<mainClass>com.baeldung.spring.reactive.customexception.CustomExceptionApplication</mainClass>
<layout>JAR</layout>
</configuration>
</plugin>
</plugins>
</build>
</project>
</project>

View File

@ -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);
}
}

View File

@ -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<ErrorDetails> {
@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();
}
}

View File

@ -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<ProblemDetail> {
@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();
}
}

View File

@ -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<Long, User> userMap = new HashMap<>();
@GetMapping("/v1/users/{userId}")
public Mono<ResponseEntity<User>> 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<ResponseEntity<User>> 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);
});
}
}

View File

@ -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;
}
}

View File

@ -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<CustomErrorResponse> handleCustomError(RuntimeException ex) {
CustomErrorException customErrorException = (CustomErrorException) ex;
return ResponseEntity.status(customErrorException.getErrorResponse().getStatus())
.body(customErrorException.getErrorResponse());
}
}

View File

@ -0,0 +1,9 @@
package com.baeldung.spring.reactive.customexception.exception;
public class UserNotFoundException extends RuntimeException {
public UserNotFoundException(String message) {
super(message);
}
}

View File

@ -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<ErrorDetails> errors;
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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<LinkedHashMap> errors = (List<LinkedHashMap>) 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());
}
}