Skip to content

Commit f801562

Browse files
committed
feat: Support ProblemDetail
Fixes #59
1 parent baf2508 commit f801562

File tree

10 files changed

+226
-19
lines changed

10 files changed

+226
-19
lines changed

src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/ErrorHandlingProperties.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ public class ErrorHandlingProperties {
4141

4242
private boolean handleFilterChainExceptions = false;
4343

44+
private boolean useProblemDetailFormat = false;
45+
46+
private String problemDetailTypePrefix = "";
47+
4448
public boolean isEnabled() {
4549
return enabled;
4650
}
@@ -153,6 +157,22 @@ public void setHandleFilterChainExceptions(boolean handleFilterChainExceptions)
153157
this.handleFilterChainExceptions = handleFilterChainExceptions;
154158
}
155159

160+
public boolean isUseProblemDetailFormat() {
161+
return useProblemDetailFormat;
162+
}
163+
164+
public void setUseProblemDetailFormat(boolean useProblemDetailFormat) {
165+
this.useProblemDetailFormat = useProblemDetailFormat;
166+
}
167+
168+
public String getProblemDetailTypePrefix() {
169+
return problemDetailTypePrefix;
170+
}
171+
172+
public void setProblemDetailTypePrefix(String problemDetailTypePrefix) {
173+
this.problemDetailTypePrefix = problemDetailTypePrefix;
174+
}
175+
156176
public enum ExceptionLogging {
157177
NO_LOGGING,
158178
MESSAGE_ONLY,
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package io.github.wimdeblauwe.errorhandlingspringbootstarter;
2+
3+
import org.slf4j.Logger;
4+
import org.slf4j.LoggerFactory;
5+
import org.springframework.http.ProblemDetail;
6+
import org.springframework.stereotype.Component;
7+
8+
import java.net.URI;
9+
import java.net.URISyntaxException;
10+
import java.util.HashMap;
11+
import java.util.List;
12+
import java.util.Objects;
13+
14+
@Component
15+
public class ProblemDetailFactory {
16+
private static final Logger LOGGER = LoggerFactory.getLogger(ProblemDetailFactory.class);
17+
18+
private final ErrorHandlingProperties properties;
19+
20+
public ProblemDetailFactory(ErrorHandlingProperties properties) {
21+
this.properties = properties;
22+
}
23+
24+
public ProblemDetail build(ApiErrorResponse errorResponse) {
25+
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(Objects.requireNonNull(errorResponse.getHttpStatus()), errorResponse.getMessage());
26+
problemDetail.setDetail(errorResponse.getMessage());
27+
try {
28+
problemDetail.setType(new URI(properties.getProblemDetailTypePrefix() + toKebabCase(errorResponse.getCode())));
29+
} catch (URISyntaxException ignored) {
30+
}
31+
32+
HashMap<String, Object> allProperties = new HashMap<>();
33+
allProperties.putAll(errorResponse.getProperties());
34+
List<ApiFieldError> fieldErrors = errorResponse.getFieldErrors();
35+
if (!fieldErrors.isEmpty()) {
36+
allProperties.put("fieldErrors", fieldErrors);
37+
}
38+
List<ApiGlobalError> globalErrors = errorResponse.getGlobalErrors();
39+
if (!globalErrors.isEmpty()) {
40+
allProperties.put("globalErrors", globalErrors);
41+
}
42+
problemDetail.setProperties(allProperties);
43+
44+
return problemDetail;
45+
}
46+
47+
private String toKebabCase(String input) {
48+
if (input.isEmpty()) {
49+
return input;
50+
}
51+
52+
String result = input
53+
.replaceAll("([a-z])([A-Z])", "$1-$2") // camelCase boundaries
54+
.replaceAll("([A-Z])([A-Z][a-z])", "$1-$2") // handle acronyms
55+
.replaceAll("\\s+", "-") // spaces to hyphens
56+
.replaceAll("_", "-") // underscores to hyphens
57+
.toLowerCase();
58+
LOGGER.info("{} -> {}", input, result);
59+
return result;
60+
}
61+
}

src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/reactive/GlobalErrorWebExceptionHandler.java

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiErrorResponse;
44
import io.github.wimdeblauwe.errorhandlingspringbootstarter.ErrorHandlingFacade;
5+
import io.github.wimdeblauwe.errorhandlingspringbootstarter.ErrorHandlingProperties;
6+
import io.github.wimdeblauwe.errorhandlingspringbootstarter.ProblemDetailFactory;
57
import org.slf4j.Logger;
68
import org.slf4j.LoggerFactory;
79
import org.springframework.boot.autoconfigure.web.ErrorProperties;
@@ -21,14 +23,21 @@ public class GlobalErrorWebExceptionHandler extends DefaultErrorWebExceptionHand
2123
private static final Logger LOGGER = LoggerFactory.getLogger(GlobalErrorWebExceptionHandler.class);
2224

2325
private final ErrorHandlingFacade errorHandlingFacade;
26+
private final ErrorHandlingProperties errorHandlingProperties;
27+
private final ProblemDetailFactory problemDetailFactory;
28+
2429

2530
public GlobalErrorWebExceptionHandler(ErrorAttributes errorAttributes,
2631
WebProperties.Resources resources,
2732
ErrorProperties errorProperties,
2833
ApplicationContext applicationContext,
29-
ErrorHandlingFacade errorHandlingFacade) {
34+
ErrorHandlingFacade errorHandlingFacade,
35+
ErrorHandlingProperties errorHandlingProperties,
36+
ProblemDetailFactory problemDetailFactory) {
3037
super(errorAttributes, resources, errorProperties, applicationContext);
3138
this.errorHandlingFacade = errorHandlingFacade;
39+
this.errorHandlingProperties = errorHandlingProperties;
40+
this.problemDetailFactory = problemDetailFactory;
3241
}
3342

3443
@Override
@@ -49,8 +58,14 @@ public Mono<ServerResponse> handleException(ServerRequest request) {
4958

5059
ApiErrorResponse errorResponse = errorHandlingFacade.handle(Objects.requireNonNull(exception));
5160

52-
return ServerResponse.status(Objects.requireNonNull(errorResponse.getHttpStatus()))
53-
.contentType(MediaType.APPLICATION_JSON)
54-
.body(BodyInserters.fromValue(errorResponse));
61+
if (errorHandlingProperties.isUseProblemDetailFormat()) {
62+
return ServerResponse.status(Objects.requireNonNull(errorResponse.getHttpStatus()))
63+
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
64+
.body(BodyInserters.fromValue(problemDetailFactory.build(errorResponse)));
65+
} else {
66+
return ServerResponse.status(Objects.requireNonNull(errorResponse.getHttpStatus()))
67+
.contentType(MediaType.APPLICATION_JSON)
68+
.body(BodyInserters.fromValue(errorResponse));
69+
}
5570
}
5671
}

