Skip to content

Commit 60b7e6c

Browse files
committed
Allow 'status' and 'error' to be excluded from error response
Update `ErrorAttributeOptions` to allow the `status` and `error` fields to be excluded from the response without throwing a NullPointerException. Fixes gh-30011
1 parent 1f698d8 commit 60b7e6c

File tree

9 files changed

+200
-54
lines changed

9 files changed

+200
-54
lines changed

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

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2023 the original author or authors.
2+
* Copyright 2012-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -35,6 +35,7 @@
3535
import org.springframework.http.HttpStatus;
3636
import org.springframework.http.InvalidMediaTypeException;
3737
import org.springframework.http.MediaType;
38+
import org.springframework.util.Assert;
3839
import org.springframework.util.MimeTypeUtils;
3940
import org.springframework.web.reactive.function.BodyInserters;
4041
import org.springframework.web.reactive.function.server.RequestPredicate;
@@ -90,6 +91,8 @@ public class DefaultErrorWebExceptionHandler extends AbstractErrorWebExceptionHa
9091
SERIES_VIEWS = Collections.unmodifiableMap(views);
9192
}
9293

94+
private static final ErrorAttributeOptions ONLY_STATUS = ErrorAttributeOptions.of(Include.STATUS);
95+
9396
private final ErrorProperties errorProperties;
9497

9598
/**
@@ -117,13 +120,13 @@ protected RouterFunction<ServerResponse> getRoutingFunction(ErrorAttributes erro
117120
* @return a {@code Publisher} of the HTTP response
118121
*/
119122
protected Mono<ServerResponse> renderErrorView(ServerRequest request) {
120-
Map<String, Object> error = getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.TEXT_HTML));
121-
int errorStatus = getHttpStatus(error);
122-
ServerResponse.BodyBuilder responseBody = ServerResponse.status(errorStatus).contentType(TEXT_HTML_UTF8);
123-
return Flux.just(getData(errorStatus).toArray(new String[] {}))
124-
.flatMap((viewName) -> renderErrorView(viewName, responseBody, error))
123+
int status = getHttpStatus(getErrorAttributes(request, ONLY_STATUS));
124+
Map<String, Object> errorAttributes = getErrorAttributes(request, MediaType.TEXT_HTML);
125+
ServerResponse.BodyBuilder responseBody = ServerResponse.status(status).contentType(TEXT_HTML_UTF8);
126+
return Flux.just(getData(status).toArray(new String[] {}))
127+
.flatMap((viewName) -> renderErrorView(viewName, responseBody, errorAttributes))
125128
.switchIfEmpty(this.errorProperties.getWhitelabel().isEnabled()
126-
? renderDefaultErrorView(responseBody, error) : Mono.error(getError(request)))
129+
? renderDefaultErrorView(responseBody, errorAttributes) : Mono.error(getError(request)))
127130
.next();
128131
}
129132

