Support error conventions in Spring WebFlux

This commit adds support for Spring Boot error conventions with WebFlux.
The Spring MVC support for that is based on an `Controller` that's
mapped on a specific `"/error"` path and configured as an error page in
the Servlet container. With WebFlux, this support leverages a
`WebExceptionHandler`, which catches exceptions flowing through the
reactive pipeline and handles them.

The `DefaultErrorWebExceptionHandler` supports the following:
* return a JSON error response to machine clients
* return error HTML views (templates, static or default HTML view)

One can customize the error information by contributing an
`ErrorAttributes` bean to the application context.

Spring Boot provides an `ErrorWebExceptionHandler` marker interface and a
base implementation that provides high level constructs to handle
errors, based on the Spring WebFlux functional flavor.
The error handling logic can be completely changed by providing a custom
`RouterFunction` there.

Fixes gh-8625
This commit is contained in:
Brian Clozel
2017-10-16 21:55:05 +02:00
parent c4adb76df2
commit 68db43cf44
15 changed files with 1292 additions and 8 deletions

View File

@@ -127,7 +127,7 @@ public class WebFluxAutoConfiguration {
@Override
public void configureArgumentResolvers(ArgumentResolverConfigurer configurer) {
if (this.argumentResolvers != null) {
this.argumentResolvers.stream().forEach(configurer::addCustomResolver);
this.argumentResolvers.forEach(configurer::addCustomResolver);
}
}

View File

@@ -0,0 +1,224 @@
/*
* Copyright 2012-2017 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
*
* http://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.error;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;
import reactor.core.publisher.Mono;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.boot.autoconfigure.template.TemplateAvailabilityProviders;
import org.springframework.boot.autoconfigure.web.ResourceProperties;
import org.springframework.context.ApplicationContext;
import org.springframework.core.io.Resource;
import org.springframework.http.codec.HttpMessageReader;
import org.springframework.http.codec.HttpMessageWriter;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.reactive.result.view.ViewResolver;
import org.springframework.web.server.ServerWebExchange;
/**
* Abstract base class for {@link ErrorWebExceptionHandler} implementations.
*
* @author Brian Clozel
* @since 2.0.0
* @see ErrorAttributes
*/
public abstract class AbstractErrorWebExceptionHandler implements ErrorWebExceptionHandler, InitializingBean {
private final ApplicationContext applicationContext;
private final ErrorAttributes errorAttributes;
private final ResourceProperties resourceProperties;
private final TemplateAvailabilityProviders templateAvailabilityProviders;
private List<HttpMessageReader<?>> messageReaders = Collections.emptyList();
private List<HttpMessageWriter<?>> messageWriters = Collections.emptyList();
private List<ViewResolver> viewResolvers = Collections.emptyList();
public AbstractErrorWebExceptionHandler(ErrorAttributes errorAttributes,
ResourceProperties resourceProperties,
ApplicationContext applicationContext) {
Assert.notNull(errorAttributes, "ErrorAttributes must not be null");
Assert.notNull(resourceProperties, "ResourceProperties must not be null");
Assert.notNull(applicationContext, "ApplicationContext must not be null");
this.errorAttributes = errorAttributes;
this.resourceProperties = resourceProperties;
this.applicationContext = applicationContext;
this.templateAvailabilityProviders = new TemplateAvailabilityProviders(applicationContext);
}
/**
* Configure HTTP message writers to serialize the response body with.
* @param messageWriters the {@link HttpMessageWriter}s to use
*/
public void setMessageWriters(List<HttpMessageWriter<?>> messageWriters) {
Assert.notNull(messageWriters, "'messageWriters' must not be null");
this.messageWriters = messageWriters;
}
/**
* Configure HTTP message readers to deserialize the request body with.
* @param messageReaders the {@link HttpMessageReader}s to use
*/
public void setMessageReaders(List<HttpMessageReader<?>> messageReaders) {
Assert.notNull(messageReaders, "'messageReaders' must not be null");
this.messageReaders = messageReaders;
}
/**
* Configure the {@link ViewResolver} to use for rendering views.
* @param viewResolvers the list of {@link ViewResolver}s to use
*/
public void setViewResolvers(List<ViewResolver> viewResolvers) {
this.viewResolvers = viewResolvers;
}
/**
* Extract the error attributes from the current request, to be used
* to populate error views or JSON payloads.
* @param request the source request
* @param includeStackTrace whether to include the error stacktrace information
* @return the error attributes as a Map.
*/
protected Map<String, Object> getErrorAttributes(ServerRequest request, boolean includeStackTrace) {
return this.errorAttributes.getErrorAttributes(request, includeStackTrace);
}
protected boolean isTraceEnabled(ServerRequest request) {
String parameter = request.queryParam("trace").orElse("false");
return !"false".equals(parameter.toLowerCase());
}
/**
* Render the given error data as a view, using a template view if available
* or a static HTML file if available otherwise. This will return an empty
* {@code Publisher} if none of the above are available.
* @param viewName the view name
* @param responseBody the error response being built
* @param error the error data as a map
* @return a Publisher of the {@link ServerResponse}
*/
protected Mono<ServerResponse> renderErrorView(String viewName, ServerResponse.BodyBuilder responseBody,
Map<String, Object> error) {
if (isTemplateAvailable(viewName)) {
return responseBody.render(viewName, error);
}
Resource resource = resolveResource(viewName);
if (resource != null) {
return responseBody.body(BodyInserters.fromResource(resource));
}
return Mono.empty();
}
private boolean isTemplateAvailable(String viewName) {
return this.templateAvailabilityProviders.getProvider(viewName, this.applicationContext) != null;
}
private Resource resolveResource(String viewName) {
for (String location : this.resourceProperties.getStaticLocations()) {
try {
Resource resource = this.applicationContext.getResource(location);
resource = resource.createRelative(viewName + ".html");
if (resource.exists()) {
return resource;
}
}
catch (Exception ex) {
}
}
return null;
}
/**
* Render a default HTML "Whitelabel Error Page".
* <p>Useful when no other error view is available in the application.
* @param responseBody the error response being built
* @param error the error data as a map
* @return a Publisher of the {@link ServerResponse}
*/
protected Mono<ServerResponse> renderDefaultErrorView(ServerResponse.BodyBuilder responseBody,
Map<String, Object> error) {
StringBuilder builder = new StringBuilder();
Date timestamp = (Date) error.get("timestamp");
builder.append("<html><body><h1>Whitelabel Error Page</h1>")
.append("<p>This application has no configured error view, so you are seeing this as a fallback.</p>")
.append("<div id='created'>").append(timestamp.toString()).append("</div>")
.append("<div>There was an unexpected error (type=")
.append(error.get("error")).append(", status=").append(error.get("status"))
.append(").</div>")
.append("<div>").append(error.get("message")).append("</div>")
.append("</body></html>");
return responseBody.syncBody(builder.toString());
}
@Override
public void afterPropertiesSet() throws Exception {
if (CollectionUtils.isEmpty(this.messageWriters)) {
throw new IllegalArgumentException("Property 'messageWriters' is required");
}
}
/**
* Create a {@link RouterFunction} that can route and handle errors as JSON responses
* or HTML views.
* <p>If the returned {@link RouterFunction} doesn't route to a {@code HandlerFunction},
* the original exception is propagated in the pipeline and can be processed by other
* {@link org.springframework.web.server.WebExceptionHandler}s.
* @param errorAttributes the {@code ErrorAttributes} instance to use to extract error information
* @return a {@link RouterFunction} that routes and handles errors
*/
protected abstract RouterFunction<ServerResponse> getRoutingFunction(ErrorAttributes errorAttributes);
@Override
public Mono<Void> handle(ServerWebExchange exchange, Throwable throwable) {
this.errorAttributes.storeErrorInformation(throwable, exchange);
ServerRequest request = ServerRequest.create(exchange, this.messageReaders);
return getRoutingFunction(this.errorAttributes)
.route(request)
.switchIfEmpty(Mono.error(throwable))
.flatMap(handler -> handler.handle(request))
.flatMap(response -> {
// force content-type since writeTo won't overwrite response header values
exchange.getResponse().getHeaders().setContentType(response.headers().getContentType());
return response.writeTo(exchange, new ServerResponse.Context() {
@Override
public List<HttpMessageWriter<?>> messageWriters() {
return AbstractErrorWebExceptionHandler.this.messageWriters;
}
@Override
public List<ViewResolver> viewResolvers() {
return AbstractErrorWebExceptionHandler.this.viewResolvers;
}
});
});
}
}

View File

@@ -0,0 +1,127 @@
/*
* Copyright 2012-2017 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
*
* http://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.error;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindingResult;
import org.springframework.validation.ObjectError;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.server.ServerWebExchange;
/**
* Default implementation of {@link ErrorAttributes}. Provides the following attributes
* when possible:
* <ul>
* <li>timestamp - The time that the errors were extracted</li>
* <li>status - The status code</li>
* <li>error - The error reason</li>
* <li>exception - The class name of the root exception (if configured)</li>
* <li>message - The exception message</li>
* <li>errors - Any {@link ObjectError}s from a {@link BindingResult} exception
* <li>trace - The exception stack trace</li>
* <li>path - The URL path when the exception was raised</li>
* </ul>
*
* @author Brian Clozel
* @since 2.0.0
* @see ErrorAttributes
*/
public class DefaultErrorAttributes implements ErrorAttributes {
private static final String ERROR_ATTRIBUTE = DefaultErrorAttributes.class.getName()
+ ".ERROR";
private final boolean includeException;
/**
* Create a new {@link DefaultErrorAttributes} instance that does not include the
* "exception" attribute.
*/
public DefaultErrorAttributes() {
this(false);
}
/**
* Create a new {@link DefaultErrorAttributes} instance.
* @param includeException whether to include the "exception" attribute
*/
public DefaultErrorAttributes(boolean includeException) {
this.includeException = includeException;
}
@Override
public Map<String, Object> getErrorAttributes(ServerRequest request, boolean includeStackTrace) {
Map<String, Object> errorAttributes = new LinkedHashMap<>();
errorAttributes.put("timestamp", new Date());
errorAttributes.put("path", request.path());
Throwable error = getError(request);
if (this.includeException) {
errorAttributes.put("exception", error.getClass().getName());
}
if (includeStackTrace) {
addStackTrace(errorAttributes, error);
}
addErrorMessage(errorAttributes, error);
if (error instanceof ResponseStatusException) {
HttpStatus errorStatus = ((ResponseStatusException) error).getStatus();
errorAttributes.put("status", errorStatus.value());
errorAttributes.put("error", errorStatus.getReasonPhrase());
}
else {
errorAttributes.put("status", HttpStatus.INTERNAL_SERVER_ERROR.value());
errorAttributes.put("error", HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase());
}
return errorAttributes;
}
private void addStackTrace(Map<String, Object> errorAttributes, Throwable error) {
StringWriter stackTrace = new StringWriter();
error.printStackTrace(new PrintWriter(stackTrace));
stackTrace.flush();
errorAttributes.put("trace", stackTrace.toString());
}
private void addErrorMessage(Map<String, Object> errorAttributes, Throwable error) {
errorAttributes.put("message", error.getMessage());
if (error instanceof BindingResult) {
BindingResult result = (BindingResult) error;
if (result.getErrorCount() > 0) {
errorAttributes.put("errors", result.getAllErrors());
}
}
}
@Override
public Throwable getError(ServerRequest request) {
return (Throwable) request.attribute(ERROR_ATTRIBUTE)
.orElseThrow(() -> new IllegalStateException("Missing exception attribute in ServerWebExchange"));
}
@Override
public void storeErrorInformation(Throwable error, ServerWebExchange exchange) {
exchange.getAttributes().putIfAbsent(ERROR_ATTRIBUTE, error);
}
}

View File

@@ -0,0 +1,186 @@
/*
* Copyright 2012-2017 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
*
* http://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.error;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import org.springframework.boot.autoconfigure.web.ErrorProperties;
import org.springframework.boot.autoconfigure.web.ResourceProperties;
import org.springframework.context.ApplicationContext;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.server.RequestPredicate;
import org.springframework.web.reactive.function.server.RequestPredicates;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.RouterFunctions;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
/**
* Basic global {@link org.springframework.web.server.WebExceptionHandler},
* rendering {@link ErrorAttributes}.
*
* <p>More specific errors can be handled either using Spring WebFlux abstractions
* (e.g. {@code @ExceptionHandler} with the annotation model) or by adding
* {@link RouterFunction} to the chain.
*
* <p>This implementation will render error as HTML views if the client explicitly
* supports that media type. It attempts to resolve error views
* using well known conventions. Will search for templates and static assets under
* {@code '/error'} using the {@link HttpStatus status code} and the
* {@link HttpStatus#series() status series}.
* <p>
* For example, an {@code HTTP 404} will search (in the specific order):
* <ul>
* <li>{@code '/<templates>/error/404.<ext>'}</li>
* <li>{@code '/<static>/error/404.html'}</li>
* <li>{@code '/<templates>/error/4xx.<ext>'}</li>
* <li>{@code '/<static>/error/4xx.html'}</li>
* <li>{@code '/<templates>/error/error'}</li>
* <li>{@code '/<static>/error/error.html'}</li>
* </ul>
*
* <p>If none found, a default "Whitelabel Error" HTML view will be rendered.
*
* <p>If the client doesn't support HTML, the error information will be rendered
* as a JSON payload.
*
* @author Brian Clozel
* @since 2.0.0
*/
public class DefaultErrorWebExceptionHandler extends AbstractErrorWebExceptionHandler {
private static final Map<HttpStatus.Series, String> SERIES_VIEWS;
static {
Map<HttpStatus.Series, String> views = new HashMap<>();
views.put(HttpStatus.Series.CLIENT_ERROR, "4xx");
views.put(HttpStatus.Series.SERVER_ERROR, "5xx");
SERIES_VIEWS = Collections.unmodifiableMap(views);
}
private final ErrorProperties errorProperties;
/**
* Create a new {@code DefaultErrorWebExceptionHandler} instance.
*
* @param errorAttributes the error attributes
* @param resourceProperties the resources configuration properties
* @param errorProperties the error configuration properties
* @param applicationContext the current application context
*/
public DefaultErrorWebExceptionHandler(ErrorAttributes errorAttributes,
ResourceProperties resourceProperties, ErrorProperties errorProperties,
ApplicationContext applicationContext) {
super(errorAttributes, resourceProperties, applicationContext);
this.errorProperties = errorProperties;
}
@Override
protected RouterFunction<ServerResponse> getRoutingFunction(ErrorAttributes errorAttributes) {
return RouterFunctions
.route(acceptsTextHtml(), this::renderErrorView)
.andRoute(RequestPredicates.all(), this::renderErrorResponse);
}
/**
* Render the error information as an HTML view.
* @param request the current request
* @return a {@code Publisher} of the HTTP response
*/
protected Mono<ServerResponse> renderErrorView(ServerRequest request) {
boolean includeStackTrace = isIncludeStackTrace(request, MediaType.TEXT_HTML);
Map<String, Object> error = getErrorAttributes(request, includeStackTrace);
HttpStatus errorStatus = getHttpStatus(error);
ServerResponse.BodyBuilder response = ServerResponse.status(errorStatus)
.contentType(MediaType.TEXT_HTML);
return Flux.just("error/" + errorStatus.toString(),
"error/" + SERIES_VIEWS.get(errorStatus.series()),
"error/error")
.flatMap(viewName -> renderErrorView(viewName, response, error))
.switchIfEmpty(renderDefaultErrorView(response, error))
.next();
}
/**
* Render the error information as a JSON payload.
* @param request the current request
* @return a {@code Publisher} of the HTTP response
*/
protected Mono<ServerResponse> renderErrorResponse(ServerRequest request) {
boolean includeStackTrace = isIncludeStackTrace(request, MediaType.ALL);
Map<String, Object> error = getErrorAttributes(request, includeStackTrace);
return ServerResponse.status(getHttpStatus(error))
.contentType(MediaType.APPLICATION_JSON_UTF8)
.body(BodyInserters.fromObject(error));
}
/**
* Determine if the stacktrace attribute should be included.
* @param request the source request
* @param produces the media type produced (or {@code MediaType.ALL})
* @return if the stacktrace attribute should be included
*/
protected boolean isIncludeStackTrace(ServerRequest request,
MediaType produces) {
ErrorProperties.IncludeStacktrace include = this.errorProperties.getIncludeStacktrace();
if (include == ErrorProperties.IncludeStacktrace.ALWAYS) {
return true;
}
if (include == ErrorProperties.IncludeStacktrace.ON_TRACE_PARAM) {
return isTraceEnabled(request);
}
return false;
}
/**
* Get the HTTP error status information from the error map.
* @param errorAttributes the current error information
* @return the error HTTP status
*/
protected HttpStatus getHttpStatus(Map<String, Object> errorAttributes) {
int statusCode = (int) errorAttributes.get("status");
return HttpStatus.valueOf(statusCode);
}
/**
* Predicate that checks whether the current request
* explicitly support {@code "text/html"} media type.
* <p>The "match-all" media type is not considered here.
* @return the request predicate
*/
protected RequestPredicate acceptsTextHtml() {
return serverRequest -> {
List<MediaType> acceptedMediaTypes = serverRequest.headers().accept();
acceptedMediaTypes.remove(MediaType.ALL);
MediaType.sortBySpecificityAndQuality(acceptedMediaTypes);
return acceptedMediaTypes.stream().anyMatch(MediaType.TEXT_HTML::isCompatibleWith);
};
}
}

View File

@@ -0,0 +1,58 @@
/*
* Copyright 2012-2017 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
*
* http://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.error;
import java.util.Map;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ServerWebExchange;
/**
* Provides access to error attributes which can be logged or presented to the user.
*
* @author Brian Clozel
* @since 2.0
* @see DefaultErrorAttributes
*/
public interface ErrorAttributes {
/**
* Return a {@link Map} of the error attributes. The map can be used as the model of
* an error page, or returned as a {@link ServerResponse} body.
* @param request the source request
* @param includeStackTrace if stack trace elements should be included
* @return a map of error attributes
*/
Map<String, Object> getErrorAttributes(ServerRequest request, boolean includeStackTrace);
/**
* Return the underlying cause of the error or {@code null} if the error
* cannot be extracted.
* @param request the source ServerRequest
* @return the {@link Exception} that caused the error or {@code null}
*/
Throwable getError(ServerRequest request);
/**
* Store the given error information in the current {@link ServerWebExchange}.
* @param error the {@link Exception} that caused the error
* @param exchange the source exchange
*/
void storeErrorInformation(Throwable error, ServerWebExchange exchange);
}

View File

@@ -0,0 +1,30 @@
/*
* Copyright 2012-2017 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
*
* http://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.error;
import org.springframework.web.server.WebExceptionHandler;
/**
* Marker interface that indicates that a {@link WebExceptionHandler}
* is used to render errors.
*
* @author Brian Clozel
* @since 2.0.0
*/
public interface ErrorWebExceptionHandler extends WebExceptionHandler {
}

View File

@@ -0,0 +1,96 @@
/*
* Copyright 2012-2017 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
*
* http://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.error;
import java.util.Collections;
import java.util.List;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.AutoConfigureBefore;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.autoconfigure.condition.SearchStrategy;
import org.springframework.boot.autoconfigure.web.ResourceProperties;
import org.springframework.boot.autoconfigure.web.ServerProperties;
import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.codec.ServerCodecConfigurer;
import org.springframework.web.reactive.config.WebFluxConfigurer;
import org.springframework.web.reactive.result.view.ViewResolver;
/**
* {@link EnableAutoConfiguration Auto-configuration} to render errors via a
* WebFlux {@link org.springframework.web.server.WebExceptionHandler}.
*
* @author Brian Clozel
* @since 2.0.0
*/
@Configuration
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE)
@ConditionalOnClass(WebFluxConfigurer.class)
@AutoConfigureBefore(WebFluxAutoConfiguration.class)
@EnableConfigurationProperties({ServerProperties.class, ResourceProperties.class})
public class ErrorWebFluxAutoConfiguration {
private final ServerProperties serverProperties;
private final ApplicationContext applicationContext;
private final ResourceProperties resourceProperties;
private final List<ViewResolver> viewResolvers;
private final ServerCodecConfigurer serverCodecConfigurer;
public ErrorWebFluxAutoConfiguration(ServerProperties serverProperties,
ResourceProperties resourceProperties,
ObjectProvider<List<ViewResolver>> viewResolversProvider,
ServerCodecConfigurer serverCodecConfigurer,
ApplicationContext applicationContext) {
this.serverProperties = serverProperties;
this.applicationContext = applicationContext;
this.resourceProperties = resourceProperties;
this.viewResolvers = viewResolversProvider.getIfAvailable(() -> Collections.emptyList());
this.serverCodecConfigurer = serverCodecConfigurer;
}
@Bean
@ConditionalOnMissingBean(value = ErrorWebExceptionHandler.class, search = SearchStrategy.CURRENT)
@Order(-1)
public ErrorWebExceptionHandler errorWebExceptionHandler(ErrorAttributes errorAttributes) {
DefaultErrorWebExceptionHandler exceptionHandler =
new DefaultErrorWebExceptionHandler(errorAttributes, this.resourceProperties,
this.serverProperties.getError(), this.applicationContext);
exceptionHandler.setViewResolvers(this.viewResolvers);
exceptionHandler.setMessageWriters(this.serverCodecConfigurer.getWriters());
exceptionHandler.setMessageReaders(this.serverCodecConfigurer.getReaders());
return exceptionHandler;
}
@Bean
@ConditionalOnMissingBean(value = ErrorAttributes.class, search = SearchStrategy.CURRENT)
public DefaultErrorAttributes errorAttributes() {
return new DefaultErrorAttributes(this.serverProperties.getError().isIncludeException());
}
}

