From 68db43cf44223de3887683e9bdd49d8e5b7be2c5 Mon Sep 17 00:00:00 2001 From: Brian Clozel Date: Mon, 16 Oct 2017 21:55:05 +0200 Subject: [PATCH] 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 --- .../reactive/WebFluxAutoConfiguration.java | 2 +- .../AbstractErrorWebExceptionHandler.java | 224 +++++++++++++++++ .../error/DefaultErrorAttributes.java | 127 ++++++++++ .../DefaultErrorWebExceptionHandler.java | 186 ++++++++++++++ .../web/reactive/error/ErrorAttributes.java | 58 +++++ .../error/ErrorWebExceptionHandler.java | 30 +++ .../error/ErrorWebFluxAutoConfiguration.java | 96 +++++++ .../web/reactive/error/package-info.java | 20 ++ .../error/ErrorMvcAutoConfiguration.java | 3 +- .../main/resources/META-INF/spring.factories | 1 + .../error/DefaultErrorAttributesTests.java | 180 +++++++++++++ ...rorWebExceptionHandlerIntegrationTest.java | 236 ++++++++++++++++++ .../web/reactive/error/DummyBody.java | 34 +++ .../resources/templates/error/error.mustache | 8 + .../main/asciidoc/spring-boot-features.adoc | 95 ++++++- 15 files changed, 1292 insertions(+), 8 deletions(-) create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/AbstractErrorWebExceptionHandler.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/DefaultErrorAttributes.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/DefaultErrorWebExceptionHandler.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/ErrorAttributes.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/ErrorWebExceptionHandler.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/ErrorWebFluxAutoConfiguration.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/package-info.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/error/DefaultErrorAttributesTests.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/error/DefaultErrorWebExceptionHandlerIntegrationTest.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/error/DummyBody.java create mode 100644 spring-boot-project/spring-boot-autoconfigure/src/test/resources/templates/error/error.mustache diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfiguration.java index 95d48ea61b..d26f0ea6ae 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfiguration.java @@ -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); } } diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/AbstractErrorWebExceptionHandler.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/AbstractErrorWebExceptionHandler.java new file mode 100644 index 0000000000..47a04fbf2f --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/AbstractErrorWebExceptionHandler.java @@ -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> messageReaders = Collections.emptyList(); + + private List> messageWriters = Collections.emptyList(); + + private List 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> 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> 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 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 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 renderErrorView(String viewName, ServerResponse.BodyBuilder responseBody, + Map 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". + *

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 renderDefaultErrorView(ServerResponse.BodyBuilder responseBody, + Map error) { + StringBuilder builder = new StringBuilder(); + Date timestamp = (Date) error.get("timestamp"); + builder.append("

Whitelabel Error Page

") + .append("

This application has no configured error view, so you are seeing this as a fallback.

") + .append("
").append(timestamp.toString()).append("
") + .append("
There was an unexpected error (type=") + .append(error.get("error")).append(", status=").append(error.get("status")) + .append(").
") + .append("
").append(error.get("message")).append("
") + .append(""); + 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. + *

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 getRoutingFunction(ErrorAttributes errorAttributes); + + @Override + public Mono 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> messageWriters() { + return AbstractErrorWebExceptionHandler.this.messageWriters; + } + + @Override + public List viewResolvers() { + return AbstractErrorWebExceptionHandler.this.viewResolvers; + } + }); + }); + } +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/DefaultErrorAttributes.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/DefaultErrorAttributes.java new file mode 100644 index 0000000000..9915e6fc81 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/DefaultErrorAttributes.java @@ -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: + *

    + *
  • timestamp - The time that the errors were extracted
  • + *
  • status - The status code
  • + *
  • error - The error reason
  • + *
  • exception - The class name of the root exception (if configured)
  • + *
  • message - The exception message
  • + *
  • errors - Any {@link ObjectError}s from a {@link BindingResult} exception + *
  • trace - The exception stack trace
  • + *
  • path - The URL path when the exception was raised
  • + *