@@ -144,10 +147,15 @@ private List<String> getData(int errorStatus) {
144147
* @return a {@code Publisher} of the HTTP response
145148
*/
146149
protected Mono<ServerResponse> renderErrorResponse(ServerRequest request) {
147-
Map<String, Object> error = getErrorAttributes(request, getErrorAttributeOptions(request, MediaType.ALL));
148-
return ServerResponse.status(getHttpStatus(error))
150+
int status = getHttpStatus(getErrorAttributes(request, ONLY_STATUS));
151+
Map<String, Object> errorAttributes = getErrorAttributes(request, MediaType.ALL);
152+
return ServerResponse.status(status)
149153
.contentType(MediaType.APPLICATION_JSON)
150-
.body(BodyInserters.fromValue(error));
154+
.body(BodyInserters.fromValue(errorAttributes));
155+
}
156+
157+
private Map<String, Object> getErrorAttributes(ServerRequest request, MediaType mediaType) {
158+
return getErrorAttributes(request, getErrorAttributeOptions(request, mediaType));
151159
}
152160

153161
protected ErrorAttributeOptions getErrorAttributeOptions(ServerRequest request, MediaType mediaType) {
@@ -215,7 +223,9 @@ protected boolean isIncludeBindingErrors(ServerRequest request, MediaType produc
215223
* @return the error HTTP status
216224
*/
217225
protected int getHttpStatus(Map<String, Object> errorAttributes) {
218-
return (int) errorAttributes.get("status");
226+
Object status = errorAttributes.get("status");
227+
Assert.state(status instanceof Integer, "ErrorAttributes must contain a status integer");
228+
return (int) status;
219229
}
220230

221231
/**

spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/error/DefaultErrorWebExceptionHandlerIntegrationTests.java

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2023 the original author or authors.
2+
* Copyright 2012-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -25,9 +25,12 @@
2525
import org.junit.jupiter.api.extension.ExtendWith;
2626
import reactor.core.publisher.Mono;
2727

28+
import org.springframework.beans.factory.ObjectProvider;
2829
import org.springframework.boot.autoconfigure.AutoConfigurations;
2930
import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration;
3031
import org.springframework.boot.autoconfigure.mustache.MustacheAutoConfiguration;
32+
import org.springframework.boot.autoconfigure.web.ServerProperties;
33+
import org.springframework.boot.autoconfigure.web.WebProperties;
3134
import org.springframework.boot.autoconfigure.web.reactive.HttpHandlerAutoConfiguration;
3235
import org.springframework.boot.autoconfigure.web.reactive.ReactiveWebServerFactoryAutoConfiguration;
3336
import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration;
@@ -36,12 +39,17 @@
3639
import org.springframework.boot.test.system.CapturedOutput;
3740
import org.springframework.boot.test.system.OutputCaptureExtension;
3841
import org.springframework.boot.web.error.ErrorAttributeOptions;
42+
import org.springframework.boot.web.error.ErrorAttributeOptions.Include;
3943
import org.springframework.boot.web.reactive.error.DefaultErrorAttributes;
4044
import org.springframework.boot.web.reactive.error.ErrorAttributes;
45+
import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler;
46+
import org.springframework.context.ApplicationContext;
4147
import org.springframework.context.annotation.Bean;
4248
import org.springframework.context.annotation.Configuration;
49+
import org.springframework.core.annotation.Order;
4350
import org.springframework.http.HttpStatus;
4451
import org.springframework.http.MediaType;
52+
import org.springframework.http.codec.ServerCodecConfigurer;
4553
import org.springframework.test.web.reactive.server.HttpHandlerConnector.FailureAfterResponseCompletedException;
4654
import org.springframework.test.web.reactive.server.WebTestClient;
4755
import org.springframework.web.bind.annotation.GetMapping;
@@ -50,6 +58,7 @@
5058
import org.springframework.web.bind.annotation.ResponseBody;
5159
import org.springframework.web.bind.annotation.RestController;
5260
import org.springframework.web.reactive.function.server.ServerRequest;
61+
import org.springframework.web.reactive.result.view.ViewResolver;
5362
import org.springframework.web.server.ResponseStatusException;
5463
import org.springframework.web.server.ServerWebExchange;
5564
import org.springframework.web.server.WebFilter;
@@ -573,6 +582,21 @@ void defaultErrorAttributesSubclassWithoutDelegation() {
573582
});
574583
}
575584

585+
@Test
586+
void customErrorWebExceptionHandlerWithoutStatus() {
587+
this.contextRunner.withUserConfiguration(CustomErrorWebExceptionHandlerWithoutStatus.class).run((context) -> {
588+
WebTestClient client = getWebClient(context);
589+
client.get()
590+
.uri("/badRequest")
591+
.exchange()
592+
.expectStatus()
593+
.isBadRequest()
594+
.expectBody()
595+
.jsonPath("status")
596+
.doesNotExist();
597+
});
598+
}
599+
576600
private String getErrorTemplatesLocation() {
577601
String packageName = getClass().getPackage().getName();
578602
return "classpath:/" + packageName.replace('.', '/') + "/templates/";
@@ -675,4 +699,29 @@ public Map<String, Object> getErrorAttributes(ServerRequest request, ErrorAttrib
675699

676700
}
677701

702+
static class CustomErrorWebExceptionHandlerWithoutStatus {
703+
704+
@Bean
705+
@Order(-1)
706+
ErrorWebExceptionHandler errorWebExceptionHandler(ServerProperties serverProperties,
707+
ErrorAttributes errorAttributes, WebProperties webProperties,
708+
ObjectProvider<ViewResolver> viewResolvers, ServerCodecConfigurer serverCodecConfigurer,
709+
ApplicationContext applicationContext) {
710+
DefaultErrorWebExceptionHandler exceptionHandler = new DefaultErrorWebExceptionHandler(errorAttributes,
711+
webProperties.getResources(), serverProperties.getError(), applicationContext) {
712+
713+
@Override
714+
protected ErrorAttributeOptions getErrorAttributeOptions(ServerRequest request, MediaType mediaType) {
715+
return super.getErrorAttributeOptions(request, mediaType).excluding(Include.STATUS, Include.ERROR);
716+
}
717+
718+
};
719+
exceptionHandler.setViewResolvers(viewResolvers.orderedStream().toList());
720+
exceptionHandler.setMessageWriters(serverCodecConfigurer.getWriters());
721+
exceptionHandler.setMessageReaders(serverCodecConfigurer.getReaders());
722+
return exceptionHandler;
723+
}
724+
725+
}
726+
678727
}

spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/reactive/error/DefaultErrorWebExceptionHandlerTests.java

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2023 the original author or authors.
2+
* Copyright 2012-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -18,7 +18,6 @@
1818

1919
import java.util.Collections;
2020
import java.util.List;
21-
import java.util.Map;
2221

2322
import org.junit.jupiter.api.Test;
2423
import reactor.core.publisher.Mono;
@@ -55,7 +54,7 @@ class DefaultErrorWebExceptionHandlerTests {
5554
@Test
5655
void nonStandardErrorStatusCodeShouldNotFail() {
5756
ErrorAttributes errorAttributes = mock(ErrorAttributes.class);
58-
given(errorAttributes.getErrorAttributes(any(), any())).willReturn(getErrorAttributes());
57+
given(errorAttributes.getErrorAttributes(any(), any())).willReturn(Collections.singletonMap("status", 498));
5958
Resources resourceProperties = new Resources();
6059
ErrorProperties errorProperties = new ErrorProperties();
6160
ApplicationContext context = new AnnotationConfigReactiveWebApplicationContext();
@@ -67,10 +66,6 @@ void nonStandardErrorStatusCodeShouldNotFail() {
6766
exceptionHandler.handle(exchange, new RuntimeException()).block();
6867
}
6968

70-
private Map<String, Object> getErrorAttributes() {
71-
return Collections.singletonMap("status", 498);
72-
}
73-
7469
private void setupViewResolver(DefaultErrorWebExceptionHandler exceptionHandler) {
7570
View view = mock(View.class);
7671
given(view.render(any(), any(), any())).willReturn(Mono.empty());

spring-boot-project/spring-boot-autoconfigure/src/test/java/org/springframework/boot/autoconfigure/web/servlet/error/BasicErrorControllerIntegrationTests.java

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,20 @@
3535
import org.junit.jupiter.api.AfterEach;
3636
import org.junit.jupiter.api.Test;
3737

38+
import org.springframework.beans.factory.ObjectProvider;
3839
import org.springframework.boot.SpringApplication;
3940
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
4041
import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration;
4142
import org.springframework.boot.autoconfigure.freemarker.FreeMarkerAutoConfiguration;
4243
import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration;
44+
import org.springframework.boot.autoconfigure.web.ServerProperties;
4345
import org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration;
4446
import org.springframework.boot.autoconfigure.web.servlet.ServletWebServerFactoryAutoConfiguration;
4547
import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration;
4648
import org.springframework.boot.test.web.client.TestRestTemplate;
49+
import org.springframework.boot.web.error.ErrorAttributeOptions;
50+
import org.springframework.boot.web.error.ErrorAttributeOptions.Include;
51+
import org.springframework.boot.web.servlet.error.ErrorAttributes;
4752
import org.springframework.context.ConfigurableApplicationContext;
4853
import org.springframework.context.annotation.Bean;
4954
import org.springframework.context.annotation.Configuration;
@@ -343,6 +348,18 @@ void testIncompatibleMediaType() {
343348
assertThat(entity.getBody()).isNull();
344349
}
345350

351+
@Test
352+
@SuppressWarnings({ "rawtypes", "unchecked" })
353+
void customErrorControllerWithoutStatusConfiguration() {
354+
load(CustomErrorControllerWithoutStatusConfiguration.class);
355+
RequestEntity request = RequestEntity.post(URI.create(createUrl("/bodyValidation")))
356+
.accept(MediaType.APPLICATION_JSON)
357+
.contentType(MediaType.APPLICATION_JSON)
358+
.body("{}");
359+
ResponseEntity<Map> entity = new TestRestTemplate().exchange(request, Map.class);
360+
assertThat(entity.getBody()).doesNotContainKey("status");
361+
}
362+
346363
private void assertErrorAttributes(Map<?, ?> content, String status, String error, Class<?> exception,
347364
String message, String path) {
348365
assertThat(content.get("status")).as("Wrong status").hasToString(status);
@@ -363,12 +380,16 @@ private String createUrl(String path) {
363380
}
364381

365382
private void load(String... arguments) {
383+
load(TestConfiguration.class, arguments);
384+
}
385+
386+
private void load(Class<?> configuration, String... arguments) {
366387
List<String> args = new ArrayList<>();
367388
args.add("--server.port=0");
368389
if (arguments != null) {
369390
args.addAll(Arrays.asList(arguments));
370391
}
371-
this.context = SpringApplication.run(TestConfiguration.class, StringUtils.toStringArray(args));
392+
this.context = SpringApplication.run(configuration, StringUtils.toStringArray(args));
372393
}
373394

374395
@Target(ElementType.TYPE)
@@ -394,11 +415,13 @@ static void main(String[] args) {
394415
@Bean
395416
View error() {
396417
return new AbstractView() {
418+
397419
@Override
398420
protected void renderMergedOutputModel(Map<String, Object> model, HttpServletRequest request,
399421
HttpServletResponse response) throws Exception {
400422
response.getWriter().write("ERROR_BEAN");
401423
}
424+
402425
};
403426
}
404427

@@ -498,4 +521,23 @@ void setContent(String content) {
498521

499522
}
500523

524+
static class CustomErrorControllerWithoutStatusConfiguration extends TestConfiguration {
525+
526+
@Bean
527+
BasicErrorController basicErrorController(ServerProperties serverProperties, ErrorAttributes errorAttributes,
528+
ObjectProvider<ErrorViewResolver> errorViewResolvers) {
529+
return new BasicErrorController(errorAttributes, serverProperties.getError(),
530+
errorViewResolvers.orderedStream().toList()) {
531+
532+
@Override
533+
protected ErrorAttributeOptions getErrorAttributeOptions(HttpServletRequest request,
534+
MediaType mediaType) {
535+
return super.getErrorAttributeOptions(request, mediaType).excluding(Include.STATUS);
536+
}
537+
538+
};
539+
}
540+
541+
}
542+
501543
}

spring-boot-project/spring-boot/src/main/java/org/springframework/boot/web/error/ErrorAttributeOptions.java

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2012-2023 the original author or authors.
2+
* Copyright 2012-2024 the original author or authors.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -20,6 +20,7 @@
2020
import java.util.Collection;
2121
import java.util.Collections;
2222
import java.util.EnumSet;
23+
import java.util.Map;
2324
import java.util.Set;
2425

2526
/**
@@ -79,6 +80,19 @@ public ErrorAttributeOptions excluding(Include... excludes) {
7980
return new ErrorAttributeOptions(Collections.unmodifiableSet(updated));
8081
}
8182

83+
/**
84+
* Remove elements from the given map if they are not included in this set of options.
85+
* @param map the map to update
86+
* @since 3.2.7
87+
*/
88+
public void retainIncluded(Map<String, Object> map) {
89+
for (Include candidate : Include.values()) {
90+
if (!this.includes.contains(candidate)) {
91+
map.remove(candidate.key);
92+
}
93+
}
94+
}
95+
8296
private EnumSet<Include> copyIncludes() {
8397
return (this.includes.isEmpty()) ? EnumSet.noneOf(Include.class) : EnumSet.copyOf(this.includes);
8498
}
@@ -88,7 +102,7 @@ private EnumSet<Include> copyIncludes() {
88102
* @return an {@code ErrorAttributeOptions}
89103
*/
90104
public static ErrorAttributeOptions defaults() {
91-
return of();
105+
return of(Include.STATUS, Include.ERROR);
92106
}
93107

94108
/**
@@ -120,22 +134,40 @@ public enum Include {
120134
/**
121135
* Include the exception class name attribute.
122136
*/
123-
EXCEPTION,
137+
EXCEPTION("exception"),
124138

125139
/**
126140
* Include the stack trace attribute.
127141
*/
128-
STACK_TRACE,
142+
STACK_TRACE("trace"),
129143

130144
/**
131145
* Include the message attribute.
132146
*/
133-
MESSAGE,
147+
MESSAGE("message"),
134148

135149
/**
136150
* Include the binding errors attribute.
137151
*/
138-
BINDING_ERRORS
152+
BINDING_ERRORS("errors"),
153+
154+
/**
155+
* Include the HTTP status code.
156+
* @since 3.2.7
157+
*/
158+
STATUS("status"),
159+
160+
/**
161+
* Include the HTTP status code.
162+
* @since 3.2.7
163+
*/
164+
ERROR("error");
165+
166+
private final String key;
167+
168+
Include(String key) {
169+
this.key = key;
170+
}
139171

140172
}
141173

0 commit comments

Comments
 (0)