Merge branch '3.2.x'

Closes gh-41141
This commit is contained in:
Phillip Webb 2024-06-17 16:51:53 -07:00
commit 8a9feb0bb6
9 changed files with 199 additions and 59 deletions

View File

@ -35,6 +35,7 @@ import org.springframework.context.ApplicationContext;
import org.springframework.http.HttpStatus;
import org.springframework.http.InvalidMediaTypeException;
import org.springframework.http.MediaType;
import org.springframework.util.Assert;
import org.springframework.util.MimeTypeUtils;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.server.RequestPredicate;
@ -91,6 +92,8 @@ public class DefaultErrorWebExceptionHandler extends AbstractErrorWebExceptionHa
SERIES_VIEWS = Collections.unmodifiableMap(views);
}
private static final ErrorAttributeOptions ONLY_STATUS = ErrorAttributeOptions.of(Include.STATUS);
private final ErrorProperties errorProperties;
/**
@ -118,13 +121,13 @@ public class DefaultErrorWebExceptionHandler extends AbstractErrorWebExceptionHa
* @return a {@code Publisher} of the HTTP response
*/
protected Mono<ServerResponse> renderErrorView(ServerRequest request) {
Map<String, Object> error = getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.TEXT_HTML));
int errorStatus = getHttpStatus(error);
ServerResponse.BodyBuilder responseBody = ServerResponse.status(errorStatus).contentType(TEXT_HTML_UTF8);
return Flux.just(getData(errorStatus).toArray(new String[] {}))
.flatMap((viewName) -> renderErrorView(viewName, responseBody, error))
int status = getHttpStatus(getErrorAttributes(request, ONLY_STATUS));
Map<String, Object> errorAttributes = getErrorAttributes(request, MediaType.TEXT_HTML);
ServerResponse.BodyBuilder responseBody = ServerResponse.status(status).contentType(TEXT_HTML_UTF8);
return Flux.just(getData(status).toArray(new String[] {}))
.flatMap((viewName) -> renderErrorView(viewName, responseBody, errorAttributes))
.switchIfEmpty(this.errorProperties.getWhitelabel().isEnabled()
? renderDefaultErrorView(responseBody, error) : Mono.error(getError(request)))
? renderDefaultErrorView(responseBody, errorAttributes) : Mono.error(getError(request)))
.next();
}
@ -145,10 +148,15 @@ public class DefaultErrorWebExceptionHandler extends AbstractErrorWebExceptionHa
* @return a {@code Publisher} of the HTTP response
*/
protected Mono<ServerResponse> renderErrorResponse(ServerRequest request) {
Map<String, Object> error = getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.ALL));
return ServerResponse.status(getHttpStatus(error))
int status = getHttpStatus(getErrorAttributes(request, ONLY_STATUS));
Map<String, Object> errorAttributes = getErrorAttributes(request, MediaType.ALL);
return ServerResponse.status(status)
.contentType(MediaType.APPLICATION_JSON)
.body(BodyInserters.fromValue(error));
.body(BodyInserters.fromValue(errorAttributes));
}
private Map<String, Object> getErrorAttributes(ServerRequest request, MediaType mediaType) {
return getErrorAttributes(request, getErrorAttributeOptions(request, mediaType));
}
protected ErrorAttributeOptions getErrorAttributeOptions(ServerRequest request, MediaType mediaType) {
@ -232,7 +240,9 @@ public class DefaultErrorWebExceptionHandler extends AbstractErrorWebExceptionHa
* @return the error HTTP status
*/
protected int getHttpStatus(Map<String, Object> errorAttributes) {
return (int) errorAttributes.get("status");
Object status = errorAttributes.get("status");
Assert.state(status instanceof Integer, "ErrorAttributes must contain a status integer");
return (int) status;
}
/**

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2024 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.
@ -25,9 +25,12 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import reactor.core.publisher.Mono;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration;
import org.springframework.boot.autoconfigure.mustache.MustacheAutoConfiguration;
import org.springframework.boot.autoconfigure.web.ServerProperties;
import org.springframework.boot.autoconfigure.web.WebProperties;
import org.springframework.boot.autoconfigure.web.reactive.HttpHandlerAutoConfiguration;
import org.springframework.boot.autoconfigure.web.reactive.ReactiveWebServerFactoryAutoConfiguration;
import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration;
@ -36,12 +39,17 @@ import org.springframework.boot.test.context.runner.ReactiveWebApplicationContex
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.boot.web.error.ErrorAttributeOptions;
import org.springframework.boot.web.error.ErrorAttributeOptions.Include;
import org.springframework.boot.web.reactive.error.DefaultErrorAttributes;
import org.springframework.boot.web.reactive.error.ErrorAttributes;
import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler;
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.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerCodecConfigurer;
import org.springframework.test.web.reactive.server.HttpHandlerConnector.FailureAfterResponseCompletedException;
import org.springframework.test.web.reactive.server.WebTestClient;
import org.springframework.web.bind.annotation.GetMapping;
@ -50,6 +58,7 @@ 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.reactive.function.server.ServerRequest;
import org.springframework.web.reactive.result.view.ViewResolver;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
@ -573,6 +582,21 @@ class DefaultErrorWebExceptionHandlerIntegrationTests {
});
}
@Test
void customErrorWebExceptionHandlerWithoutStatus() {
this.contextRunner.withUserConfiguration(CustomErrorWebExceptionHandlerWithoutStatus.class).run((context) -> {
WebTestClient client = getWebClient(context);
client.get()
.uri("/badRequest")
.exchange()
.expectStatus()
.isBadRequest()
.expectBody()
.jsonPath("status")
.doesNotExist();
});
}
private String getErrorTemplatesLocation() {
String packageName = getClass().getPackage().getName();
return "classpath:/" + packageName.replace('.', '/') + "/templates/";
@ -675,4 +699,29 @@ class DefaultErrorWebExceptionHandlerIntegrationTests {
}
static class CustomErrorWebExceptionHandlerWithoutStatus {
@Bean
@Order(-1)
ErrorWebExceptionHandler errorWebExceptionHandler(ServerProperties serverProperties,
ErrorAttributes errorAttributes, WebProperties webProperties,
ObjectProvider<ViewResolver> viewResolvers, ServerCodecConfigurer serverCodecConfigurer,
ApplicationContext applicationContext) {
DefaultErrorWebExceptionHandler exceptionHandler = new DefaultErrorWebExceptionHandler(errorAttributes,
webProperties.getResources(), serverProperties.getError(), applicationContext) {
@Override
protected ErrorAttributeOptions getErrorAttributeOptions(ServerRequest request, MediaType mediaType) {
return super.getErrorAttributeOptions(request, mediaType).excluding(Include.STATUS, Include.ERROR);
}
};
exceptionHandler.setViewResolvers(viewResolvers.orderedStream().toList());
exceptionHandler.setMessageWriters(serverCodecConfigurer.getWriters());
exceptionHandler.setMessageReaders(serverCodecConfigurer.getReaders());
return exceptionHandler;
}
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2024 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.
@ -18,7 +18,6 @@ package org.springframework.boot.autoconfigure.web.reactive.error;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import reactor.core.publisher.Mono;
@ -55,7 +54,7 @@ class DefaultErrorWebExceptionHandlerTests {
@Test
void nonStandardErrorStatusCodeShouldNotFail() {
ErrorAttributes errorAttributes = mock(ErrorAttributes.class);
given(errorAttributes.getErrorAttributes(any(), any())).willReturn(getErrorAttributes());
given(errorAttributes.getErrorAttributes(any(), any())).willReturn(Collections.singletonMap("status", 498));
Resources resourceProperties = new Resources();
ErrorProperties errorProperties = new ErrorProperties();
ApplicationContext context = new AnnotationConfigReactiveWebApplicationContext();
@ -67,10 +66,6 @@ class DefaultErrorWebExceptionHandlerTests {
exceptionHandler.handle(exchange, new RuntimeException()).block();
}
private Map<String, Object> getErrorAttributes() {
return Collections.singletonMap("status", 498);
}
private void setupViewResolver(DefaultErrorWebExceptionHandler exceptionHandler) {
View view = mock(View.class);
given(view.render(any(), any(), any())).willReturn(Mono.empty());

View File

@ -35,15 +35,20 @@ import jakarta.validation.constraints.NotNull;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration;
import org.springframework.boot.autoconfigure.freemarker.FreeMarkerAutoConfiguration;
import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration;
import org.springframework.boot.autoconfigure.web.ServerProperties;
import org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration;
import org.springframework.boot.autoconfigure.web.servlet.ServletWebServerFactoryAutoConfiguration;
import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.error.ErrorAttributeOptions;
import org.springframework.boot.web.error.ErrorAttributeOptions.Include;
import org.springframework.boot.web.servlet.error.ErrorAttributes;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ -343,6 +348,18 @@ class BasicErrorControllerIntegrationTests {
assertThat(entity.getBody()).isNull();
}
@Test
@SuppressWarnings({ "rawtypes", "unchecked" })
void customErrorControllerWithoutStatusConfiguration() {
load(CustomErrorControllerWithoutStatusConfiguration.class);
RequestEntity request = RequestEntity.post(URI.create(createUrl("/bodyValidation")))
.accept(MediaType.APPLICATION_JSON)
.contentType(MediaType.APPLICATION_JSON)
.body("{}");
ResponseEntity<Map> entity = new TestRestTemplate().exchange(request, Map.class);
assertThat(entity.getBody()).doesNotContainKey("status");
}
private void assertErrorAttributes(Map<?, ?> content, String status, String error, Class<?> exception,
String message, String path) {
assertThat(content.get("status")).as("Wrong status").hasToString(status);
@ -363,12 +380,16 @@ class BasicErrorControllerIntegrationTests {
}
private void load(String... arguments) {
load(TestConfiguration.class, arguments);
}
private void load(Class<?> configuration, String... arguments) {
List<String> args = new ArrayList<>();
args.add("--server.port=0");
if (arguments != null) {
args.addAll(Arrays.asList(arguments));
}
this.context = SpringApplication.run(TestConfiguration.class, StringUtils.toStringArray(args));
this.context = SpringApplication.run(configuration, StringUtils.toStringArray(args));
}
@Target(ElementType.TYPE)
@ -394,11 +415,13 @@ class BasicErrorControllerIntegrationTests {
@Bean
View error() {
return new AbstractView() {
@Override
protected void renderMergedOutputModel(Map<String, Object> model, HttpServletRequest request,
HttpServletResponse response) throws Exception {
response.getWriter().write("ERROR_BEAN");
}
};
}
@ -498,4 +521,23 @@ class BasicErrorControllerIntegrationTests {
}
static class CustomErrorControllerWithoutStatusConfiguration extends TestConfiguration {
@Bean
BasicErrorController basicErrorController(ServerProperties serverProperties, ErrorAttributes errorAttributes,
ObjectProvider<ErrorViewResolver> errorViewResolvers) {
return new BasicErrorController(errorAttributes, serverProperties.getError(),
errorViewResolvers.orderedStream().toList()) {
@Override
protected ErrorAttributeOptions getErrorAttributeOptions(HttpServletRequest request,
MediaType mediaType) {
return super.getErrorAttributeOptions(request, mediaType).excluding(Include.STATUS);
}
};
}
}
}

View File

@ -20,6 +20,7 @@ import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.Map;
import java.util.Set;
/**
@ -79,6 +80,19 @@ public final class ErrorAttributeOptions {
return new ErrorAttributeOptions(Collections.unmodifiableSet(updated));
}
/**
* Remove elements from the given map if they are not included in this set of options.
* @param map the map to update
* @since 3.2.7
*/
public void retainIncluded(Map<String, Object> map) {
for (Include candidate : Include.values()) {
if (!this.includes.contains(candidate)) {
map.remove(candidate.key);
}
}
}
private EnumSet<Include> copyIncludes() {
return (this.includes.isEmpty()) ? EnumSet.noneOf(Include.class) : EnumSet.copyOf(this.includes);
}
@ -88,7 +102,7 @@ public final class ErrorAttributeOptions {
* @return an {@code ErrorAttributeOptions}
*/
public static ErrorAttributeOptions defaults() {
return of(Include.PATH);
return of(Include.PATH, Include.STATUS, Include.ERROR);
}
/**
@ -120,28 +134,46 @@ public final class ErrorAttributeOptions {
/**
* Include the exception class name attribute.
*/
EXCEPTION,
EXCEPTION("exception"),
/**
* Include the stack trace attribute.
*/
STACK_TRACE,
STACK_TRACE("trace"),
/**
* Include the message attribute.
*/
MESSAGE,
MESSAGE("message"),
/**
* Include the binding errors attribute.
*/
BINDING_ERRORS,
BINDING_ERRORS("errors"),
/**
* Include the HTTP status code.
* @since 3.2.7
*/
STATUS("status"),
/**
* Include the HTTP status code.
* @since 3.2.7
*/
ERROR("error"),
/**
* Include the request path.
* @since 3.3.0
*/
PATH
PATH("path");
private final String key;
Include(String key) {
this.key = key;
}
}

View File

@ -71,21 +71,7 @@ public class DefaultErrorAttributes implements ErrorAttributes {
@Override
public Map<String, Object> getErrorAttributes(ServerRequest request, ErrorAttributeOptions options) {
Map<String, Object> errorAttributes = getErrorAttributes(request, options.isIncluded(Include.STACK_TRACE));
if (!options.isIncluded(Include.EXCEPTION)) {
errorAttributes.remove("exception");
}
if (!options.isIncluded(Include.STACK_TRACE)) {
errorAttributes.remove("trace");
}
if (!options.isIncluded(Include.MESSAGE) && errorAttributes.get("message") != null) {
errorAttributes.remove("message");
}
if (!options.isIncluded(Include.BINDING_ERRORS)) {
errorAttributes.remove("errors");
}
if (!options.isIncluded(Include.PATH)) {
errorAttributes.remove("path");
}
options.retainIncluded(errorAttributes);
return errorAttributes;
}

View File

@ -92,21 +92,7 @@ public class DefaultErrorAttributes implements ErrorAttributes, HandlerException
@Override
public Map<String, Object> getErrorAttributes(WebRequest webRequest, ErrorAttributeOptions options) {
Map<String, Object> errorAttributes = getErrorAttributes(webRequest, options.isIncluded(Include.STACK_TRACE));
if (!options.isIncluded(Include.EXCEPTION)) {
errorAttributes.remove("exception");
}
if (!options.isIncluded(Include.STACK_TRACE)) {
errorAttributes.remove("trace");
}
if (!options.isIncluded(Include.MESSAGE) && errorAttributes.get("message") != null) {
errorAttributes.remove("message");
}
if (!options.isIncluded(Include.BINDING_ERRORS)) {
errorAttributes.remove("errors");
}
if (!options.isIncluded(Include.PATH)) {
errorAttributes.remove("path");
}
options.retainIncluded(errorAttributes);
return errorAttributes;
}

View File

@ -107,7 +107,7 @@ class DefaultErrorAttributesTests {
Exception error = new CustomException("Test Message");
MockServerHttpRequest request = MockServerHttpRequest.get("/test").build();
Map<String, Object> attributes = this.errorAttributes.getErrorAttributes(buildServerRequest(request, error),
ErrorAttributeOptions.of(Include.MESSAGE));
ErrorAttributeOptions.of(Include.MESSAGE, Include.STATUS, Include.ERROR));
assertThat(attributes).containsEntry("error", HttpStatus.I_AM_A_TEAPOT.getReasonPhrase());
assertThat(attributes).containsEntry("message", "Test Message");
assertThat(attributes).containsEntry("status", HttpStatus.I_AM_A_TEAPOT.value());
@ -118,7 +118,7 @@ class DefaultErrorAttributesTests {
Exception error = new Custom2Exception();
MockServerHttpRequest request = MockServerHttpRequest.get("/test").build();
Map<String, Object> attributes = this.errorAttributes.getErrorAttributes(buildServerRequest(request, error),
ErrorAttributeOptions.of(Include.MESSAGE));
ErrorAttributeOptions.of(Include.MESSAGE, Include.STATUS, Include.ERROR));
assertThat(attributes).containsEntry("error", HttpStatus.I_AM_A_TEAPOT.getReasonPhrase());
assertThat(attributes).containsEntry("status", HttpStatus.I_AM_A_TEAPOT.value());
assertThat(attributes).containsEntry("message", "Nope!");
@ -177,7 +177,7 @@ class DefaultErrorAttributesTests {
MockServerHttpRequest request = MockServerHttpRequest.get("/test").build();
ServerRequest serverRequest = buildServerRequest(request, error);
Map<String, Object> attributes = this.errorAttributes.getErrorAttributes(serverRequest,
ErrorAttributeOptions.of(Include.EXCEPTION, Include.MESSAGE));
ErrorAttributeOptions.of(Include.EXCEPTION, Include.MESSAGE, Include.STATUS));
assertThat(attributes).containsEntry("status", 400);
assertThat(attributes).containsEntry("message", "invalid request");
assertThat(attributes).containsEntry("exception", RuntimeException.class.getName());
@ -192,7 +192,7 @@ class DefaultErrorAttributesTests {
MockServerHttpRequest request = MockServerHttpRequest.get("/test").build();
ServerRequest serverRequest = buildServerRequest(request, error);
Map<String, Object> attributes = this.errorAttributes.getErrorAttributes(serverRequest,
ErrorAttributeOptions.of(Include.EXCEPTION, Include.MESSAGE));
ErrorAttributeOptions.of(Include.EXCEPTION, Include.MESSAGE, Include.STATUS));
assertThat(attributes).containsEntry("status", 406);
assertThat(attributes).containsEntry("message", "could not process request");
assertThat(attributes).containsEntry("exception", ResponseStatusException.class.getName());
@ -308,6 +308,30 @@ class DefaultErrorAttributesTests {
assertThat(attributes).doesNotContainKey("errors");
}
@Test
void excludeStatus() {
ResponseStatusException error = new ResponseStatusException(HttpStatus.NOT_ACCEPTABLE,
"could not process request");
this.errorAttributes = new DefaultErrorAttributes();
MockServerHttpRequest request = MockServerHttpRequest.get("/test").build();
ServerRequest serverRequest = buildServerRequest(request, error);
Map<String, Object> attributes = this.errorAttributes.getErrorAttributes(serverRequest,
ErrorAttributeOptions.defaults().excluding(Include.STATUS));
assertThat(attributes).doesNotContainKey("status");
}
@Test
void excludeError() {
ResponseStatusException error = new ResponseStatusException(HttpStatus.NOT_ACCEPTABLE,
"could not process request");
this.errorAttributes = new DefaultErrorAttributes();
MockServerHttpRequest request = MockServerHttpRequest.get("/test").build();
ServerRequest serverRequest = buildServerRequest(request, error);
Map<String, Object> attributes = this.errorAttributes.getErrorAttributes(serverRequest,
ErrorAttributeOptions.defaults().excluding(Include.ERROR));
assertThat(attributes).doesNotContainKey("error");
}
private ServerRequest buildServerRequest(MockServerHttpRequest request, Throwable error) {
ServerWebExchange exchange = MockServerWebExchange.from(request);
this.errorAttributes.storeErrorInformation(error, exchange);

View File

@ -311,4 +311,20 @@ class DefaultErrorAttributesTests {
assertThat(attributes).containsEntry("message", "custom message");
}
@Test
void excludeStatus() {
this.request.setAttribute("jakarta.servlet.error.status_code", 404);
Map<String, Object> attributes = this.errorAttributes.getErrorAttributes(this.webRequest,
ErrorAttributeOptions.defaults().excluding(Include.STATUS));
assertThat(attributes).doesNotContainKey("status");
}
@Test
void excludeError() {
this.request.setAttribute("jakarta.servlet.error.status_code", 404);
Map<String, Object> attributes = this.errorAttributes.getErrorAttributes(this.webRequest,
ErrorAttributeOptions.defaults().excluding(Include.ERROR));
assertThat(attributes).doesNotContainKey("error");
}
}