Skip to content

Commit 68db43c

Browse files
committed
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
1 parent c4adb76 commit 68db43c

File tree

15 files changed

+1292
-8
lines changed

15 files changed

+1292
-8
lines changed

spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/reactive/WebFluxAutoConfiguration.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ public WebFluxConfig(ResourceProperties resourceProperties,
127127
@Override
128128
public void configureArgumentResolvers(ArgumentResolverConfigurer configurer) {
129129
if (this.argumentResolvers != null) {
130-
this.argumentResolvers.stream().forEach(configurer::addCustomResolver);
130+
this.argumentResolvers.forEach(configurer::addCustomResolver);
131131
}
132132
}
133133

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
/*
2+
* Copyright 2012-2017 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.autoconfigure.web.reactive.error;
18+
19+
import java.util.Collections;
20+
import java.util.Date;
21+
import java.util.List;
22+
import java.util.Map;
23+
24+
import reactor.core.publisher.Mono;
25+
26+
import org.springframework.beans.factory.InitializingBean;
27+
import org.springframework.boot.autoconfigure.template.TemplateAvailabilityProviders;
28+
import org.springframework.boot.autoconfigure.web.ResourceProperties;
29+
import org.springframework.context.ApplicationContext;
30+
import org.springframework.core.io.Resource;
31+
import org.springframework.http.codec.HttpMessageReader;
32+
import org.springframework.http.codec.HttpMessageWriter;
33+
import org.springframework.util.Assert;
34+
import org.springframework.util.CollectionUtils;
35+
import org.springframework.web.reactive.function.BodyInserters;
36+
import org.springframework.web.reactive.function.server.RouterFunction;
37+
import org.springframework.web.reactive.function.server.ServerRequest;
38+
import org.springframework.web.reactive.function.server.ServerResponse;
39+
import org.springframework.web.reactive.result.view.ViewResolver;
40+
import org.springframework.web.server.ServerWebExchange;
41+
42+
/**
43+
* Abstract base class for {@link ErrorWebExceptionHandler} implementations.
44+
*
45+
* @author Brian Clozel
46+
* @since 2.0.0
47+
* @see ErrorAttributes
48+
*/
49+
public abstract class AbstractErrorWebExceptionHandler implements ErrorWebExceptionHandler, InitializingBean {
50+
51+
private final ApplicationContext applicationContext;
52+
53+
private final ErrorAttributes errorAttributes;
54+
55+
private final ResourceProperties resourceProperties;
56+
57+
private final TemplateAvailabilityProviders templateAvailabilityProviders;
58+
59+
private List<HttpMessageReader<?>> messageReaders = Collections.emptyList();
60+
61+
private List<HttpMessageWriter<?>> messageWriters = Collections.emptyList();
62+
63+
private List<ViewResolver> viewResolvers = Collections.emptyList();
64+
65+
public AbstractErrorWebExceptionHandler(ErrorAttributes errorAttributes,
66+
ResourceProperties resourceProperties,
67+
ApplicationContext applicationContext) {
68+
Assert.notNull(errorAttributes, "ErrorAttributes must not be null");
69+
Assert.notNull(resourceProperties, "ResourceProperties must not be null");
70+
Assert.notNull(applicationContext, "ApplicationContext must not be null");
71+
this.errorAttributes = errorAttributes;
72+
this.resourceProperties = resourceProperties;
73+
this.applicationContext = applicationContext;
74+
this.templateAvailabilityProviders = new TemplateAvailabilityProviders(applicationContext);
75+
}
76+
77+
/**
78+
* Configure HTTP message writers to serialize the response body with.
79+
* @param messageWriters the {@link HttpMessageWriter}s to use
80+
*/
81+
public void setMessageWriters(List<HttpMessageWriter<?>> messageWriters) {
82+
Assert.notNull(messageWriters, "'messageWriters' must not be null");
83+
this.messageWriters = messageWriters;
84+
}
85+
86+
/**
87+
* Configure HTTP message readers to deserialize the request body with.
88+
* @param messageReaders the {@link HttpMessageReader}s to use
89+
*/
90+
public void setMessageReaders(List<HttpMessageReader<?>> messageReaders) {
91+
Assert.notNull(messageReaders, "'messageReaders' must not be null");
92+
this.messageReaders = messageReaders;
93+
}
94+
95+
/**
96+
* Configure the {@link ViewResolver} to use for rendering views.
97+
* @param viewResolvers the list of {@link ViewResolver}s to use
98+
*/
99+
public void setViewResolvers(List<ViewResolver> viewResolvers) {
100+
this.viewResolvers = viewResolvers;
101+
}
102+
103+
/**
104+
* Extract the error attributes from the current request, to be used
105+
* to populate error views or JSON payloads.
106+
* @param request the source request
107+
* @param includeStackTrace whether to include the error stacktrace information
108+
* @return the error attributes as a Map.
109+
*/
110+
protected Map<String, Object> getErrorAttributes(ServerRequest request, boolean includeStackTrace) {
111+
return this.errorAttributes.getErrorAttributes(request, includeStackTrace);
112+
}
113+
114+
protected boolean isTraceEnabled(ServerRequest request) {
115+
String parameter = request.queryParam("trace").orElse("false");
116+
return !"false".equals(parameter.toLowerCase());
117+
}
118+
119+
/**
120+
* Render the given error data as a view, using a template view if available
121+
* or a static HTML file if available otherwise. This will return an empty
122+
* {@code Publisher} if none of the above are available.
123+
* @param viewName the view name
124+
* @param responseBody the error response being built
125+
* @param error the error data as a map
126+
* @return a Publisher of the {@link ServerResponse}
127+
*/
128+
protected Mono<ServerResponse> renderErrorView(String viewName, ServerResponse.BodyBuilder responseBody,
129+
Map<String, Object> error) {
130+
if (isTemplateAvailable(viewName)) {
131+
return responseBody.render(viewName, error);
132+
}
133+
Resource resource = resolveResource(viewName);
134+
if (resource != null) {
135+
return responseBody.body(BodyInserters.fromResource(resource));
136+
}
137+
return Mono.empty();
138+
}
139+
140+
private boolean isTemplateAvailable(String viewName) {
141+
return this.templateAvailabilityProviders.getProvider(viewName, this.applicationContext) != null;
142+
}
143+
144+
private Resource resolveResource(String viewName) {
145+
for (String location : this.resourceProperties.getStaticLocations()) {
146+
try {
147+
Resource resource = this.applicationContext.getResource(location);
148+
resource = resource.createRelative(viewName + ".html");
149+
if (resource.exists()) {
150+
return resource;
151+
}
152+
}
153+
catch (Exception ex) {
154+
}
155+
}
156+
return null;
157+
}
158+
159+
/**
160+
* Render a default HTML "Whitelabel Error Page".
161+
* <p>Useful when no other error view is available in the application.
162+
* @param responseBody the error response being built
163+
* @param error the error data as a map
164+
* @return a Publisher of the {@link ServerResponse}
165+
*/
166+
protected Mono<ServerResponse> renderDefaultErrorView(ServerResponse.BodyBuilder responseBody,
167+
Map<String, Object> error) {
168+
StringBuilder builder = new StringBuilder();
169+
Date timestamp = (Date) error.get("timestamp");
170+
builder.append("<html><body><h1>Whitelabel Error Page</h1>")
171+
.append("<p>This application has no configured error view, so you are seeing this as a fallback.</p>")
172+
.append("<div id='created'>").append(timestamp.toString()).append("</div>")
173+
.append("<div>There was an unexpected error (type=")
174+
.append(error.get("error")).append(", status=").append(error.get("status"))
175+
.append(").</div>")
176+
.append("<div>").append(error.get("message")).append("</div>")
177+
.append("</body></html>");
178+
return responseBody.syncBody(builder.toString());
179+
}
180+
181+
@Override
182+
public void afterPropertiesSet() throws Exception {
183+
if (CollectionUtils.isEmpty(this.messageWriters)) {
184+
throw new IllegalArgumentException("Property 'messageWriters' is required");
185+
}
186+
}
187+
188+
/**
189+
* Create a {@link RouterFunction} that can route and handle errors as JSON responses
190+
* or HTML views.
191+
* <p>If the returned {@link RouterFunction} doesn't route to a {@code HandlerFunction},
192+
* the original exception is propagated in the pipeline and can be processed by other
193+
* {@link org.springframework.web.server.WebExceptionHandler}s.
194+
* @param errorAttributes the {@code ErrorAttributes} instance to use to extract error information
195+
* @return a {@link RouterFunction} that routes and handles errors
196+
*/
197+
protected abstract RouterFunction<ServerResponse> getRoutingFunction(ErrorAttributes errorAttributes);
198+
199+
@Override
200+
public Mono<Void> handle(ServerWebExchange exchange, Throwable throwable) {
201+
202+
this.errorAttributes.storeErrorInformation(throwable, exchange);
203+
ServerRequest request = ServerRequest.create(exchange, this.messageReaders);
204+
return getRoutingFunction(this.errorAttributes)
205+
.route(request)
206+
.switchIfEmpty(Mono.error(throwable))
207+
.flatMap(handler -> handler.handle(request))
208+
.flatMap(response -> {
209+
// force content-type since writeTo won't overwrite response header values
210+
exchange.getResponse().getHeaders().setContentType(response.headers().getContentType());
211+
return response.writeTo(exchange, new ServerResponse.Context() {
212+
@Override
213+
public List<HttpMessageWriter<?>> messageWriters() {
214+
return AbstractErrorWebExceptionHandler.this.messageWriters;
215+
}
216+
217+
@Override
218+
public List<ViewResolver> viewResolvers() {
219+
return AbstractErrorWebExceptionHandler.this.viewResolvers;
220+
}
221+
});
222+
});
223+
}
224+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/*
2+
* Copyright 2012-2017 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.autoconfigure.web.reactive.error;
18+
19+
import java.io.PrintWriter;
20+
import java.io.StringWriter;
21+
import java.util.Date;
22+
import java.util.LinkedHashMap;
23+
import java.util.Map;
24+
25+
import org.springframework.http.HttpStatus;
26+
import org.springframework.validation.BindingResult;
27+
import org.springframework.validation.ObjectError;
28+
import org.springframework.web.reactive.function.server.ServerRequest;
29+
import org.springframework.web.server.ResponseStatusException;
30+
import org.springframework.web.server.ServerWebExchange;
31+
32+
/**
33+
* Default implementation of {@link ErrorAttributes}. Provides the following attributes
34+
* when possible:
35+
* <ul>
36+
* <li>timestamp - The time that the errors were extracted</li>
37+
* <li>status - The status code</li>
38+
* <li>error - The error reason</li>
39+
* <li>exception - The class name of the root exception (if configured)</li>
40+
* <li>message - The exception message</li>
41+
* <li>errors - Any {@link ObjectError}s from a {@link BindingResult} exception
42+
* <li>trace - The exception stack trace</li>
43+
* <li>path - The URL path when the exception was raised</li>
44+
* </ul>
45+
*
46+
* @author Brian Clozel
47+
* @since 2.0.0
48+
* @see ErrorAttributes
49+
*/
50+
public class DefaultErrorAttributes implements ErrorAttributes {
51+
52+
private static final String ERROR_ATTRIBUTE = DefaultErrorAttributes.class.getName()
53+
+ ".ERROR";
54+
55+
private final boolean includeException;
56+
57+
/**
58+
* Create a new {@link DefaultErrorAttributes} instance that does not include the
59+
* "exception" attribute.
60+
*/
61+
public DefaultErrorAttributes() {
62+
this(false);
63+
}
64+
65+
/**
66+
* Create a new {@link DefaultErrorAttributes} instance.
67+
* @param includeException whether to include the "exception" attribute
68+
*/
69+
public DefaultErrorAttributes(boolean includeException) {
70+
this.includeException = includeException;
71+
}
72+
73+
@Override
74+
public Map<String, Object> getErrorAttributes(ServerRequest request, boolean includeStackTrace) {
75+
Map<String, Object> errorAttributes = new LinkedHashMap<>();
76+
errorAttributes.put("timestamp", new Date());
77+
errorAttributes.put("path", request.path());
78+
Throwable error = getError(request);
79+
if (this.includeException) {
80+
errorAttributes.put("exception", error.getClass().getName());
81+
}
82+
if (includeStackTrace) {
83+
addStackTrace(errorAttributes, error);
84+
}
85+
addErrorMessage(errorAttributes, error);
86+
if (error instanceof ResponseStatusException) {
87+
HttpStatus errorStatus = ((ResponseStatusException) error).getStatus();
88+
errorAttributes.put("status", errorStatus.value());
89+
errorAttributes.put("error", errorStatus.getReasonPhrase());
90+
}
91+
else {
92+
errorAttributes.put("status", HttpStatus.INTERNAL_SERVER_ERROR.value());
93+
errorAttributes.put("error", HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase());
94+
}
95+
return errorAttributes;
96+
}
97+
98+
private void addStackTrace(Map<String, Object> errorAttributes, Throwable error) {
99+
StringWriter stackTrace = new StringWriter();
100+
error.printStackTrace(new PrintWriter(stackTrace));
101+
stackTrace.flush();
102+
errorAttributes.put("trace", stackTrace.toString());
103+
}
104+
105+
private void addErrorMessage(Map<String, Object> errorAttributes, Throwable error) {
106+
errorAttributes.put("message", error.getMessage());
107+
if (error instanceof BindingResult) {
108+
BindingResult result = (BindingResult) error;
109+
if (result.getErrorCount() > 0) {
110+
errorAttributes.put("errors", result.getAllErrors());
111+
}
112+
}
113+
}
114+
115+
@Override
116+
public Throwable getError(ServerRequest request) {
117+
return (Throwable) request.attribute(ERROR_ATTRIBUTE)
118+
.orElseThrow(() -> new IllegalStateException("Missing exception attribute in ServerWebExchange"));
119+
}
120+
121+
@Override
122+
public void storeErrorInformation(Throwable error, ServerWebExchange exchange) {
123+
exchange.getAttributes().putIfAbsent(ERROR_ATTRIBUTE, error);
124+
125+
}
126+
127+
}

0 commit comments

Comments
 (0)