Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ public class ErrorHandlingProperties {

private boolean handleFilterChainExceptions = false;

private boolean useProblemDetailFormat = false;

private String problemDetailTypePrefix = "";

public boolean isEnabled() {
return enabled;
}
Expand Down Expand Up @@ -153,6 +157,22 @@ public void setHandleFilterChainExceptions(boolean handleFilterChainExceptions)
this.handleFilterChainExceptions = handleFilterChainExceptions;
}

public boolean isUseProblemDetailFormat() {
return useProblemDetailFormat;
}

public void setUseProblemDetailFormat(boolean useProblemDetailFormat) {
this.useProblemDetailFormat = useProblemDetailFormat;
}

public String getProblemDetailTypePrefix() {
return problemDetailTypePrefix;
}

public void setProblemDetailTypePrefix(String problemDetailTypePrefix) {
this.problemDetailTypePrefix = problemDetailTypePrefix;
}

public enum ExceptionLogging {
NO_LOGGING,
MESSAGE_ONLY,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package io.github.wimdeblauwe.errorhandlingspringbootstarter;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ProblemDetail;
import org.springframework.stereotype.Component;

import java.net.URI;
import java.net.URISyntaxException;
import java.util.HashMap;
import java.util.List;
import java.util.Objects;

@Component
public class ProblemDetailFactory {
private static final Logger LOGGER = LoggerFactory.getLogger(ProblemDetailFactory.class);

private final ErrorHandlingProperties properties;

public ProblemDetailFactory(ErrorHandlingProperties properties) {
this.properties = properties;
}

public ProblemDetail build(ApiErrorResponse errorResponse) {
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(Objects.requireNonNull(errorResponse.getHttpStatus()), errorResponse.getMessage());
problemDetail.setDetail(errorResponse.getMessage());
try {
problemDetail.setType(new URI(properties.getProblemDetailTypePrefix() + toKebabCase(errorResponse.getCode())));
} catch (URISyntaxException ignored) {
}

HashMap<String, Object> allProperties = new HashMap<>(errorResponse.getProperties());
List<ApiFieldError> fieldErrors = errorResponse.getFieldErrors();
if (!fieldErrors.isEmpty()) {
allProperties.put("fieldErrors", fieldErrors);
}
List<ApiGlobalError> globalErrors = errorResponse.getGlobalErrors();
if (!globalErrors.isEmpty()) {
allProperties.put("globalErrors", globalErrors);
}
problemDetail.setProperties(allProperties);

return problemDetail;
}

private String toKebabCase(String input) {
if (input.isEmpty()) {
return input;
}

String result = input
.replaceAll("([a-z])([A-Z])", "$1-$2") // camelCase boundaries
.replaceAll("([A-Z])([A-Z][a-z])", "$1-$2") // handle acronyms
.replaceAll("\\s+", "-") // spaces to hyphens
.replaceAll("_", "-") // underscores to hyphens
.toLowerCase();
LOGGER.info("{} -> {}", input, result);
return result;
}
Comment on lines +46 to +59
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unless the ProblemDetail spec requires the type field to be kebab case, It's not clear why you're changing the code to kebab case when ProblemDetail mode is enabled.

This will make it more difficult to migrate to ProblemDetail mode (e.g. changing frontend code), for no obvious benefit.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All the examples I have found use kebab case for the type field. I guess because it is supposed to be a URL?

Copy link
Contributor

@donalmurtagh donalmurtagh Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIK, there's nothing that prohibits the use of uppercase or underscores in a URL, but obviously it's much more common to use lowercase and dashes. So you could argue that lowercase and dash-separators are a de facto standard.

Presumably a lot of applications using this starter will have switch-statements or if-else statements that check the type of the error, which will need to be migrated if ProblemDetail support is enabled. I can see 3 options

