Skip to content

Commit

Permalink
Auto-configure ProblemDetails support
Browse files Browse the repository at this point in the history
This commit auto-configures ProblemDetails support for both Spring MVC
and Spring WebFlux, contributing a `@ControllerAdvice` annotated
`ResponseEntityExceptionHandler` bean if the
`spring.mvc.problemdetails.enabled` or
`spring.webflux.problemdetails.enabled` properties are set to `true`.

Closes gh-32634
  • Loading branch information
bclozel committed Oct 7, 2022
1 parent 13e0a13 commit 23a9818
Show file tree
Hide file tree
Showing 11 changed files with 241 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import org.springframework.data.r2dbc.dialect.R2dbcDialect;
import org.springframework.data.r2dbc.mapping.R2dbcMappingContext;
import org.springframework.data.relational.RelationalManagedTypes;
import org.springframework.data.relational.core.mapping.DefaultNamingStrategy;
import org.springframework.data.relational.core.mapping.NamingStrategy;
import org.springframework.data.relational.core.mapping.Table;
import org.springframework.r2dbc.core.DatabaseClient;
Expand Down Expand Up @@ -79,11 +80,10 @@ static RelationalManagedTypes r2dbcManagedTypes(ApplicationContext applicationCo

@Bean
@ConditionalOnMissingBean
@SuppressWarnings("deprecation")
public R2dbcMappingContext r2dbcMappingContext(ObjectProvider<NamingStrategy> namingStrategy,
R2dbcCustomConversions r2dbcCustomConversions, RelationalManagedTypes r2dbcManagedTypes) {
R2dbcMappingContext relationalMappingContext = new R2dbcMappingContext(
namingStrategy.getIfAvailable(() -> NamingStrategy.INSTANCE));
namingStrategy.getIfAvailable(() -> DefaultNamingStrategy.INSTANCE));
relationalMappingContext.setSimpleTypeHolder(r2dbcCustomConversions.getSimpleTypeHolder());
relationalMappingContext.setManagedTypes(r2dbcManagedTypes);
return relationalMappingContext;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Copyright 2012-2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.springframework.boot.autoconfigure.web.reactive;

import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.reactive.result.method.annotation.ResponseEntityExceptionHandler;

/**
* {@code @ControllerAdvice} annotated {@link ResponseEntityExceptionHandler} that is
* auto-configured for problem details support.
*
* @author Brian Clozel
*/
@ControllerAdvice
final class ProblemDetailsExceptionHandler extends ResponseEntityExceptionHandler {

}
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
import org.springframework.web.reactive.result.method.annotation.ArgumentResolverConfigurer;
import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerAdapter;
import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerMapping;
import org.springframework.web.reactive.result.method.annotation.ResponseEntityExceptionHandler;
import org.springframework.web.reactive.result.view.ViewResolver;
import org.springframework.web.server.WebSession;
import org.springframework.web.server.adapter.WebHttpHandlerBuilder;
Expand Down Expand Up @@ -334,6 +335,18 @@ ResourceChainResourceHandlerRegistrationCustomizer resourceHandlerRegistrationCu

}

@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(prefix = "spring.webflux.problemdetails", name = "enabled", havingValue = "true")
static class ProblemDetailsErrorHandlingConfiguration {

@Bean
@ConditionalOnMissingBean(ResponseEntityExceptionHandler.class)
ProblemDetailsExceptionHandler problemDetailsExceptionHandler() {
return new ProblemDetailsExceptionHandler();
}

}

static final class MaxIdleTimeInMemoryWebSessionStore extends InMemoryWebSessionStore {

private final Duration timeout;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ public class WebFluxProperties {

private final Format format = new Format();

private final Problemdetails problemdetails = new Problemdetails();

/**
* Path pattern used for static resources.
*/
Expand Down Expand Up @@ -74,6 +76,10 @@ public Format getFormat() {
return this.format;
}

public Problemdetails getProblemdetails() {
return this.problemdetails;
}

public String getStaticPathPattern() {
return this.staticPathPattern;
}
Expand Down Expand Up @@ -133,4 +139,21 @@ public void setDateTime(String dateTime) {

}

public static class Problemdetails {

/**
* Whether RFC 7807 Problem Details support should be enabled.
*/
private boolean enabled = false;

public boolean isEnabled() {
return this.enabled;
}

public void setEnabled(boolean enabled) {
this.enabled = enabled;
}

}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/*
* Copyright 2012-2022 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.springframework.boot.autoconfigure.web.servlet;

import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

/**
* {@code @ControllerAdvice} annotated {@link ResponseEntityExceptionHandler} that is
* auto-configured for problem details support.
*
* @author Brian Clozel
*/
@ControllerAdvice
final class ProblemDetailsExceptionHandler extends ResponseEntityExceptionHandler {

}
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@
import org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import org.springframework.web.servlet.resource.EncodedResourceResolver;
import org.springframework.web.servlet.resource.ResourceResolver;
import org.springframework.web.servlet.resource.ResourceUrlProvider;
Expand Down Expand Up @@ -643,6 +644,18 @@ private ResourceResolver getVersionResourceResolver(Strategy properties) {

}

@Configuration(proxyBeanMethods = false)
@ConditionalOnProperty(prefix = "spring.mvc.problemdetails", name = "enabled", havingValue = "true")
static class ProblemDetailsErrorHandlingConfiguration {

@Bean
@ConditionalOnMissingBean(ResponseEntityExceptionHandler.class)
ProblemDetailsExceptionHandler problemDetailsExceptionHandler() {
return new ProblemDetailsExceptionHandler();
}

}

/**
* Decorator to make
* {@link org.springframework.web.accept.PathExtensionContentNegotiationStrategy}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ public class WebMvcProperties {

private final Pathmatch pathmatch = new Pathmatch();

private final Problemdetails problemdetails = new Problemdetails();

public DefaultMessageCodesResolver.Format getMessageCodesResolverFormat() {
return this.messageCodesResolverFormat;
}
Expand Down Expand Up @@ -213,6 +215,10 @@ public Pathmatch getPathmatch() {
return this.pathmatch;
}

public Problemdetails getProblemdetails() {
return this.problemdetails;
}

public static class Async {

/**
Expand Down Expand Up @@ -447,4 +453,21 @@ public enum MatchingStrategy {

}

public static class Problemdetails {

/**
* Whether RFC 7807 Problem Details support should be enabled.
*/
private boolean enabled = false;

public boolean isEnabled() {
return this.enabled;
}

public void setEnabled(boolean enabled) {
this.enabled = enabled;
}

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
import org.springframework.util.StringUtils;
import org.springframework.validation.Validator;
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.filter.reactive.HiddenHttpMethodFilter;
import org.springframework.web.reactive.HandlerMapping;
import org.springframework.web.reactive.accept.RequestedContentTypeResolver;
Expand All @@ -85,6 +86,7 @@
import org.springframework.web.reactive.result.method.HandlerMethodArgumentResolver;
import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerAdapter;
import org.springframework.web.reactive.result.method.annotation.RequestMappingHandlerMapping;
import org.springframework.web.reactive.result.method.annotation.ResponseEntityExceptionHandler;
import org.springframework.web.reactive.result.view.ViewResolutionResultHandler;
import org.springframework.web.reactive.result.view.ViewResolver;
import org.springframework.web.server.ServerWebExchange;
Expand Down Expand Up @@ -622,6 +624,25 @@ void propertiesAreNotEnabledInNonWebApplication(Class<?> propertiesClass) {
.run((context) -> assertThat(context).doesNotHaveBean(propertiesClass));
}

@Test
void problemDetailsDisabledByDefault() {
this.contextRunner.run((context) -> assertThat(context).doesNotHaveBean(ProblemDetailsExceptionHandler.class));
}

@Test
void problemDetailsEnabledAddsExceptionHandler() {
this.contextRunner.withPropertyValues("spring.webflux.problemdetails.enabled:true")
.run((context) -> assertThat(context).hasSingleBean(ProblemDetailsExceptionHandler.class));
}

@Test
void problemDetailsBacksOffWhenExceptionHandler() {
this.contextRunner.withPropertyValues("spring.webflux.problemdetails.enabled:true")
.withUserConfiguration(CustomExceptionResolverConfiguration.class)
.run((context) -> assertThat(context).doesNotHaveBean(ProblemDetailsExceptionHandler.class)
.hasSingleBean(CustomExceptionResolver.class));
}

private ContextConsumer<ReactiveWebApplicationContext> assertExchangeWithSession(
Consumer<MockServerWebExchange> exchange) {
return (context) -> {
Expand Down Expand Up @@ -911,4 +932,19 @@ static class LowPrecedenceConfigurer implements WebFluxConfigurer {

}

@Configuration(proxyBeanMethods = false)
static class CustomExceptionResolverConfiguration {

@Bean
CustomExceptionResolver customExceptionResolver() {
return new CustomExceptionResolver();
}

}

@ControllerAdvice
static class CustomExceptionResolver extends ResponseEntityExceptionHandler {

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean;
import org.springframework.web.accept.ContentNegotiationManager;
import org.springframework.web.accept.ParameterContentNegotiationStrategy;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.support.ConfigurableWebBindingInitializer;
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
Expand Down Expand Up @@ -106,6 +107,7 @@
import org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver;
import org.springframework.web.servlet.resource.CachingResourceResolver;
import org.springframework.web.servlet.resource.CachingResourceTransformer;
Expand Down Expand Up @@ -959,6 +961,25 @@ void addResourceHandlersAppliesToChildAndParentContext() {
}
}

@Test
void problemDetailsDisabledByDefault() {
this.contextRunner.run((context) -> assertThat(context).doesNotHaveBean(ProblemDetailsExceptionHandler.class));
}

@Test
void problemDetailsEnabledAddsExceptionHandler() {
this.contextRunner.withPropertyValues("spring.mvc.problemdetails.enabled:true")
.run((context) -> assertThat(context).hasSingleBean(ProblemDetailsExceptionHandler.class));
}

@Test
void problemDetailsBacksOffWhenExceptionHandler() {
this.contextRunner.withPropertyValues("spring.mvc.problemdetails.enabled:true")
.withUserConfiguration(CustomExceptionResolverConfiguration.class)
.run((context) -> assertThat(context).doesNotHaveBean(ProblemDetailsExceptionHandler.class)
.hasSingleBean(CustomExceptionResolver.class));
}

private void assertResourceHttpRequestHandler(AssertableWebApplicationContext context,
Consumer<ResourceHttpRequestHandler> handlerConsumer) {
Map<String, Object> handlerMap = getHandlerMap(context.getBean("resourceHandlerMapping", HandlerMapping.class));
Expand Down Expand Up @@ -1485,4 +1506,19 @@ public void addResourceHandlers(ResourceHandlerRegistry registry) {

}

@Configuration(proxyBeanMethods = false)
static class CustomExceptionResolverConfiguration {

@Bean
CustomExceptionResolver customExceptionResolver() {
return new CustomExceptionResolver();
}

}

@ControllerAdvice
static class CustomExceptionResolver extends ResponseEntityExceptionHandler {

}

}
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,23 @@ For machine clients, it produces a JSON response with details of the error, the
For browser clients, there is a "`whitelabel`" error handler that renders the same data in HTML format.
You can also provide your own HTML templates to display errors (see the <<web#web.reactive.webflux.error-handling.error-pages,next section>>).

Before customizing error handling in Spring Boot directly, you can leverage the {spring-framework-docs}/web-reactive.html#webflux-ann-rest-exceptions[RFC 7807 Problem Details] support in Spring WebFlux.
Spring WebFlux can produce custom error messages with the `application/problem+json` media type, like:

[source,json,indent=0,subs="verbatim"]
----
{
"type": "https://example.org/problems/unknown-project",
"title": "Unknown project",
"status": 404,
"detail": "No project found for id 'spring-unknown'",
"instance": "/projects/spring-unknown"
}
----

This support can be enabled by setting configprop:spring.webflux.problemdetails.enabled[] to `true`.


The first step to customizing this feature often involves using the existing mechanism but replacing or augmenting the error contents.
For that, you can add a bean of type `ErrorAttributes`.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,22 @@ TIP: The `BasicErrorController` can be used as a base class for a custom `ErrorC
This is particularly useful if you want to add a handler for a new content type (the default is to handle `text/html` specifically and provide a fallback for everything else).
To do so, extend `BasicErrorController`, add a public method with a `@RequestMapping` that has a `produces` attribute, and create a bean of your new type.

As of Spring Framework 6.0, {spring-framework-docs}/web.html#mvc-ann-rest-exceptions[RFC 7807 Problem Details] is supported.
Spring MVC can produce custom error messages with the `application/problem+json` media type, like:

[source,json,indent=0,subs="verbatim"]
----
{
"type": "https://example.org/problems/unknown-project",
"title": "Unknown project",
"status": 404,
"detail": "No project found for id 'spring-unknown'",
"instance": "/projects/spring-unknown"
}
----

This support can be enabled by setting configprop:spring.mvc.problemdetails.enabled[] to `true`.

You can also define a class annotated with `@ControllerAdvice` to customize the JSON document to return for a particular controller and/or exception type, as shown in the following example:

include::code:MyControllerAdvice[]
Expand Down

0 comments on commit 23a9818

Please sign in to comment.