View File

@@ -0,0 +1,20 @@
/*
* Copyright 2012-2017 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
*
* http://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.
*/
/**
* Auto-configuration for for Spring WebFlux error handling.
*/
package org.springframework.boot.autoconfigure.web.reactive.error;

View File

@@ -51,6 +51,7 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.web.server.ErrorPage;
import org.springframework.boot.web.server.ErrorPageRegistrar;
import org.springframework.boot.web.server.ErrorPageRegistry;
import org.springframework.boot.web.server.WebServerFactoryCustomizer;
import org.springframework.boot.web.servlet.error.DefaultErrorAttributes;
import org.springframework.boot.web.servlet.error.ErrorAttributes;
import org.springframework.boot.web.servlet.error.ErrorController;
@@ -302,7 +303,7 @@ public class ErrorMvcAutoConfiguration {
}
/**
* {@link ServletWebServerFactoryCustomizer} that configures the server's error pages.
* {@link WebServerFactoryCustomizer} that configures the server's error pages.
*/
private static class ErrorPageCustomizer implements ErrorPageRegistrar, Ordered {

View File

@@ -116,6 +116,7 @@ org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration,
org.springframework.boot.autoconfigure.web.reactive.HttpHandlerAutoConfiguration,\
org.springframework.boot.autoconfigure.web.reactive.ReactiveWebServerAutoConfiguration,\
org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration,\
org.springframework.boot.autoconfigure.web.reactive.error.ErrorWebFluxAutoConfiguration,\
org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration,\
org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration,\
org.springframework.boot.autoconfigure.web.servlet.ServletWebServerFactoryAutoConfiguration,\

View File

@@ -0,0 +1,180 @@
/*
* Copyright 2012-2017 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
*
* http://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.error;
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.ExpectedException;
import org.springframework.core.MethodParameter;
import org.springframework.http.HttpStatus;
import org.springframework.http.codec.HttpMessageReader;
import org.springframework.http.codec.ServerCodecConfigurer;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
import org.springframework.validation.BindingResult;
import org.springframework.validation.MapBindingResult;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.support.WebExchangeBindException;
import org.springframework.web.reactive.function.server.ServerRequest;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.server.ServerWebExchange;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tests for {@link DefaultErrorAttributes}.
*
* @author Brian Clozel
*/
public class DefaultErrorAttributesTests {
private static final ResponseStatusException NOT_FOUND = new ResponseStatusException(HttpStatus.NOT_FOUND);
@Rule
public ExpectedException thrown = ExpectedException.none();
private DefaultErrorAttributes errorAttributes = new DefaultErrorAttributes();
private List<HttpMessageReader<?>> readers = ServerCodecConfigurer.create().getReaders();
@Test
public void missingExceptionAttribute() throws Exception {
this.thrown.expect(IllegalStateException.class);
this.thrown.expectMessage("Missing exception attribute in ServerWebExchange");
MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/test").build());
ServerRequest request = ServerRequest.create(exchange, this.readers);
this.errorAttributes.getErrorAttributes(request, false);
}
@Test
public void includeTimeStamp() throws Exception {
MockServerHttpRequest request = MockServerHttpRequest.get("/test").build();
Map<String, Object> attributes = this.errorAttributes
.getErrorAttributes(buildServerRequest(request, NOT_FOUND), false);
assertThat(attributes.get("timestamp")).isInstanceOf(Date.class);
}
@Test
public void defaultStatusCode() throws Exception {
Error error = new OutOfMemoryError("Test error");
MockServerHttpRequest request = MockServerHttpRequest.get("/test").build();
Map<String, Object> attributes = this.errorAttributes
.getErrorAttributes(buildServerRequest(request, error), false);
assertThat(attributes.get("error"))
.isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase());
assertThat(attributes.get("status")).isEqualTo(500);
}
@Test
public void includeStatusCode() throws Exception {
MockServerHttpRequest request = MockServerHttpRequest.get("/test").build();
Map<String, Object> attributes = this.errorAttributes
.getErrorAttributes(buildServerRequest(request, NOT_FOUND), false);
assertThat(attributes.get("error"))
.isEqualTo(HttpStatus.NOT_FOUND.getReasonPhrase());
assertThat(attributes.get("status")).isEqualTo(404);
}
@Test
public void getError() throws Exception {
Error error = new OutOfMemoryError("Test error");
MockServerHttpRequest request = MockServerHttpRequest.get("/test").build();
ServerRequest serverRequest = buildServerRequest(request, error);
Map<String, Object> attributes = this.errorAttributes
.getErrorAttributes(serverRequest, false);
assertThat(this.errorAttributes.getError(serverRequest)).isSameAs(error);
assertThat(attributes.get("exception")).isNull();
assertThat(attributes.get("message")).isEqualTo("Test error");
}
@Test
public void includeException() throws Exception {
RuntimeException error = new RuntimeException("Test");
this.errorAttributes = new DefaultErrorAttributes(true);
MockServerHttpRequest request = MockServerHttpRequest.get("/test").build();
ServerRequest serverRequest = buildServerRequest(request, error);
Map<String, Object> attributes = this.errorAttributes
.getErrorAttributes(serverRequest, false);
assertThat(this.errorAttributes.getError(serverRequest)).isSameAs(error);
assertThat(attributes.get("exception")).isEqualTo(RuntimeException.class.getName());
assertThat(attributes.get("message")).isEqualTo("Test");
}
@Test
public void notIncludeTrace() throws Exception {
RuntimeException ex = new RuntimeException("Test");
MockServerHttpRequest request = MockServerHttpRequest.get("/test").build();
Map<String, Object> attributes = this.errorAttributes
.getErrorAttributes(buildServerRequest(request, ex), false);
assertThat(attributes.get("trace")).isNull();
}
@Test
public void includeTrace() throws Exception {
RuntimeException ex = new RuntimeException("Test");
MockServerHttpRequest request = MockServerHttpRequest.get("/test").build();
Map<String, Object> attributes = this.errorAttributes
.getErrorAttributes(buildServerRequest(request, ex), true);
assertThat(attributes.get("trace").toString()).startsWith("java.lang");
}
@Test
public void includePath() throws Exception {
MockServerHttpRequest request = MockServerHttpRequest.get("/test").build();
Map<String, Object> attributes = this.errorAttributes
.getErrorAttributes(buildServerRequest(request, NOT_FOUND), false);
assertThat(attributes.get("path")).isEqualTo("/test");
}
@Test
public void extractBindingResultErrors() throws Exception {
Method method = getClass().getMethod("method", String.class);
MethodParameter stringParam = new MethodParameter(method, 0);
BindingResult bindingResult = new MapBindingResult(
Collections.singletonMap("a", "b"), "objectName");
bindingResult.addError(new ObjectError("c", "d"));
Exception ex = new WebExchangeBindException(stringParam, bindingResult);
MockServerHttpRequest request = MockServerHttpRequest.get("/test").build();
Map<String, Object> attributes = this.errorAttributes
.getErrorAttributes(buildServerRequest(request, ex), false);
assertThat(attributes.get("message"))
.asString().startsWith("Validation failed for argument at index 0 in method: " +
"public int org.springframework.boot.autoconfigure.web.reactive.error.DefaultErrorAttributesTests" +
".method(java.lang.String), with 1 error(s)");
assertThat(attributes.get("errors")).isEqualTo(bindingResult.getAllErrors());
}
private ServerRequest buildServerRequest(MockServerHttpRequest request, Throwable error) {
ServerWebExchange exchange = MockServerWebExchange.from(request);
this.errorAttributes.storeErrorInformation(error, exchange);
return ServerRequest.create(exchange, this.readers);
}
public int method(String firstParam) {
return 42;
}
}

