diff --git a/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc b/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc index 31eb04c2989..ecfbd49f82a 100644 --- a/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc +++ b/framework-docs/modules/ROOT/pages/integration/rest-clients.adoc @@ -938,7 +938,8 @@ method parameters: | `@RequestHeader` | Add a request header or multiple headers. The argument may be a `Map` or `MultiValueMap` with multiple headers, a `Collection` of values, or an - individual value. Type conversion is supported for non-String values. + individual value. Type conversion is supported for non-String values. This overrides + the annotation's `headers` attribute. | `@PathVariable` | Add a variable for expand a placeholder in the request URL. The argument may be a diff --git a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-requestmapping.adoc b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-requestmapping.adoc index 09b30a4a43c..884c91252dc 100644 --- a/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-requestmapping.adoc +++ b/framework-docs/modules/ROOT/pages/web/webflux/controller/ann-requestmapping.adoc @@ -606,3 +606,8 @@ subset of the method parameters that `@RequestMapping` does. Notably, it exclude server-side specific parameter types. For details, see the list for xref:integration/rest-clients.adoc#rest-http-interface-method-parameters[@HttpExchange] and xref:web/webflux/controller/ann-methods/arguments.adoc[@RequestMapping]. + +`@HttpExchange` also supports a `headers()` parameter which accepts `"name=value"`-like +pairs like in `@RequestMapping(headers={})` on the client side. On the server side, +this extends to the full syntax that +xref:#webflux-ann-requestmapping-params-and-headers[`@RequestMapping`] supports. diff --git a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-requestmapping.adoc b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-requestmapping.adoc index fe929fda35e..fe0519ba2f0 100644 --- a/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-requestmapping.adoc +++ b/framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-requestmapping.adoc @@ -652,3 +652,8 @@ subset of the method parameters that `@RequestMapping` does. Notably, it exclude server-side specific parameter types. For details, see the list for xref:integration/rest-clients.adoc#rest-http-interface-method-parameters[@HttpExchange] and xref:web/webmvc/mvc-controller/ann-methods/arguments.adoc[@RequestMapping]. + +`@HttpExchange` also supports a `headers()` parameter which accepts `"name=value"`-like +pairs like in `@RequestMapping(headers={})` on the client side. On the server side, +this extends to the full syntax that +xref:#mvc-ann-requestmapping-params-and-headers[`@RequestMapping`] supports. diff --git a/spring-web/src/main/java/org/springframework/web/service/annotation/DeleteExchange.java b/spring-web/src/main/java/org/springframework/web/service/annotation/DeleteExchange.java index 3409a5a78a8..055c752ea49 100644 --- a/spring-web/src/main/java/org/springframework/web/service/annotation/DeleteExchange.java +++ b/spring-web/src/main/java/org/springframework/web/service/annotation/DeleteExchange.java @@ -60,4 +60,10 @@ public @interface DeleteExchange { @AliasFor(annotation = HttpExchange.class) String[] accept() default {}; + /** + * Alias for {@link HttpExchange#headers()}. + */ + @AliasFor(annotation = HttpExchange.class) + String[] headers() default {}; + } diff --git a/spring-web/src/main/java/org/springframework/web/service/annotation/HttpExchange.java b/spring-web/src/main/java/org/springframework/web/service/annotation/HttpExchange.java index 4f3845ae095..15bce4e0f91 100644 --- a/spring-web/src/main/java/org/springframework/web/service/annotation/HttpExchange.java +++ b/spring-web/src/main/java/org/springframework/web/service/annotation/HttpExchange.java @@ -173,4 +173,16 @@ public @interface HttpExchange { */ String[] accept() default {}; + /** + * The additional headers to use, as an array of {@code name=value} pairs. + *

Multiple comma-separated values are accepted, and placeholders are + * supported in these values. However, Accept and Content-Type headers are + * ignored: see {@link #accept()} and {@link #contentType()}. + *

Supported at the type level as well as at the method level, in which + * case the method-level values override type-level values. + *

By default, this is empty. + * @since 6.2 + */ + String[] headers() default {}; + } diff --git a/spring-web/src/main/java/org/springframework/web/service/annotation/PatchExchange.java b/spring-web/src/main/java/org/springframework/web/service/annotation/PatchExchange.java index d84f8610152..8df55813d76 100644 --- a/spring-web/src/main/java/org/springframework/web/service/annotation/PatchExchange.java +++ b/spring-web/src/main/java/org/springframework/web/service/annotation/PatchExchange.java @@ -60,4 +60,10 @@ public @interface PatchExchange { @AliasFor(annotation = HttpExchange.class) String[] accept() default {}; + /** + * Alias for {@link HttpExchange#headers()}. + */ + @AliasFor(annotation = HttpExchange.class) + String[] headers() default {}; + } diff --git a/spring-web/src/main/java/org/springframework/web/service/annotation/PostExchange.java b/spring-web/src/main/java/org/springframework/web/service/annotation/PostExchange.java index 44f2c1d69a7..ee7fc689f6b 100644 --- a/spring-web/src/main/java/org/springframework/web/service/annotation/PostExchange.java +++ b/spring-web/src/main/java/org/springframework/web/service/annotation/PostExchange.java @@ -60,4 +60,10 @@ public @interface PostExchange { @AliasFor(annotation = HttpExchange.class) String[] accept() default {}; + /** + * Alias for {@link HttpExchange#headers()}. + */ + @AliasFor(annotation = HttpExchange.class) + String[] headers() default {}; + } diff --git a/spring-web/src/main/java/org/springframework/web/service/annotation/PutExchange.java b/spring-web/src/main/java/org/springframework/web/service/annotation/PutExchange.java index 7fa2d0a546d..e039e13f547 100644 --- a/spring-web/src/main/java/org/springframework/web/service/annotation/PutExchange.java +++ b/spring-web/src/main/java/org/springframework/web/service/annotation/PutExchange.java @@ -60,4 +60,10 @@ public @interface PutExchange { @AliasFor(annotation = HttpExchange.class) String[] accept() default {}; + /** + * Alias for {@link HttpExchange#headers()}. + */ + @AliasFor(annotation = HttpExchange.class) + String[] headers() default {}; + } diff --git a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethod.java b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethod.java index 1ebf85b41e2..819667c7449 100644 --- a/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethod.java +++ b/spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethod.java @@ -19,8 +19,10 @@ package org.springframework.web.service.invoker; import java.lang.reflect.AnnotatedElement; import java.lang.reflect.Method; import java.time.Duration; +import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.function.Function; import java.util.function.Supplier; @@ -46,6 +48,8 @@ import org.springframework.http.ResponseEntity; import org.springframework.lang.Nullable; import org.springframework.util.Assert; import org.springframework.util.ClassUtils; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; import org.springframework.util.StringValueResolver; @@ -156,6 +160,7 @@ final class HttpServiceMethod { private record HttpRequestValuesInitializer( @Nullable HttpMethod httpMethod, @Nullable String url, @Nullable MediaType contentType, @Nullable List acceptMediaTypes, + @Nullable MultiValueMap otherHeaders, Supplier requestValuesSupplier) { public HttpRequestValues.Builder initializeRequestValuesBuilder() { @@ -172,6 +177,16 @@ final class HttpServiceMethod { if (this.acceptMediaTypes != null) { requestValues.setAccept(this.acceptMediaTypes); } + if (this.otherHeaders != null) { + this.otherHeaders.forEach((name, values) -> { + if (values.size() == 1) { + requestValues.addHeader(name, values.get(0)); + } + else { + requestValues.addHeader(name, values.toArray(new String[0])); + } + }); + } return requestValues; } @@ -202,9 +217,10 @@ final class HttpServiceMethod { String url = initUrl(typeAnnotation, methodAnnotation, embeddedValueResolver); MediaType contentType = initContentType(typeAnnotation, methodAnnotation); List acceptableMediaTypes = initAccept(typeAnnotation, methodAnnotation); - - return new HttpRequestValuesInitializer( - httpMethod, url, contentType, acceptableMediaTypes, requestValuesSupplier); + MultiValueMap headers = initHeaders(typeAnnotation, methodAnnotation, + embeddedValueResolver); + return new HttpRequestValuesInitializer(httpMethod, url, contentType, + acceptableMediaTypes, headers, requestValuesSupplier); } @Nullable @@ -280,6 +296,50 @@ final class HttpServiceMethod { return null; } + private static MultiValueMap parseHeaders(String[] headersArray, + @Nullable StringValueResolver embeddedValueResolver) { + MultiValueMap headers = new LinkedMultiValueMap<>(); + for (String h: headersArray) { + String[] headerPair = StringUtils.split(h, "="); + if (headerPair != null) { + String headerName = headerPair[0].trim(); + List headerValues = new ArrayList<>(); + Set parsedValues = StringUtils.commaDelimitedListToSet(headerPair[1]); + for (String headerValue : parsedValues) { + if (embeddedValueResolver != null) { + headerValue = embeddedValueResolver.resolveStringValue(headerValue); + } + if (headerValue != null) { + headerValue = headerValue.trim(); + headerValues.add(headerValue); + } + } + if (!headerValues.isEmpty()) { + headers.addAll(headerName, headerValues); + } + } + } + return headers; + } + + @Nullable + private static MultiValueMap initHeaders(@Nullable HttpExchange typeAnnotation, HttpExchange methodAnnotation, + @Nullable StringValueResolver embeddedValueResolver) { + MultiValueMap methodLevelHeaders = parseHeaders(methodAnnotation.headers(), + embeddedValueResolver); + if (!ObjectUtils.isEmpty(methodLevelHeaders)) { + return methodLevelHeaders; + } + + MultiValueMap typeLevelHeaders = (typeAnnotation != null ? + parseHeaders(typeAnnotation.headers(), embeddedValueResolver) : null); + if (!ObjectUtils.isEmpty(typeLevelHeaders)) { + return typeLevelHeaders; + } + + return null; + } + private static List getAnnotationDescriptors(AnnotatedElement element) { return MergedAnnotations.from(element, SearchStrategy.TYPE_HIERARCHY, RepeatableContainers.none()) .stream(HttpExchange.class) diff --git a/spring-web/src/test/java/org/springframework/web/service/invoker/HttpServiceMethodTests.java b/spring-web/src/test/java/org/springframework/web/service/invoker/HttpServiceMethodTests.java index b890ec83eaf..90932f856b1 100644 --- a/spring-web/src/test/java/org/springframework/web/service/invoker/HttpServiceMethodTests.java +++ b/spring-web/src/test/java/org/springframework/web/service/invoker/HttpServiceMethodTests.java @@ -48,6 +48,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatIllegalStateException; import static org.springframework.http.MediaType.APPLICATION_CBOR_VALUE; import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; +import static org.springframework.http.MediaType.APPLICATION_NDJSON_VALUE; /** * Tests for {@link HttpServiceMethod} with @@ -184,6 +185,15 @@ class HttpServiceMethodTests { assertThat(requestValues.getUriTemplate()).isEqualTo("/url"); assertThat(requestValues.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON); assertThat(requestValues.getHeaders().getAccept()).containsOnly(MediaType.APPLICATION_JSON); + + service.performGetWithHeaders(); + + requestValues = this.client.getRequestValues(); + assertThat(requestValues.getHttpMethod()).isEqualTo(HttpMethod.GET); + assertThat(requestValues.getUriTemplate()).isEmpty(); + assertThat(requestValues.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON); + assertThat(requestValues.getHeaders().getAccept()).isEmpty(); + assertThat(requestValues.getHeaders().get("CustomHeader")).containsExactly("a", "b", "c"); } @Test @@ -338,6 +348,10 @@ class HttpServiceMethodTests { @PostExchange(url = "/url", contentType = APPLICATION_JSON_VALUE, accept = APPLICATION_JSON_VALUE) void performPost(); + @HttpExchange(contentType = APPLICATION_JSON_VALUE, headers = {"CustomHeader=a,b, c", + "Content-Type=" + APPLICATION_NDJSON_VALUE}, method = "GET") + void performGetWithHeaders(); + } diff --git a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java index 3b9bdfd6a5e..6cce38b7e54 100644 --- a/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java +++ b/spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java @@ -296,7 +296,8 @@ public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMappi .paths(resolveEmbeddedValuesInPatterns(toStringArray(httpExchange.value()))) .methods(toMethodArray(httpExchange.method())) .consumes(toStringArray(httpExchange.contentType())) - .produces(httpExchange.accept()); + .produces(httpExchange.accept()) + .headers(httpExchange.headers()); if (customCondition != null) { builder.customCondition(customCondition); diff --git a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMappingTests.java b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMappingTests.java index 5c04020d5eb..8e438acd3f5 100644 --- a/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMappingTests.java +++ b/spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMappingTests.java @@ -303,6 +303,26 @@ class RequestMappingHandlerMappingTests { .containsOnly(MediaType.valueOf("text/plain;charset=UTF-8")); } + @SuppressWarnings("DataFlowIssue") + @Test + void httpExchangeWithCustomHeaders() { + this.handlerMapping.afterPropertiesSet(); + + RequestMappingHandlerMapping mapping = new RequestMappingHandlerMapping(); + mapping.setApplicationContext(new StaticWebApplicationContext()); + mapping.afterPropertiesSet(); + + Class clazz = HttpExchangeController.class; + Method method = ReflectionUtils.findMethod(clazz, "customHeadersExchange"); + RequestMappingInfo mappingInfo = mapping.getMappingForMethod(method, clazz); + + assertThat(mappingInfo.getMethodsCondition().getMethods()).containsOnly(RequestMethod.GET); + assertThat(mappingInfo.getParamsCondition().getExpressions()).isEmpty(); + + assertThat(mappingInfo.getHeadersCondition().getExpressions().stream().map(Object::toString)) + .containsExactly("h1=hv1", "!h2"); + } + private RequestMappingInfo assertComposedAnnotationMapping(RequestMethod requestMethod) { String methodName = requestMethod.name().toLowerCase(); String path = "/" + methodName; @@ -409,6 +429,12 @@ class RequestMappingHandlerMappingTests { @PostExchange(url = "/custom", contentType = "application/json", accept = "text/plain;charset=UTF-8") public void customValuesExchange(){} + + @HttpExchange(method="GET", url = "/headers", + headers = {"h1=hv1", "!h2", "Accept=application/ignored"}) + public String customHeadersExchange() { + return "info"; + } } @HttpExchange("/exchange") diff --git a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java index 07d26aa77c8..cbaf12afdb0 100644 --- a/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java +++ b/spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java @@ -449,7 +449,8 @@ public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMappi .paths(resolveEmbeddedValuesInPatterns(toStringArray(httpExchange.value()))) .methods(toMethodArray(httpExchange.method())) .consumes(toStringArray(httpExchange.contentType())) - .produces(httpExchange.accept()); + .produces(httpExchange.accept()) + .headers(httpExchange.headers()); if (customCondition != null) { builder.customCondition(customCondition); diff --git a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMappingTests.java b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMappingTests.java index a5038867838..b765850ad08 100644 --- a/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMappingTests.java +++ b/spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMappingTests.java @@ -431,6 +431,26 @@ class RequestMappingHandlerMappingTests { .containsOnly(MediaType.valueOf("text/plain;charset=UTF-8")); } + @SuppressWarnings("DataFlowIssue") + @Test + void httpExchangeWithCustomHeaders() throws Exception { + RequestMappingHandlerMapping mapping = createMapping(); + + RequestMappingInfo mappingInfo = mapping.getMappingForMethod( + HttpExchangeController.class.getMethod("customHeadersExchange"), + HttpExchangeController.class); + + assertThat(mappingInfo.getPathPatternsCondition().getPatterns()) + .extracting(PathPattern::toString) + .containsOnly("/exchange/headers"); + + assertThat(mappingInfo.getMethodsCondition().getMethods()).containsOnly(RequestMethod.GET); + assertThat(mappingInfo.getParamsCondition().getExpressions()).isEmpty(); + + assertThat(mappingInfo.getHeadersCondition().getExpressions().stream().map(Object::toString)) + .containsExactly("h1=hv1", "!h2"); + } + private static RequestMappingHandlerMapping createMapping() { RequestMappingHandlerMapping mapping = new RequestMappingHandlerMapping(); mapping.setApplicationContext(new StaticWebApplicationContext()); @@ -543,6 +563,12 @@ class RequestMappingHandlerMappingTests { @PostExchange(url = "/custom", contentType = "application/json", accept = "text/plain;charset=UTF-8") public void customValuesExchange(){} + + @HttpExchange(method="GET", url = "/headers", + headers = {"h1=hv1", "!h2", "Accept=application/ignored"}) + public String customHeadersExchange() { + return "info"; + } }