Skip to content

Commit

Permalink
Add support for headers in @HttpExchange
Browse files Browse the repository at this point in the history
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
  • Loading branch information
simonbasle committed Aug 9, 2024
1 parent b61eee7 commit bf5e218
Show file tree
Hide file tree
Showing 14 changed files with 181 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,10 @@
@AliasFor(annotation = HttpExchange.class)
String[] accept() default {};

/**
* Alias for {@link HttpExchange#headers()}.
*/
@AliasFor(annotation = HttpExchange.class)
String[] headers() default {};

}
Original file line number Diff line number Diff line change
Expand Up @@ -173,4 +173,16 @@
*/
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 {};

}
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,10 @@
@AliasFor(annotation = HttpExchange.class)
String[] accept() default {};

/**
* Alias for {@link HttpExchange#headers()}.
*/
@AliasFor(annotation = HttpExchange.class)
String[] headers() default {};

}
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,10 @@
@AliasFor(annotation = HttpExchange.class)
String[] accept() default {};

/**
* Alias for {@link HttpExchange#headers()}.
*/
@AliasFor(annotation = HttpExchange.class)
String[] headers() default {};

}
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,10 @@
@AliasFor(annotation = HttpExchange.class)
String[] accept() default {};

/**
* Alias for {@link HttpExchange#headers()}.
*/
@AliasFor(annotation = HttpExchange.class)
String[] headers() default {};

}
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@
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;

Expand All @@ -46,6 +48,8 @@
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;
Expand Down Expand Up @@ -156,6 +160,7 @@ private void applyArguments(HttpRequestValues.Builder requestValues, Object[] ar
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() {
Expand All @@ -172,6 +177,16 @@ public HttpRequestValues.Builder initializeRequestValuesBuilder() {
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;
}

Expand Down Expand Up @@ -202,9 +217,10 @@ public static HttpRequestValuesInitializer create(
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
Expand Down Expand Up @@ -280,6 +296,50 @@ private static List<MediaType> initAccept(@Nullable HttpExchange typeAnnotation,
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
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
Expand Down Expand Up @@ -184,6 +185,15 @@ void methodAnnotatedService() {
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
Expand Down Expand Up @@ -338,6 +348,10 @@ private interface MethodLevelAnnotatedService {
@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();

}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,8 @@ protected RequestMappingInfo createRequestMappingInfo(
.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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,26 @@ void httpExchangeWithCustomValues() {
.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;
Expand Down Expand Up @@ -409,6 +429,12 @@ public void defaultValuesExchange() {}

@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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,8 @@ protected RequestMappingInfo createRequestMappingInfo(
.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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,26 @@ void httpExchangeWithCustomValues() throws Exception {
.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());
Expand Down Expand Up @@ -543,6 +563,12 @@ public void defaultValuesExchange() {}

@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";
}
}


Expand Down

0 comments on commit bf5e218

Please sign in to comment.