src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/reactive/ReactiveErrorHandlingConfiguration.java

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,19 +65,29 @@ public GlobalErrorWebExceptionHandler globalErrorWebExceptionHandler(ErrorAttrib
6565
ObjectProvider<ViewResolver> viewResolvers,
6666
ServerCodecConfigurer serverCodecConfigurer,
6767
ApplicationContext applicationContext,
68-
ErrorHandlingFacade errorHandlingFacade) {
68+
ErrorHandlingFacade errorHandlingFacade,
69+
ErrorHandlingProperties errorHandlingProperties,
70+
ProblemDetailFactory problemDetailFactory) {
6971

7072
GlobalErrorWebExceptionHandler exceptionHandler = new GlobalErrorWebExceptionHandler(errorAttributes,
7173
webProperties.getResources(),
7274
webProperties.getError(),
7375
applicationContext,
74-
errorHandlingFacade);
76+
errorHandlingFacade,
77+
errorHandlingProperties,
78+
problemDetailFactory);
7579
exceptionHandler.setViewResolvers(viewResolvers.orderedStream().collect(Collectors.toList()));
7680
exceptionHandler.setMessageWriters(serverCodecConfigurer.getWriters());
7781
exceptionHandler.setMessageReaders(serverCodecConfigurer.getReaders());
7882
return exceptionHandler;
7983
}
8084

