Browse Source

Add support for headers in `@HttpExchange`

On the client side, supports `name=value` pairs. Placeholders in values
are resolved by the `embeddedValueResolver`.
On the server side, additionally supports `name` and `!name` syntax.

Closes gh-33309
pull/33364/head
Simon Baslé 1 year ago
parent
commit
bf5e218b35
  1. 3
      framework-docs/modules/ROOT/pages/integration/rest-clients.adoc
  2. 5
      framework-docs/modules/ROOT/pages/web/webflux/controller/ann-requestmapping.adoc
  3. 5
      framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-requestmapping.adoc
  4. 6
      spring-web/src/main/java/org/springframework/web/service/annotation/DeleteExchange.java
  5. 12
      spring-web/src/main/java/org/springframework/web/service/annotation/HttpExchange.java
  6. 6
      spring-web/src/main/java/org/springframework/web/service/annotation/PatchExchange.java
  7. 6
      spring-web/src/main/java/org/springframework/web/service/annotation/PostExchange.java
  8. 6
      spring-web/src/main/java/org/springframework/web/service/annotation/PutExchange.java
  9. 66
      spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethod.java
  10. 14
      spring-web/src/test/java/org/springframework/web/service/invoker/HttpServiceMethodTests.java
  11. 3
      spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java
  12. 26
      spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMappingTests.java
  13. 3
      spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java
  14. 26
      spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMappingTests.java

3
framework-docs/modules/ROOT/pages/integration/rest-clients.adoc

@ -938,7 +938,8 @@ method parameters: @@ -938,7 +938,8 @@ method parameters:
| `@RequestHeader`
| Add a request header or multiple headers. The argument may be a `Map<String, ?>` or
`MultiValueMap<String, ?>` 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

5
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 @@ -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.

5
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 @@ -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.

6
spring-web/src/main/java/org/springframework/web/service/annotation/DeleteExchange.java

@ -60,4 +60,10 @@ public @interface DeleteExchange { @@ -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 {};
}

12
spring-web/src/main/java/org/springframework/web/service/annotation/HttpExchange.java

@ -173,4 +173,16 @@ public @interface HttpExchange { @@ -173,4 +173,16 @@ public @interface HttpExchange {
*/
String[] accept() default {};
/**
* The additional headers to use, as an array of {@code name=value} pairs.
* <p>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()}.
* <p>Supported at the type level as well as at the method level, in which
* case the method-level values override type-level values.
* <p>By default, this is empty.
* @since 6.2
*/
String[] headers() default {};
}

6
spring-web/src/main/java/org/springframework/web/service/annotation/PatchExchange.java

@ -60,4 +60,10 @@ public @interface PatchExchange { @@ -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 {};
}

6
spring-web/src/main/java/org/springframework/web/service/annotation/PostExchange.java

@ -60,4 +60,10 @@ public @interface PostExchange { @@ -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 {};
}

6
spring-web/src/main/java/org/springframework/web/service/annotation/PutExchange.java

@ -60,4 +60,10 @@ public @interface PutExchange { @@ -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 {};
}

66
spring-web/src/main/java/org/springframework/web/service/invoker/HttpServiceMethod.java

@ -19,8 +19,10 @@ package org.springframework.web.service.invoker; @@ -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; @@ -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 { @@ -156,6 +160,7 @@ final class HttpServiceMethod {
private record HttpRequestValuesInitializer(
@Nullable HttpMethod httpMethod, @Nullable String url,
@Nullable MediaType contentType, @Nullable List<MediaType> acceptMediaTypes,
@Nullable MultiValueMap<String, String> otherHeaders,
Supplier<HttpRequestValues.Builder> requestValuesSupplier) {
public HttpRequestValues.Builder initializeRequestValuesBuilder() {
@ -172,6 +177,16 @@ final class HttpServiceMethod { @@ -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 { @@ -202,9 +217,10 @@ final class HttpServiceMethod {
String url = initUrl(typeAnnotation, methodAnnotation, embeddedValueResolver);
MediaType contentType = initContentType(typeAnnotation, methodAnnotation);
List<MediaType> acceptableMediaTypes = initAccept(typeAnnotation, methodAnnotation);
return new HttpRequestValuesInitializer(
httpMethod, url, contentType, acceptableMediaTypes, requestValuesSupplier);
MultiValueMap<String, String> headers = initHeaders(typeAnnotation, methodAnnotation,
embeddedValueResolver);
return new HttpRequestValuesInitializer(httpMethod, url, contentType,
acceptableMediaTypes, headers, requestValuesSupplier);
}
@Nullable
@ -280,6 +296,50 @@ final class HttpServiceMethod { @@ -280,6 +296,50 @@ final class HttpServiceMethod {
return null;
}
private static MultiValueMap<String, String> parseHeaders(String[] headersArray,
@Nullable StringValueResolver embeddedValueResolver) {
MultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
for (String h: headersArray) {
String[] headerPair = StringUtils.split(h, "=");
if (headerPair != null) {
String headerName = headerPair[0].trim();
List<String> headerValues = new ArrayList<>();
Set<String> 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<String, String> initHeaders(@Nullable HttpExchange typeAnnotation, HttpExchange methodAnnotation,
@Nullable StringValueResolver embeddedValueResolver) {
MultiValueMap<String, String> methodLevelHeaders = parseHeaders(methodAnnotation.headers(),
embeddedValueResolver);
if (!ObjectUtils.isEmpty(methodLevelHeaders)) {
return methodLevelHeaders;
}
MultiValueMap<String, String> typeLevelHeaders = (typeAnnotation != null ?
parseHeaders(typeAnnotation.headers(), embeddedValueResolver) : null);
if (!ObjectUtils.isEmpty(typeLevelHeaders)) {
return typeLevelHeaders;
}
return null;
}
private static List<AnnotationDescriptor> getAnnotationDescriptors(AnnotatedElement element) {
return MergedAnnotations.from(element, SearchStrategy.TYPE_HIERARCHY, RepeatableContainers.none())
.stream(HttpExchange.class)

14
spring-web/src/test/java/org/springframework/web/service/invoker/HttpServiceMethodTests.java

@ -48,6 +48,7 @@ import static org.assertj.core.api.Assertions.assertThat; @@ -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 { @@ -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 { @@ -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();
}

3
spring-webflux/src/main/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMapping.java

@ -296,7 +296,8 @@ public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMappi @@ -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);

26
spring-webflux/src/test/java/org/springframework/web/reactive/result/method/annotation/RequestMappingHandlerMappingTests.java

@ -303,6 +303,26 @@ class RequestMappingHandlerMappingTests { @@ -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<HttpExchangeController> 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 { @@ -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")

3
spring-webmvc/src/main/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMapping.java

@ -449,7 +449,8 @@ public class RequestMappingHandlerMapping extends RequestMappingInfoHandlerMappi @@ -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);

26
spring-webmvc/src/test/java/org/springframework/web/servlet/mvc/method/annotation/RequestMappingHandlerMappingTests.java

@ -431,6 +431,26 @@ class RequestMappingHandlerMappingTests { @@ -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 { @@ -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";
}
}

Loading…
Cancel
Save