Skip to content

Commit bf5e218

Browse files
committed
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
1 parent b61eee7 commit bf5e218

File tree

14 files changed

+181
-6
lines changed

14 files changed

+181
-6
lines changed

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

+2-1
Original file line numberDiff line numberDiff line change
@@ -938,7 +938,8 @@ method parameters:
938938
| `@RequestHeader`
939939
| Add a request header or multiple headers. The argument may be a `Map<String, ?>` or
940940
`MultiValueMap<String, ?>` with multiple headers, a `Collection<?>` of values, or an
941-
individual value. Type conversion is supported for non-String values.
941+
individual value. Type conversion is supported for non-String values. This overrides
942+
the annotation's `headers` attribute.
942943

943944
| `@PathVariable`
944945
| Add a variable for expand a placeholder in the request URL. The argument may be a

framework-docs/modules/ROOT/pages/web/webflux/controller/ann-requestmapping.adoc

+5
Original file line numberDiff line numberDiff line change
@@ -606,3 +606,8 @@ subset of the method parameters that `@RequestMapping` does. Notably, it exclude
606606
server-side specific parameter types. For details, see the list for
607607
xref:integration/rest-clients.adoc#rest-http-interface-method-parameters[@HttpExchange] and
608608
xref:web/webflux/controller/ann-methods/arguments.adoc[@RequestMapping].
609+
610+
`@HttpExchange` also supports a `headers()` parameter which accepts `"name=value"`-like
611+
pairs like in `@RequestMapping(headers={})` on the client side. On the server side,
612+
this extends to the full syntax that
613+
xref:#webflux-ann-requestmapping-params-and-headers[`@RequestMapping`] supports.

framework-docs/modules/ROOT/pages/web/webmvc/mvc-controller/ann-requestmapping.adoc

+5
Original file line numberDiff line numberDiff line change
@@ -652,3 +652,8 @@ subset of the method parameters that `@RequestMapping` does. Notably, it exclude
652652
server-side specific parameter types. For details, see the list for
653653
xref:integration/rest-clients.adoc#rest-http-interface-method-parameters[@HttpExchange] and
654654
xref:web/webmvc/mvc-controller/ann-methods/arguments.adoc[@RequestMapping].
655+
656+
`@HttpExchange` also supports a `headers()` parameter which accepts `"name=value"`-like
657+
pairs like in `@RequestMapping(headers={})` on the client side. On the server side,
658+
this extends to the full syntax that
659+
xref:#mvc-ann-requestmapping-params-and-headers[`@RequestMapping`] supports.

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

+6
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,10 @@
6060
@AliasFor(annotation = HttpExchange.class)
6161
String[] accept() default {};
6262

63+
/**
64+
* Alias for {@link HttpExchange#headers()}.
65+
*/
66+
@AliasFor(annotation = HttpExchange.class)
67+
String[] headers() default {};
68+
6369
}

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

+12
Original file line numberDiff line numberDiff line change
@@ -173,4 +173,16 @@
173173
*/
174174
String[] accept() default {};
175175

176+
/**
177+
* The additional headers to use, as an array of {@code name=value} pairs.
178+
* <p>Multiple comma-separated values are accepted, and placeholders are
179+
* supported in these values. However, Accept and Content-Type headers are
180+
* ignored: see {@link #accept()} and {@link #contentType()}.
181+
* <p>Supported at the type level as well as at the method level, in which
182+
* case the method-level values override type-level values.
183+
* <p>By default, this is empty.
184+
* @since 6.2
185+
*/
186+
String[] headers() default {};
187+
176188
}

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

+6
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,10 @@
6060
@AliasFor(annotation = HttpExchange.class)
6161
String[] accept() default {};
6262

63+
/**
64+
* Alias for {@link HttpExchange#headers()}.
65+
*/
66+
@AliasFor(annotation = HttpExchange.class)
67+
String[] headers() default {};
68+
6369
}

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

+6
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,10 @@
6060
@AliasFor(annotation = HttpExchange.class)
6161
String[] accept() default {};
6262

63+
/**
64+
* Alias for {@link HttpExchange#headers()}.
65+
*/
66+
@AliasFor(annotation = HttpExchange.class)
67+
String[] headers() default {};
68+
6369
}

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

