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:
parent
7317f0816d
commit
50faedd2bc
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package com.baeldung.spring.reactive.customexception.exception;
|
||||
|
||||
public class UserNotFoundException extends RuntimeException {
|
||||
|
||||
public UserNotFoundException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue