Auto-configure ProblemDetails support

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
This commit is contained in:
Brian Clozel
2022-10-07 17:23:12 +02:00
parent 13e0a1314a
commit 23a9818e0d
11 changed files with 241 additions and 2 deletions

View File

@@ -39,6 +39,7 @@ import org.springframework.data.r2dbc.dialect.DialectResolver;
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;
@@ -79,11 +80,10 @@ public class R2dbcDataAutoConfiguration {
@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;

View File

@@ -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 {
}

View File

@@ -74,6 +74,7 @@ import org.springframework.web.reactive.result.method.HandlerMethodArgumentResol
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;
@@ -334,6 +335,18 @@ public class WebFluxAutoConfiguration {
}
@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;

View File

@@ -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.
*/
@@ -74,6 +76,10 @@ public class WebFluxProperties {
return this.format;
}
public Problemdetails getProblemdetails() {
return this.problemdetails;
}
public String getStaticPathPattern() {
return this.staticPathPattern;
}
@@ -133,4 +139,21 @@ public class WebFluxProperties {
}
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;
}
}
}

View File

@@ -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 {
}

View File

@@ -114,6 +114,7 @@ import org.springframework.web.servlet.i18n.FixedLocaleResolver;
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;
@@ -643,6 +644,18 @@ public class WebMvcAutoConfiguration {
}
@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}

View File

@@ -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;
}
@@ -213,6 +215,10 @@ public class WebMvcProperties {
return this.pathmatch;
}
public Problemdetails getProblemdetails() {
return this.problemdetails;
}
public static class Async {
/**
@@ -447,4 +453,21 @@ public class WebMvcProperties {
}
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;
}
}
}

View File

@@ -70,6 +70,7 @@ import org.springframework.test.util.ReflectionTestUtils;
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;
@@ -85,6 +86,7 @@ import org.springframework.web.reactive.resource.ResourceWebHandler;
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;
@@ -622,6 +624,25 @@ class WebFluxAutoConfigurationTests {
.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) -> {
@@ -911,4 +932,19 @@ class WebFluxAutoConfigurationTests {
}
@Configuration(proxyBeanMethods = false)
static class CustomExceptionResolverConfiguration {
@Bean
CustomExceptionResolver customExceptionResolver() {
return new CustomExceptionResolver();
}
}
@ControllerAdvice
static class CustomExceptionResolver extends ResponseEntityExceptionHandler {
}
}

View File

@@ -77,6 +77,7 @@ import org.springframework.validation.Validator;
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;
@@ -106,6 +107,7 @@ import org.springframework.web.servlet.i18n.FixedLocaleResolver;
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;
@@ -959,6 +961,25 @@ class WebMvcAutoConfigurationTests {
}
}
@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));
@@ -1485,4 +1506,19 @@ class WebMvcAutoConfigurationTests {
}
@Configuration(proxyBeanMethods = false)
static class CustomExceptionResolverConfiguration {
@Bean
CustomExceptionResolver customExceptionResolver() {
return new CustomExceptionResolver();
}
}
@ControllerAdvice
static class CustomExceptionResolver extends ResponseEntityExceptionHandler {
}
}

View File

@@ -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`.

View File

@@ -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[]