+6
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,10 @@
6060
@AliasFor(annotation = HttpExchange.class)
6161
String[] accept() default {};
6262

63+
/**
64+
* Alias for {@link HttpExchange#headers()}.
65+
*/
66+
@AliasFor(annotation = HttpExchange.class)
67+
String[] headers() default {};
68+
6369
}

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

+63-3
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@
1919
import java.lang.reflect.AnnotatedElement;
2020
import java.lang.reflect.Method;
2121
import java.time.Duration;
22+
import java.util.ArrayList;
2223
import java.util.List;
2324
import java.util.Optional;
25+
import java.util.Set;
2426
import java.util.function.Function;
2527
import java.util.function.Supplier;
2628

@@ -46,6 +48,8 @@
4648
import org.springframework.lang.Nullable;
4749
import org.springframework.util.Assert;
4850
import org.springframework.util.ClassUtils;
51+
import org.springframework.util.LinkedMultiValueMap;
52+
import org.springframework.util.MultiValueMap;
4953
import org.springframework.util.ObjectUtils;
5054
import org.springframework.util.StringUtils;
5155
import org.springframework.util.StringValueResolver;
@@ -156,6 +160,7 @@ private void applyArguments(HttpRequestValues.Builder requestValues, Object[] ar
156160
private record HttpRequestValuesInitializer(
157161
@Nullable HttpMethod httpMethod, @Nullable String url,
158162
@Nullable MediaType contentType, @Nullable List<MediaType> acceptMediaTypes,
163+
@Nullable MultiValueMap<String, String> otherHeaders,
159164
Supplier<HttpRequestValues.Builder> requestValuesSupplier) {
160165

161166
public HttpRequestValues.Builder initializeRequestValuesBuilder() {
@@ -172,6 +177,16 @@ public HttpRequestValues.Builder initializeRequestValuesBuilder() {
172177
if (this.acceptMediaTypes != null) {
173178
requestValues.setAccept(this.acceptMediaTypes);
174179
}
180+
if (this.otherHeaders != null) {
181+
this.otherHeaders.forEach((name, values) -> {
182+
if (values.size() == 1) {
183+
requestValues.addHeader(name, values.get(0));
184+
}
185+
else {
186+
requestValues.addHeader(name, values.toArray(new String[0]));
187+
}
188+
});
189+
}
175190
return requestValues;
176191
}
177192

@@ -202,9 +217,10 @@ public static HttpRequestValuesInitializer create(
202217
String url = initUrl(typeAnnotation, methodAnnotation, embeddedValueResolver);
203218
MediaType contentType = initContentType(typeAnnotation, methodAnnotation);
204219
List<MediaType> acceptableMediaTypes = initAccept(typeAnnotation, methodAnnotation);
205-
206-
return new HttpRequestValuesInitializer(
207-
httpMethod, url, contentType, acceptableMediaTypes, requestValuesSupplier);
220+
MultiValueMap<String, String> headers = initHeaders(typeAnnotation, methodAnnotation,
221+
embeddedValueResolver);
222+
return new HttpRequestValuesInitializer(httpMethod, url, contentType,
223+
acceptableMediaTypes, headers, requestValuesSupplier);
208224
}
209225

210226
@Nullable
@@ -280,6 +296,50 @@ private static List<MediaType> initAccept(@Nullable HttpExchange typeAnnotation,
280296
return null;
281297
}
282298

299+
private static MultiValueMap<String, String> parseHeaders(String[] headersArray,
300+
@Nullable StringValueResolver embeddedValueResolver) {
301+
MultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
302+
for (String h: headersArray) {
303+
String[] headerPair = StringUtils.split(h, "=");
304+
if (headerPair != null) {
305+
String headerName = headerPair[0].trim();
306+
List<String> headerValues = new ArrayList<>();
307+
Set<String> parsedValues = StringUtils.commaDelimitedListToSet(headerPair[1]);
308+
for (String headerValue : parsedValues) {
309+
if (embeddedValueResolver != null) {
310+
headerValue = embeddedValueResolver.resolveStringValue(headerValue);
311+
}
312+
if (headerValue != null) {
313+
headerValue = headerValue.trim();
314+
headerValues.add(headerValue);
315+
}
316+
}
317+
if (!headerValues.isEmpty()) {
318+
headers.addAll(headerName, headerValues);
319+
}
320+
}
321+
}
322+
return headers;
323+
}
324+
325+
@Nullable
326+
private static MultiValueMap<String, String> initHeaders(@Nullable HttpExchange typeAnnotation, HttpExchange methodAnnotation,
327+
@Nullable StringValueResolver embeddedValueResolver) {
328+
MultiValueMap<String, String> methodLevelHeaders = parseHeaders(methodAnnotation.headers(),
329+
embeddedValueResolver);
330+
if (!ObjectUtils.isEmpty(methodLevelHeaders)) {
331+
return methodLevelHeaders;
332+
}
333+
334+
MultiValueMap<String, String> typeLevelHeaders = (typeAnnotation != null ?
335+
parseHeaders(typeAnnotation.headers(), embeddedValueResolver) : null);
336+
if (!ObjectUtils.isEmpty(typeLevelHeaders)) {
337+
return typeLevelHeaders;
338+
}
339+
340+
return null;
341+
}
342+
283343
private static List<AnnotationDescriptor> getAnnotationDescriptors(AnnotatedElement element) {
284344
return MergedAnnotations.from(element, SearchStrategy.TYPE_HIERARCHY, RepeatableContainers.none())
285345
.stream(HttpExchange.class)

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

+14
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
4949
import static org.springframework.http.MediaType.APPLICATION_CBOR_VALUE;
5050
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
51+
import static org.springframework.http.MediaType.APPLICATION_NDJSON_VALUE;
5152

5253
/**
5354
* Tests for {@link HttpServiceMethod} with
@@ -184,6 +185,15 @@ void methodAnnotatedService() {
184185
assertThat(requestValues.getUriTemplate()).isEqualTo("/url");
185186
assertThat(requestValues.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON);
186187
assertThat(requestValues.getHeaders().getAccept()).containsOnly(MediaType.APPLICATION_JSON);
188+
189+
service.performGetWithHeaders();
190+
191+
requestValues = this.client.getRequestValues();
192+
assertThat(requestValues.getHttpMethod()).isEqualTo(HttpMethod.GET);
193+
assertThat(requestValues.getUriTemplate()).isEmpty();
194+
assertThat(requestValues.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON);
195+
assertThat(requestValues.getHeaders().getAccept()).isEmpty();
196+
assertThat(requestValues.getHeaders().get("CustomHeader")).containsExactly("a", "b", "c");
187197
}
188198

189199
@Test
@@ -338,6 +348,10 @@ private interface MethodLevelAnnotatedService {
338348
@PostExchange(url = "/url", contentType = APPLICATION_JSON_VALUE, accept = APPLICATION_JSON_VALUE)
339349
void performPost();
340350

351+
@HttpExchange(contentType = APPLICATION_JSON_VALUE, headers = {"CustomHeader=a,b, c",
352+
"Content-Type=" + APPLICATION_NDJSON_VALUE}, method = "GET")
353+
void performGetWithHeaders();
354+
341355
}
342356

343357

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

+2-1
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,8 @@ protected RequestMappingInfo createRequestMappingInfo(
296296
.paths(resolveEmbeddedValuesInPatterns(toStringArray(httpExchange.value())))
297297
.methods(toMethodArray(httpExchange.method()))
298298
.consumes(toStringArray(httpExchange.contentType()))
299-
.produces(httpExchange.accept());
299+
.produces(httpExchange.accept())
300+
.headers(httpExchange.headers());
300301

301302
if (customCondition != null) {
302303
builder.customCondition(customCondition);

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

+26
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,26 @@ void httpExchangeWithCustomValues() {
303303
.containsOnly(MediaType.valueOf("text/plain;charset=UTF-8"));
304304
}
305305

306+
@SuppressWarnings("DataFlowIssue")
307+
@Test
308+
void httpExchangeWithCustomHeaders() {
309+
this.handlerMapping.afterPropertiesSet();
310+
311+
RequestMappingHandlerMapping mapping = new RequestMappingHandlerMapping();
312+
mapping.setApplicationContext(new StaticWebApplicationContext());
313+
mapping.afterPropertiesSet();
314+
315+
Class<HttpExchangeController> clazz = HttpExchangeController.class;
316+
Method method = ReflectionUtils.findMethod(clazz, "customHeadersExchange");
317+
RequestMappingInfo mappingInfo = mapping.getMappingForMethod(method, clazz);
318+
319+
assertThat(mappingInfo.getMethodsCondition().getMethods()).containsOnly(RequestMethod.GET);
320+
assertThat(mappingInfo.getParamsCondition().getExpressions()).isEmpty();
321+
322+
assertThat(mappingInfo.getHeadersCondition().getExpressions().stream().map(Object::toString))
323+
.containsExactly("h1=hv1", "!h2");
324+
}
325+
306326
private RequestMappingInfo assertComposedAnnotationMapping(RequestMethod requestMethod) {
307327
String methodName = requestMethod.name().toLowerCase();
308328
String path = "/" + methodName;
@@ -409,6 +429,12 @@ public void defaultValuesExchange() {}
409429

410430
@PostExchange(url = "/custom", contentType = "application/json", accept = "text/plain;charset=UTF-8")
411431
public void customValuesExchange(){}
432+
433+
@HttpExchange(method="GET", url = "/headers",
434+
headers = {"h1=hv1", "!h2", "Accept=application/ignored"})
435+
public String customHeadersExchange() {
436+
return "info";
437+
}
412438
}
413439

414440
@HttpExchange("/exchange")

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

+2-1
Original file line numberDiff line numberDiff line change
@@ -449,7 +449,8 @@ protected RequestMappingInfo createRequestMappingInfo(
449449
.paths(resolveEmbeddedValuesInPatterns(toStringArray(httpExchange.value())))
450450
.methods(toMethodArray(httpExchange.method()))
451451
.consumes(toStringArray(httpExchange.contentType()))
452-
.produces(httpExchange.accept());
452+
.produces(httpExchange.accept())
453+
.headers(httpExchange.headers());
453454

454455
if (customCondition != null) {
455456
builder.customCondition(customCondition);

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

+26
Original file line numberDiff line numberDiff line change
@@ -431,6 +431,26 @@ void httpExchangeWithCustomValues() throws Exception {
431431
.containsOnly(MediaType.valueOf("text/plain;charset=UTF-8"));
432432
}
433433

434+
@SuppressWarnings("DataFlowIssue")
435+
@Test
436+
void httpExchangeWithCustomHeaders() throws Exception {
437+
RequestMappingHandlerMapping mapping = createMapping();
438+
439+
RequestMappingInfo mappingInfo = mapping.getMappingForMethod(
440+
HttpExchangeController.class.getMethod("customHeadersExchange"),
441+
HttpExchangeController.class);
442+
443+
assertThat(mappingInfo.getPathPatternsCondition().getPatterns())
444+
.extracting(PathPattern::toString)
445+
.containsOnly("/exchange/headers");
446+
447+
assertThat(mappingInfo.getMethodsCondition().getMethods()).containsOnly(RequestMethod.GET);
448+
assertThat(mappingInfo.getParamsCondition().getExpressions()).isEmpty();
449+
450+
assertThat(mappingInfo.getHeadersCondition().getExpressions().stream().map(Object::toString))
451+
.containsExactly("h1=hv1", "!h2");
452+
}
453+
434454
private static RequestMappingHandlerMapping createMapping() {
435455
RequestMappingHandlerMapping mapping = new RequestMappingHandlerMapping();
436456
mapping.setApplicationContext(new StaticWebApplicationContext());
@@ -543,6 +563,12 @@ public void defaultValuesExchange() {}
543563

544564
@PostExchange(url = "/custom", contentType = "application/json", accept = "text/plain;charset=UTF-8")
545565
public void customValuesExchange(){}
566+
567+
@HttpExchange(method="GET", url = "/headers",
568+
headers = {"h1=hv1", "!h2", "Accept=application/ignored"})
569+
public String customHeadersExchange() {
570+
return "info";
571+
}
546572
}
547573

548574

0 commit comments

Comments
 (0)