View File

@@ -0,0 +1,236 @@
/*
* Copyright 2012-2017 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
*
* http://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.error;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import javax.validation.Valid;
import org.junit.After;
import org.junit.Test;
import reactor.core.publisher.Mono;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.WebApplicationType;
import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration;
import org.springframework.boot.autoconfigure.mustache.MustacheAutoConfiguration;
import org.springframework.boot.autoconfigure.web.reactive.HttpHandlerAutoConfiguration;
import org.springframework.boot.autoconfigure.web.reactive.ReactiveWebServerAutoConfiguration;
import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Integration tests for {@link DefaultErrorWebExceptionHandler}
*
* @author Brian Clozel
*/
public class DefaultErrorWebExceptionHandlerIntegrationTest {
private ConfigurableApplicationContext context;
private WebTestClient webTestClient;
@After
public void closeContext() {
if (this.context != null) {
this.context.close();
}
}
@Test
public void jsonError() throws Exception {
load();
this.webTestClient.get().uri("/").exchange()
.expectStatus().isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR)
.expectBody().jsonPath("status").isEqualTo("500")
.jsonPath("error").isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase())
.jsonPath("path").isEqualTo(("/"))
.jsonPath("message").isEqualTo("Expected!")
.jsonPath("exception").doesNotExist()
.jsonPath("trace").doesNotExist();
}
@Test
public void notFound() throws Exception {
load();
this.webTestClient.get().uri("/notFound").exchange()
.expectStatus().isEqualTo(HttpStatus.NOT_FOUND)
.expectBody().jsonPath("status").isEqualTo("404")
.jsonPath("error").isEqualTo(HttpStatus.NOT_FOUND.getReasonPhrase())
.jsonPath("path").isEqualTo(("/notFound"))
.jsonPath("exception").doesNotExist();
}
@Test
public void htmlError() throws Exception {
load();
String body = this.webTestClient.get().uri("/").accept(MediaType.TEXT_HTML).exchange()
.expectStatus().isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR)
.expectHeader().contentType(MediaType.TEXT_HTML)
.expectBody(String.class).returnResult().getResponseBody();
assertThat(body).contains("status: 500").contains("message: Expected!");
}
@Test
public void bindingResultError() throws Exception {
load();
this.webTestClient.post().uri("/bind")
.contentType(MediaType.APPLICATION_JSON)
.syncBody("{}")
.exchange()
.expectStatus().isEqualTo(HttpStatus.BAD_REQUEST)
.expectBody().jsonPath("status").isEqualTo("400")
.jsonPath("error").isEqualTo(HttpStatus.BAD_REQUEST.getReasonPhrase())
.jsonPath("path").isEqualTo(("/bind"))
.jsonPath("exception").doesNotExist()
.jsonPath("errors").isArray()
.jsonPath("message").isNotEmpty();
}
@Test
public void includeStackTraceOnParam() throws Exception {
load("--server.error.include-exception=true",
"--server.error.include-stacktrace=on-trace-param");
this.webTestClient.get().uri("/?trace=true").exchange()
.expectStatus().isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR)
.expectBody().jsonPath("status").isEqualTo("500")
.jsonPath("error").isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase())
.jsonPath("exception").isEqualTo(IllegalStateException.class.getName())
.jsonPath("trace").exists();
}
@Test
public void alwaysIncludeStackTrace() throws Exception {
load("--server.error.include-exception=true",
"--server.error.include-stacktrace=always");
this.webTestClient.get().uri("/?trace=false").exchange()
.expectStatus().isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR)
.expectBody().jsonPath("status").isEqualTo("500")
.jsonPath("error").isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase())
.jsonPath("exception").isEqualTo(IllegalStateException.class.getName())
.jsonPath("trace").exists();
}
@Test
public void neverIncludeStackTrace() throws Exception {
load("--server.error.include-exception=true",
"--server.error.include-stacktrace=never");
this.webTestClient.get().uri("/?trace=true").exchange()
.expectStatus().isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR)
.expectBody().jsonPath("status").isEqualTo("500")
.jsonPath("error").isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase())
.jsonPath("exception").isEqualTo(IllegalStateException.class.getName())
.jsonPath("trace").doesNotExist();
}
@Test
public void statusException() throws Exception {
load("--server.error.include-exception=true");
this.webTestClient.get().uri("/badRequest").exchange()
.expectStatus().isEqualTo(HttpStatus.BAD_REQUEST)
.expectBody().jsonPath("status").isEqualTo("400")
.jsonPath("error").isEqualTo(HttpStatus.BAD_REQUEST.getReasonPhrase())
.jsonPath("exception").isEqualTo(ResponseStatusException.class.getName());
}
@Test
public void defaultErrorView() throws Exception {
load("--spring.mustache.prefix=classpath:/unknown/");
String body = this.webTestClient.get().uri("/").accept(MediaType.TEXT_HTML).exchange()
.expectStatus().isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR)
.expectHeader().contentType(MediaType.TEXT_HTML)
.expectBody(String.class).returnResult().getResponseBody();
assertThat(body).contains("Whitelabel Error Page").contains("<div>Expected!</div>");
}
private void load(String... arguments) {
List<String> args = new ArrayList<>();
args.add("--server.port=0");
if (arguments != null) {
args.addAll(Arrays.asList(arguments));
}
SpringApplication application = new SpringApplication(Application.class);
application.setWebApplicationType(WebApplicationType.REACTIVE);
this.context = application.run(args.toArray(new String[args.size()]));
this.webTestClient = WebTestClient.bindToApplicationContext(this.context).build();
}
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import({ReactiveWebServerAutoConfiguration.class, HttpHandlerAutoConfiguration.class,
WebFluxAutoConfiguration.class, ErrorWebFluxAutoConfiguration.class,
PropertyPlaceholderAutoConfiguration.class})
private @interface MinimalWebConfiguration {
}
@Configuration
@MinimalWebConfiguration
@Import(MustacheAutoConfiguration.class)
public static class Application {
public static void main(String[] args) {
SpringApplication application = new SpringApplication(Application.class);
application.setWebApplicationType(WebApplicationType.REACTIVE);
application.run(args);
}
@RestController
protected static class ErrorController {
@GetMapping("/")
public String home() {
throw new IllegalStateException("Expected!");
}
@GetMapping("/badRequest")
public Mono<String> badRequest() {
return Mono.error(new ResponseStatusException(HttpStatus.BAD_REQUEST));
}
@PostMapping(path = "/bind", produces = "application/json")
@ResponseBody
public String bodyValidation(@Valid @RequestBody DummyBody body) {
return body.getContent();
}
}
}
}

View File

@@ -0,0 +1,34 @@
/*
* Copyright 2012-2017 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
*
* http://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.error;
import javax.validation.constraints.NotNull;
public class DummyBody {
@NotNull
private String content;
public String getContent() {
return this.content;
}
public void setContent(String content) {
this.content = content;
}
}

View File

@@ -0,0 +1,8 @@
<html>
<body>
<ul>
<li>status: {{status}}</li>
<li>message: {{message}}</li>
</ul>
</body>
</html>