  1. Stick with kebab case and mention (e.g. in the release notes) that the aforementioned migration will be necessary if ProblemDetail support is enabled
  2. Use SNAKE_CASE for the error type in all cases
  3. Use kebab case (1) by default, but provide a flag that allows SNAKE_CASE (2) to be used instead. Obviously, this is the option that provides the most flexibility for the user, but the most amount of effort for you

Personally, I think all 3 options are defensible, and even if someone does have to migrate, it's a pretty easy migration, so probably not worth worrying about too much.

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiErrorResponse;
import io.github.wimdeblauwe.errorhandlingspringbootstarter.ErrorHandlingFacade;
import io.github.wimdeblauwe.errorhandlingspringbootstarter.ErrorHandlingProperties;
import io.github.wimdeblauwe.errorhandlingspringbootstarter.ProblemDetailFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.web.ErrorProperties;
Expand All @@ -21,14 +23,21 @@ public class GlobalErrorWebExceptionHandler extends DefaultErrorWebExceptionHand
private static final Logger LOGGER = LoggerFactory.getLogger(GlobalErrorWebExceptionHandler.class);

private final ErrorHandlingFacade errorHandlingFacade;
private final ErrorHandlingProperties errorHandlingProperties;
private final ProblemDetailFactory problemDetailFactory;


public GlobalErrorWebExceptionHandler(ErrorAttributes errorAttributes,
WebProperties.Resources resources,
ErrorProperties errorProperties,
ApplicationContext applicationContext,
ErrorHandlingFacade errorHandlingFacade) {
ErrorHandlingFacade errorHandlingFacade,
ErrorHandlingProperties errorHandlingProperties,
ProblemDetailFactory problemDetailFactory) {
super(errorAttributes, resources, errorProperties, applicationContext);
this.errorHandlingFacade = errorHandlingFacade;
this.errorHandlingProperties = errorHandlingProperties;
this.problemDetailFactory = problemDetailFactory;
}

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

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

return ServerResponse.status(Objects.requireNonNull(errorResponse.getHttpStatus()))
.contentType(MediaType.APPLICATION_JSON)
.body(BodyInserters.fromValue(errorResponse));
if (errorHandlingProperties.isUseProblemDetailFormat()) {
return ServerResponse.status(Objects.requireNonNull(errorResponse.getHttpStatus()))
Copy link
Contributor

@donalmurtagh donalmurtagh Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could the httpStatus in ApiErrorResponse be defined as non-null to avoid all the calls to Objects.requireNotNull?

Maybe there are some places (outside this PR), where getHttpStatus() is expected to return null that I've missed. If so, an alternative approach is to change getHttpStatus() to return Optional<HttpStatusCode> which would allow the @Nullable to be removed from the getter.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unfortunately, this is not possible.

The status field is not always present in the JSON output (it is mostly not present as this is the default). So when you want to serialize the JSON back to the object (using ApiErrorResponseDeserializer), then it can be null.

Using Optional would interfere with existing code. I am also not sure if it would influence the JSON serialization. This is something that could be considered for a next major version, but I don't want to do breaking changes at this point.

.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(BodyInserters.fromValue(problemDetailFactory.build(errorResponse)));
} else {
return ServerResponse.status(Objects.requireNonNull(errorResponse.getHttpStatus()))
.contentType(MediaType.APPLICATION_JSON)
.body(BodyInserters.fromValue(errorResponse));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,19 +65,29 @@ public GlobalErrorWebExceptionHandler globalErrorWebExceptionHandler(ErrorAttrib
ObjectProvider<ViewResolver> viewResolvers,
ServerCodecConfigurer serverCodecConfigurer,
ApplicationContext applicationContext,
ErrorHandlingFacade errorHandlingFacade) {
ErrorHandlingFacade errorHandlingFacade,
ErrorHandlingProperties errorHandlingProperties,
ProblemDetailFactory problemDetailFactory) {

GlobalErrorWebExceptionHandler exceptionHandler = new GlobalErrorWebExceptionHandler(errorAttributes,
webProperties.getResources(),
webProperties.getError(),
applicationContext,
errorHandlingFacade);
errorHandlingFacade,
errorHandlingProperties,
problemDetailFactory);
exceptionHandler.setViewResolvers(viewResolvers.orderedStream().collect(Collectors.toList()));
exceptionHandler.setMessageWriters(serverCodecConfigurer.getWriters());
exceptionHandler.setMessageReaders(serverCodecConfigurer.getReaders());
return exceptionHandler;
}

@Bean
@ConditionalOnMissingBean
public ProblemDetailFactory responseEntityFactory(ErrorHandlingProperties errorHandlingProperties) {
return new ProblemDetailFactory(errorHandlingProperties);
}

@Bean
@ConditionalOnMissingBean(value = ErrorAttributes.class, search = SearchStrategy.CURRENT)
public DefaultErrorAttributes errorAttributes() {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
package io.github.wimdeblauwe.errorhandlingspringbootstarter.servlet;

import io.github.wimdeblauwe.errorhandlingspringbootstarter.ApiErrorResponse;
import io.github.wimdeblauwe.errorhandlingspringbootstarter.ErrorHandlingFacade;
import io.github.wimdeblauwe.errorhandlingspringbootstarter.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
Expand All @@ -21,9 +21,13 @@ public class ErrorHandlingControllerAdvice {
private static final Logger LOGGER = LoggerFactory.getLogger(ErrorHandlingControllerAdvice.class);

private final ErrorHandlingFacade errorHandlingFacade;
private final ErrorHandlingProperties errorHandlingProperties;
private final ProblemDetailFactory problemDetailFactory;

public ErrorHandlingControllerAdvice(ErrorHandlingFacade errorHandlingFacade) {
public ErrorHandlingControllerAdvice(ErrorHandlingFacade errorHandlingFacade, ErrorHandlingProperties errorHandlingProperties, ProblemDetailFactory problemDetailFactory) {
this.errorHandlingFacade = errorHandlingFacade;
this.errorHandlingProperties = errorHandlingProperties;
this.problemDetailFactory = problemDetailFactory;
}

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

ApiErrorResponse errorResponse = errorHandlingFacade.handle(exception);

return ResponseEntity.status(Objects.requireNonNull(errorResponse.getHttpStatus()))
.contentType(MediaType.APPLICATION_JSON)
.body(errorResponse);
if (errorHandlingProperties.isUseProblemDetailFormat()) {
ProblemDetail problemDetail = problemDetailFactory.build(errorResponse);
return ResponseEntity.status(Objects.requireNonNull(errorResponse.getHttpStatus()))
.contentType(MediaType.APPLICATION_PROBLEM_JSON)
.body(problemDetail);
} else {
return ResponseEntity.status(Objects.requireNonNull(errorResponse.getHttpStatus()))
.contentType(MediaType.APPLICATION_JSON)
.body(errorResponse);
}
Comment on lines +40 to +49
Copy link
Contributor

@donalmurtagh donalmurtagh Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This if-else block is very similar to the if-else in GlobalErrorWebExceptionHandler.handleException.

Perhaps it could be improved (in terms of encapsulation/DRY) by defining a custom type for rendering the response, e.g.

public interface ResponseFactory {
  ServerResponse buildReactiveResponse(ApiErrorResponse error);

  ResponseEntity<?> buildServletResponse(ApiErrorResponse error);
}

and have 2 beans that implement this interface

  1. DefaultResponseFactory - when ProblemDetail is disabled
  2. ProblemDetailResponseFactory - when ProblemDetail is enabled

Inject the appropriate implementation of the interface into GlobalErrorWebExceptionHandler and ErrorHandlingControllerAdvice and use it instead of ProblemDetailFactory.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting idea, but what I don't like about it is that it mixes reactive and servlet classes in the same interface. i rather keep those parts separate.

Copy link
Contributor

@donalmurtagh donalmurtagh Jan 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are two sources of variation

  1. Reactive or servlet
  2. ProblemDetail enabled or disabled

In the proposal above, (1) is handled via separate interface methods and (2) is handled via separate implementations of the interface

If you would prefer not to mix servlet and reactive code in the same type, perhaps it would be better to swap (1) and (2). In other words, define an interface with one method for generating the ProblemDetail enabled response, a second method for the ProblemDetail disabled response, and separate implementations of the interface for the reactive and servlet cases.

There are many other options for handling the various cases, but I do think that moving the response-generation to a dedicated type would be a cleaner design and eliminate some code duplication.

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,16 @@ public MissingRequestValueExceptionHandler missingRequestValueExceptionHandler(H

@Bean
@ConditionalOnMissingBean
public ErrorHandlingControllerAdvice errorHandlingControllerAdvice(ErrorHandlingFacade errorHandlingFacade) {
return new ErrorHandlingControllerAdvice(errorHandlingFacade);
public ErrorHandlingControllerAdvice errorHandlingControllerAdvice(ErrorHandlingFacade errorHandlingFacade,
ErrorHandlingProperties errorHandlingProperties,
ProblemDetailFactory problemDetailFactory) {
return new ErrorHandlingControllerAdvice(errorHandlingFacade, errorHandlingProperties, problemDetailFactory);
}

@Bean
@ConditionalOnMissingBean
public ProblemDetailFactory responseEntityFactory(ErrorHandlingProperties errorHandlingProperties) {
return new ProblemDetailFactory(errorHandlingProperties);
}

@Bean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,7 @@
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest
@ContextConfiguration(classes = {
Expand Down Expand Up @@ -362,6 +361,30 @@ void testDisableAddingPath(@Autowired ErrorHandlingProperties properties) throws
;
}

@Test
@WithMockUser
void testConstraintViolationExceptionUsingProblemDetailFormat(@Autowired ErrorHandlingProperties properties) throws Exception {
properties.setUseProblemDetailFormat(true);
mockMvc.perform(post("/test/validation")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"value2\": \"\"}")
.with(csrf()))
.andExpect(status().isBadRequest())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON))
.andExpect(jsonPath("title").value("Bad Request"))
.andExpect(jsonPath("type").value("validation-failed"))
.andExpect(jsonPath("detail").value("Validation failed. Error count: 3"))
.andExpect(jsonPath("fieldErrors", hasSize(2)))
.andExpect(jsonPath("fieldErrors..code", allOf(hasItem("REQUIRED_NOT_NULL"), hasItem("INVALID_SIZE"))))
.andExpect(jsonPath("fieldErrors..property", allOf(hasItem("value"), hasItem("value2"))))
.andExpect(jsonPath("fieldErrors..message", allOf(hasItem("must not be null"), hasItem("size must be between 1 and 255"))))
.andExpect(jsonPath("fieldErrors..rejectedValue", allOf(hasItem(Matchers.nullValue()), hasItem(""))))
.andExpect(jsonPath("globalErrors", hasSize(1)))
.andExpect(jsonPath("globalErrors..code", allOf(hasItem("ValuesEqual"))))
.andExpect(jsonPath("globalErrors..message", allOf(hasItem("Values not equal"))))
;
}

@RestController
@RequestMapping
public static class TestController {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package io.github.wimdeblauwe.errorhandlingspringbootstarter.handler;

import io.github.wimdeblauwe.errorhandlingspringbootstarter.servlet.ServletErrorHandlingConfiguration;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@
package io.github.wimdeblauwe.errorhandlingspringbootstarter.handler;


import io.github.wimdeblauwe.errorhandlingspringbootstarter.ErrorHandlingProperties;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@WebMvcTest
@ContextConfiguration(classes = {
ObjectOptimisticLockingFailureApiExceptionHandlerTest.TestController.class})
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD)
class ObjectOptimisticLockingFailureApiExceptionHandlerTest {

@Autowired
Expand All @@ -36,6 +39,37 @@ void testOOLF() throws Exception {
;
}

@Test
@WithMockUser
void testOOLFWithProblemDetailFormat(@Autowired ErrorHandlingProperties properties) throws Exception {
properties.setUseProblemDetailFormat(true);
mockMvc.perform(get("/test/object-optimistic-locking-failure"))
.andExpect(status().isConflict())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON))
.andExpect(jsonPath("title").value("Conflict"))
.andExpect(jsonPath("type").value("optimistic-locking-error"))
.andExpect(jsonPath("detail").value("Object of class [com.example.user.User] with identifier [1]: optimistic locking failed"))
.andExpect(jsonPath("persistentClassName").value("com.example.user.User"))
.andExpect(jsonPath("identifier").value("1"))
;
}

@Test
@WithMockUser
void testOOLFWithProblemDetailFormatAndTypePrefix(@Autowired ErrorHandlingProperties properties) throws Exception {
properties.setUseProblemDetailFormat(true);
properties.setProblemDetailTypePrefix("https://example.org/");
mockMvc.perform(get("/test/object-optimistic-locking-failure"))
.andExpect(status().isConflict())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_PROBLEM_JSON))
.andExpect(jsonPath("title").value("Conflict"))
.andExpect(jsonPath("type").value("https://example.org/optimistic-locking-error"))
.andExpect(jsonPath("detail").value("Object of class [com.example.user.User] with identifier [1]: optimistic locking failed"))
.andExpect(jsonPath("persistentClassName").value("com.example.user.User"))
.andExpect(jsonPath("identifier").value("1"))
;
}

@RestController
@RequestMapping("/test/object-optimistic-locking-failure")
public static class TestController {
Expand Down
Loading
Loading