+ * + * @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 getErrorAttributes(ServerRequest request, boolean includeStackTrace) { + Map 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 errorAttributes, Throwable error) { + StringWriter stackTrace = new StringWriter(); + error.printStackTrace(new PrintWriter(stackTrace)); + stackTrace.flush(); + errorAttributes.put("trace", stackTrace.toString()); + } + + private void addErrorMessage(Map 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); + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/DefaultErrorWebExceptionHandler.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/DefaultErrorWebExceptionHandler.java new file mode 100644 index 0000000000..a53faa266a --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/DefaultErrorWebExceptionHandler.java @@ -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}. + * + *

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. + * + *

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}. + *

+ * For example, an {@code HTTP 404} will search (in the specific order): + *

    + *
  • {@code '//error/404.'}
  • + *
  • {@code '//error/404.html'}
  • + *
  • {@code '//error/4xx.'}
  • + *
  • {@code '//error/4xx.html'}
  • + *
  • {@code '//error/error'}
  • + *
  • {@code '//error/error.html'}
  • + *
+ * + *

If none found, a default "Whitelabel Error" HTML view will be rendered. + * + *

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 SERIES_VIEWS; + + static { + Map 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 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 renderErrorView(ServerRequest request) { + boolean includeStackTrace = isIncludeStackTrace(request, MediaType.TEXT_HTML); + Map 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 renderErrorResponse(ServerRequest request) { + boolean includeStackTrace = isIncludeStackTrace(request, MediaType.ALL); + Map 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 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. + *

The "match-all" media type is not considered here. + * @return the request predicate + */ + protected RequestPredicate acceptsTextHtml() { + return serverRequest -> { + List acceptedMediaTypes = serverRequest.headers().accept(); + acceptedMediaTypes.remove(MediaType.ALL); + MediaType.sortBySpecificityAndQuality(acceptedMediaTypes); + return acceptedMediaTypes.stream().anyMatch(MediaType.TEXT_HTML::isCompatibleWith); + }; + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/ErrorAttributes.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/ErrorAttributes.java new file mode 100644 index 0000000000..fcedd1a025 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/ErrorAttributes.java @@ -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 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); + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/ErrorWebExceptionHandler.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/ErrorWebExceptionHandler.java new file mode 100644 index 0000000000..ba21018d77 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/ErrorWebExceptionHandler.java @@ -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 { + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/ErrorWebFluxAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/ErrorWebFluxAutoConfiguration.java new file mode 100644 index 0000000000..9b50a057b5 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/ErrorWebFluxAutoConfiguration.java @@ -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 viewResolvers; + + private final ServerCodecConfigurer serverCodecConfigurer; + + public ErrorWebFluxAutoConfiguration(ServerProperties serverProperties, + ResourceProperties resourceProperties, + ObjectProvider> 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()); + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/package-info.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/package-info.java new file mode 100644 index 0000000000..51d0a240b0 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/error/package-info.java @@ -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; diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/error/ErrorMvcAutoConfiguration.java b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/error/ErrorMvcAutoConfiguration.java index 13b63e703a..128a00e068 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/error/ErrorMvcAutoConfiguration.java +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/error/ErrorMvcAutoConfiguration.java @@ -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 { diff --git a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories index 8f9d4a390b..d33686187a 100644 --- a/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories +++ b/spring-boot-project/spring-boot-autoconfigure/src/main/resources/META-INF/spring.factories @@ -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,\ diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/error/DefaultErrorAttributesTests.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/error/DefaultErrorAttributesTests.java new file mode 100644 index 0000000000..644215a80f --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/error/DefaultErrorAttributesTests.java @@ -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> 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 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 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 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 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 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 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 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 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 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; + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/error/DefaultErrorWebExceptionHandlerIntegrationTest.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/error/DefaultErrorWebExceptionHandlerIntegrationTest.java new file mode 100644 index 0000000000..e608cd66a5 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/error/DefaultErrorWebExceptionHandlerIntegrationTest.java @@ -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("

Expected!
"); + } + + private void load(String... arguments) { + List 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 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(); + } + } + + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/error/DummyBody.java b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/error/DummyBody.java new file mode 100644 index 0000000000..eacc562f09 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/error/DummyBody.java @@ -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; + } + +} diff --git a/spring-boot-project/spring-boot-autoconfigure/src/test/resources/templates/error/error.mustache b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/templates/error/error.mustache new file mode 100644 index 0000000000..42eec03b12 --- /dev/null +++ b/spring-boot-project/spring-boot-autoconfigure/src/test/resources/templates/error/error.mustache @@ -0,0 +1,8 @@ + + +
    +
  • status: {{status}}
  • +
  • message: {{message}}
  • +
+ + \ No newline at end of file diff --git a/spring-boot-project/spring-boot-docs/src/main/asciidoc/spring-boot-features.adoc b/spring-boot-project/spring-boot-docs/src/main/asciidoc/spring-boot-features.adoc index 1fc81b29cb..a3ccc5e075 100644 --- a/spring-boot-project/spring-boot-docs/src/main/asciidoc/spring-boot-features.adoc +++ b/spring-boot-project/spring-boot-docs/src/main/asciidoc/spring-boot-features.adoc @@ -2242,7 +2242,7 @@ method: -[[boot-features-spring-webflux]] +[[boot-features-webflux]] === The '`Spring WebFlux framework`' Spring WebFlux is the new reactive web framework introduced in Spring Framework 5.0. @@ -2325,7 +2325,7 @@ behavior has been chosen because many Spring developers will add -[[boot-features-spring-webflux-auto-configuration]] +[[boot-features-webflux-auto-configuration]] ==== Spring WebFlux auto-configuration Spring Boot provides auto-configuration for Spring WebFlux that works well with most applications. @@ -2345,7 +2345,7 @@ If you want to take complete control of Spring WebFlux, you can add your own -[[boot-features-spring-webflux-httpcodecs]] +[[boot-features-webflux-httpcodecs]] ==== HTTP codecs with HttpMessageReaders and HttpMessageWriters Spring WebFlux uses the `HttpMessageReader` and `HttpMessageWriter` interface to convert HTTP requests and responses. They are configured with `CodecConfigurer` with sensible @@ -2378,7 +2378,7 @@ You can also leverage <>). + +The first step to customizing this feature is often about using the existing mechanism +but replacing or augmenting the error contents. For that, you can simply add a bean +of type `ErrorAttributes`. + +To change the error handling behavior, you can implement `ErrorWebExceptionHandler` and +register a bean definition of that type; because a `WebExceptionHandler` is quite +low-level, Spring Boot also provides a convenient `AbstractErrorWebExceptionHandler` +to let you handle errors in a WebFlux functional way: + +[source,java,indent=0,subs="verbatim,quotes,attributes"] +---- + public class CustomErrorWebExceptionHandler extends AbstractErrorWebExceptionHandler { + + // Define constructor here + + @Override + protected RouterFunction getRoutingFunction(ErrorAttributes errorAttributes) { + + return RouterFunctions + .route(aPredicate, aHandler) + .andRoute(anotherPredicate, anotherHandler); + } + + } +---- + +For a more complete picture, you can also subclass `DefaultErrorWebExceptionHandler` +directly and override specific methods. + + + +[[boot-features-webflux-error-handling-custom-error-pages]] +===== Custom error pages + +If you want to display a custom HTML error page for a given status code, you can add +a file to an `/error` folder. Error pages can either be static HTML (i.e. added under +any of the static resource folders) or built using templates. The name of the file +should be the exact status code or a series mask. + +For example, to map `404` to a static HTML file, your folder structure would look like +this: + +[source,indent=0,subs="verbatim,quotes,attributes"] +---- + src/ + +- main/ + +- java/ + | + + +- resources/ + +- public/ + +- error/ + | +- 404.html + +- +---- + +To map all `5xx` errors using a Mustache template, you'd have a structure like this: + +[source,indent=0,subs="verbatim,quotes,attributes"] +---- + src/ + +- main/ + +- java/ + | + + +- resources/ + +- templates/ + +- error/ + | +- 5xx.mustache + +- +---- + + + [[boot-features-jersey]] === JAX-RS and Jersey If you prefer the JAX-RS programming model for REST endpoints you can use one of the @@ -5030,7 +5113,7 @@ out-of-the-box experience. See the Spring Boot will create and pre-configure such a builder for you; for example, client HTTP codecs will be configured just like the server ones -(see <>). +(see <>). Here's a typical example: