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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
}
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
}
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
}
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
}
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
}
|
||||
|
||||
@@ -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<MediaType> acceptMediaTypes,
|
||||
@Nullable MultiValueMap<String, String> otherHeaders,
|
||||
Supplier<HttpRequestValues.Builder> 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<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 {
|
||||
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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@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")
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user