Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions spring-reactive-modules/spring-reactive-exceptions/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -35,15 +38,24 @@
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<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>
Original file line number Diff line number Diff line change
@@ -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);
}

}
Original file line number Diff line number Diff line change
@@ -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();
}
}

Original file line number Diff line number Diff line change
@@ -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();
}
}

Original file line number Diff line number Diff line change
@@ -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);
});
}

}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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());
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.baeldung.spring.reactive.customexception.exception;

public class UserNotFoundException extends RuntimeException {

public UserNotFoundException(String message) {
super(message);
}

}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;

}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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());
}

}