85+
@Bean
86+
@ConditionalOnMissingBean
87+
public ProblemDetailFactory responseEntityFactory(ErrorHandlingProperties errorHandlingProperties) {
88+
return new ProblemDetailFactory(errorHandlingProperties);
89+
}
90+
8191
@Bean
8292
@ConditionalOnMissingBean(value = ErrorAttributes.class, search = SearchStrategy.CURRENT)
8393
public DefaultErrorAttributes errorAttributes() {

src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/servlet/ErrorHandlingControllerAdvice.java

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
package io.github.wimdeblauwe.errorhandlingspringbootstarter.servlet;
22

3-
import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiErrorResponse;
4-
import io.github.wimdeblauwe.errorhandlingspringbootstarter.ErrorHandlingFacade;
3+
import io.github.wimdeblauwe.errorhandlingspringbootstarter.*;
54
import org.slf4j.Logger;
65
import org.slf4j.LoggerFactory;
76
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
87
import org.springframework.http.MediaType;
8+
import org.springframework.http.ProblemDetail;
99
import org.springframework.http.ResponseEntity;
1010
import org.springframework.web.bind.annotation.ControllerAdvice;
1111
import org.springframework.web.bind.annotation.ExceptionHandler;
@@ -21,9 +21,13 @@ public class ErrorHandlingControllerAdvice {
2121
private static final Logger LOGGER = LoggerFactory.getLogger(ErrorHandlingControllerAdvice.class);
2222

2323
private final ErrorHandlingFacade errorHandlingFacade;
24+
private final ErrorHandlingProperties errorHandlingProperties;
25+
private final ProblemDetailFactory problemDetailFactory;
2426

25-
public ErrorHandlingControllerAdvice(ErrorHandlingFacade errorHandlingFacade) {
27+
public ErrorHandlingControllerAdvice(ErrorHandlingFacade errorHandlingFacade, ErrorHandlingProperties errorHandlingProperties, ProblemDetailFactory problemDetailFactory) {
2628
this.errorHandlingFacade = errorHandlingFacade;
29+
this.errorHandlingProperties = errorHandlingProperties;
30+
this.problemDetailFactory = problemDetailFactory;
2731
}
2832

2933
@ExceptionHandler
@@ -33,8 +37,15 @@ public ResponseEntity<?> handleException(Throwable exception, WebRequest webRequ
3337

3438
ApiErrorResponse errorResponse = errorHandlingFacade.handle(exception);
3539

36-
return ResponseEntity.status(Objects.requireNonNull(errorResponse.getHttpStatus()))
37-
.contentType(MediaType.APPLICATION_JSON)
38-
.body(errorResponse);
40+
if (errorHandlingProperties.isUseProblemDetailFormat()) {
41+
ProblemDetail problemDetail = problemDetailFactory.build(errorResponse);
42+
return ResponseEntity.status(Objects.requireNonNull(errorResponse.getHttpStatus()))
43+
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
44+
.body(problemDetail);
45+
} else {
46+
return ResponseEntity.status(Objects.requireNonNull(errorResponse.getHttpStatus()))
47+
.contentType(MediaType.APPLICATION_JSON)
48+
.body(errorResponse);
49+
}
3950
}
4051
}

src/main/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/servlet/ServletErrorHandlingConfiguration.java

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,16 @@ public MissingRequestValueExceptionHandler missingRequestValueExceptionHandler(H
3939

4040
@Bean
4141
@ConditionalOnMissingBean
42-
public ErrorHandlingControllerAdvice errorHandlingControllerAdvice(ErrorHandlingFacade errorHandlingFacade) {
43-
return new ErrorHandlingControllerAdvice(errorHandlingFacade);
42+
public ErrorHandlingControllerAdvice errorHandlingControllerAdvice(ErrorHandlingFacade errorHandlingFacade,
43+
ErrorHandlingProperties errorHandlingProperties,
44+
ProblemDetailFactory problemDetailFactory) {
45+
return new ErrorHandlingControllerAdvice(errorHandlingFacade, errorHandlingProperties, problemDetailFactory);
46+
}
47+
48+
@Bean
49+
@ConditionalOnMissingBean
50+
public ProblemDetailFactory responseEntityFactory(ErrorHandlingProperties errorHandlingProperties) {
51+
return new ProblemDetailFactory(errorHandlingProperties);
4452
}
4553

4654
@Bean

src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/ConstraintViolationApiExceptionHandlerTest.java

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,7 @@
3535
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
3636
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
3737
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
38-
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
39-
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
38+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
4039

4140
@WebMvcTest
4241
@ContextConfiguration(classes = {
@@ -362,6 +361,30 @@ void testDisableAddingPath(@Autowired ErrorHandlingProperties properties) throws
362361
;
363362
}
364363

364+
@Test
365+
@WithMockUser
366+
void testConstraintViolationExceptionUsingProblemDetailFormat(@Autowired ErrorHandlingProperties properties) throws Exception {
367+
properties.setUseProblemDetailFormat(true);
368+
mockMvc.perform(post("/test/validation")
369+
.contentType(MediaType.APPLICATION_JSON)
370+
.content("{\"value2\": \"\"}")
371+
.with(csrf()))
372+
.andExpect(status().isBadRequest())
373+
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON))
374+
.andExpect(jsonPath("title").value("Bad Request"))
375+
.andExpect(jsonPath("type").value("validation-failed"))
376+
.andExpect(jsonPath("detail").value("Validation failed. Error count: 3"))
377+
.andExpect(jsonPath("fieldErrors", hasSize(2)))
378+
.andExpect(jsonPath("fieldErrors..code", allOf(hasItem("REQUIRED_NOT_NULL"), hasItem("INVALID_SIZE"))))
379+
.andExpect(jsonPath("fieldErrors..property", allOf(hasItem("value"), hasItem("value2"))))
380+
.andExpect(jsonPath("fieldErrors..message", allOf(hasItem("must not be null"), hasItem("size must be between 1 and 255"))))
381+
.andExpect(jsonPath("fieldErrors..rejectedValue", allOf(hasItem(Matchers.nullValue()), hasItem(""))))
382+
.andExpect(jsonPath("globalErrors", hasSize(1)))
383+
.andExpect(jsonPath("globalErrors..code", allOf(hasItem("ValuesEqual"))))
384+
.andExpect(jsonPath("globalErrors..message", allOf(hasItem("Values not equal"))))
385+
;
386+
}
387+
365388
@RestController
366389
@RequestMapping
367390
public static class TestController {

src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/CustomApiExceptionHandlerDocumentation.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package io.github.wimdeblauwe.errorhandlingspringbootstarter.handler;
22

3-
import io.github.wimdeblauwe.errorhandlingspringbootstarter.servlet.ServletErrorHandlingConfiguration;
43
import org.junit.jupiter.api.Test;
54
import org.springframework.beans.factory.annotation.Autowired;
65
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;

src/test/java/io/github/wimdeblauwe/errorhandlingspringbootstarter/handler/ObjectOptimisticLockingFailureApiExceptionHandlerTest.java

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,27 @@
11
package io.github.wimdeblauwe.errorhandlingspringbootstarter.handler;
22

33

4+
import io.github.wimdeblauwe.errorhandlingspringbootstarter.ErrorHandlingProperties;
45
import org.junit.jupiter.api.Test;
56
import org.springframework.beans.factory.annotation.Autowired;
67
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
8+
import org.springframework.http.MediaType;
79
import org.springframework.orm.ObjectOptimisticLockingFailureException;
810
import org.springframework.security.test.context.support.WithMockUser;
11+
import org.springframework.test.annotation.DirtiesContext;
912
import org.springframework.test.context.ContextConfiguration;
1013
import org.springframework.test.web.servlet.MockMvc;
1114
import org.springframework.web.bind.annotation.GetMapping;
1215
import org.springframework.web.bind.annotation.RequestMapping;
1316
import org.springframework.web.bind.annotation.RestController;
1417

1518
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
16-
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
17-
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
19+
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
1820

1921
@WebMvcTest
2022
@ContextConfiguration(classes = {
2123
ObjectOptimisticLockingFailureApiExceptionHandlerTest.TestController.class})
24+
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
2225
class ObjectOptimisticLockingFailureApiExceptionHandlerTest {
2326

2427
@Autowired
@@ -36,6 +39,37 @@ void testOOLF() throws Exception {
3639
;
3740
}
3841

42+
@Test
43+
@WithMockUser
44+
void testOOLFWithProblemDetailFormat(@Autowired ErrorHandlingProperties properties) throws Exception {
45+
properties.setUseProblemDetailFormat(true);
46+
mockMvc.perform(get("/test/object-optimistic-locking-failure"))
47+
.andExpect(status().isConflict())
48+
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON))
49+
.andExpect(jsonPath("title").value("Conflict"))
50+
.andExpect(jsonPath("type").value("optimistic-locking-error"))
51+
.andExpect(jsonPath("detail").value("Object of class [com.example.user.User] with identifier [1]: optimistic locking failed"))
52+
.andExpect(jsonPath("persistentClassName").value("com.example.user.User"))
53+
.andExpect(jsonPath("identifier").value("1"))
54+
;
55+
}
56+
57+
@Test
58+
@WithMockUser
59+
void testOOLFWithProblemDetailFormatAndTypePrefix(@Autowired ErrorHandlingProperties properties) throws Exception {
60+
properties.setUseProblemDetailFormat(true);
61+
properties.setProblemDetailTypePrefix("https://example.org/");
62+
mockMvc.perform(get("/test/object-optimistic-locking-failure"))
63+
.andExpect(status().isConflict())
64+
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON))
65+
.andExpect(jsonPath("title").value("Conflict"))
66+
.andExpect(jsonPath("type").value("https://example.org/optimistic-locking-error"))
67+
.andExpect(jsonPath("detail").value("Object of class [com.example.user.User] with identifier [1]: optimistic locking failed"))
68+
.andExpect(jsonPath("persistentClassName").value("com.example.user.User"))
69+
.andExpect(jsonPath("identifier").value("1"))
70+
;
71+
}
72+
3973
@RestController
4074
@RequestMapping("/test/object-optimistic-locking-failure")
4175
public static class TestController {

0 commit comments

Comments
 (0)