Skip to content

Commit e7adaa7

Browse files
committed
Add HeaderMap annotation
Add @HeaderMap parameter annotation that allows methods to have an annotated Map parameter whose contents is used to set the header values on the request. Provides a way for APIs to allow for per-request header customization for both fields and values without customizing the Feign client.
1 parent 3312169 commit e7adaa7

7 files changed

Lines changed: 172 additions & 4 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
### Version 8.16
2+
* Adds `@HeaderMap` annotation to support dynamic header fields and values
3+
14
### Version 8.15
25
* Adds `@QueryMap` annotation to support dynamic query parameters
36
* Supports runtime injection of `Param.Expander` via `MethodMetadata.indexToExpander`

‎README.md‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -273,9 +273,18 @@ Methods can specify dynamic content for static headers using using variable expa
273273
void post(@Param("token") String token);
274274
```
275275

276-
These approaches specify specific header entries as part of the api without requiring any customizations
277-
when buildling Feing clients. It is not currently possible to customize the header entries themselves
278-
on a per-request basis at the api level.
276+
In cases where both the header field keys and values are dynamic and the range of possible keys cannot
277+
be known ahead of time and may vary between different method calls in the same api/client (e.g. custom
278+
metadata header fields such as "x-amz-meta-\*" or "x-goog-meta-\*"), a Map parameter can be annotated
279+
with `HeaderMap` to construct a query that uses the contents of the map as its header parameters.
280+
281+
```java
282+
@RequestLine("POST /")
283+
void post(@HeaderMap Map<String, Object> headerMap);
284+
```
285+
286+
These approaches specify header entries as part of the api and do not require any customizations
287+
when building the Feign client.
279288

280289
#### Setting headers per target
281290
In cases where headers should differ for the same api based on different endpoints or where per-request
@@ -417,5 +426,5 @@ A Map parameter can be annotated with `QueryMap` to construct a query that uses
417426

418427
```java
419428
@RequestLine("GET /find")
420-
V find(@QueryMap Map<String, Object>);
429+
V find(@QueryMap Map<String, Object> queryMap);
421430
```

‎core/src/main/java/feign/Contract.java‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,11 @@ protected MethodMetadata parseAndValidateMetadata(Class<?> targetType, Method me
114114
}
115115
}
116116

117+
if (data.headerMapIndex() != null) {
118+
checkState(Map.class.isAssignableFrom(parameterTypes[data.headerMapIndex()]),
119+
"HeaderMap parameter must be a Map: %s", parameterTypes[data.headerMapIndex()]);
120+
}
121+
117122
if (data.queryMapIndex() != null) {
118123
checkState(Map.class.isAssignableFrom(parameterTypes[data.queryMapIndex()]),
119124
"QueryMap parameter must be a Map: %s", parameterTypes[data.queryMapIndex()]);
@@ -258,6 +263,10 @@ protected boolean processAnnotationsOnParameter(MethodMetadata data, Annotation[
258263
checkState(data.queryMapIndex() == null, "QueryMap annotation was present on multiple parameters.");
259264
data.queryMapIndex(paramIndex);
260265
isHttpAnnotation = true;
266+
} else if (annotationType == HeaderMap.class) {
267+
checkState(data.queryMapIndex() == null, "HeaderMap annotation was present on multiple parameters.");
268+
data.headerMapIndex(paramIndex);
269+
isHttpAnnotation = true;
261270
}
262271
}
263272
return isHttpAnnotation;
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package feign;
2+
3+
import java.lang.annotation.Retention;
4+
import java.util.List;
5+
import java.util.Map;
6+
7+
import static java.lang.annotation.ElementType.PARAMETER;
8+
import static java.lang.annotation.RetentionPolicy.RUNTIME;
9+
10+
/**
11+
* A template parameter that can be applied to a Map that contains header
12+
* entries, where the keys are Strings that are the header field names and the
13+
* values are the header field values. The headers specified by the map will be
14+
* applied to the request after all other processing, and will take precedence
15+
* over any previously specified header parameters.
16+
* <br>
17+
* This parameter is useful in cases where different header fields and values
18+
* need to be set on an API method on a per-request basis in a thread-safe manner
19+
* and independently of Feign client construction. A concrete example of a case
20+
* like this are custom metadata header fields (e.g. as "x-amz-meta-*" or
21+
* "x-goog-meta-*") where the header field names are dynamic and the range of keys
22+
* cannot be determined a priori. The {@link Headers} annotation does not allow this
23+
* because the header fields that it defines are static (it is not possible to add or
24+
* remove fields on a per-request basis), and doing this using a custom {@link Target}
25+
* or {@link RequestInterceptor} can be cumbersome (it requires more code for per-method
26+
* customization, it is difficult to implement in a thread-safe manner and it requires
27+
* customization when the Feign client for the API is built).
28+
* <br>
29+
* <pre>
30+
* ...
31+
* &#64;RequestLine("GET /servers/{serverId}")
32+
* void get(&#64;Param("serverId") String serverId, &#64;HeaderMap Map<String, Object>);
33+
* ...
34+
* </pre>
35+
* The annotated parameter must be an instance of {@link Map}, and the keys must
36+
* be Strings. The header field value of a key will be the value of its toString
37+
* method, except in the following cases:
38+
* <br>
39+
* <br>
40+
* <ul>
41+
* <li>if the value is null, the value will remain null (rather than converting
42+
* to the String "null")
43+
* <li>if the value is an {@link Iterable}, it is converted to a {@link List} of
44+
* String objects where each value in the list is either null if the original
45+
* value was null or the value's toString representation otherwise.
46+
* </ul>
47+
* <br>
48+
* Once this conversion is applied, the query keys and resulting String values
49+
* follow the same contract as if they were set using
50+
* {@link RequestTemplate#header(String, String...)}.
51+
*/
52+
@Retention(RUNTIME)
53+
@java.lang.annotation.Target(PARAMETER)
54+
public @interface HeaderMap {
55+
}

‎core/src/main/java/feign/MethodMetadata.java‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ public final class MethodMetadata implements Serializable {
3232
private transient Type returnType;
3333
private Integer urlIndex;
3434
private Integer bodyIndex;
35+
private Integer headerMapIndex;
3536
private Integer queryMapIndex;
3637
private transient Type bodyType;
3738
private RequestTemplate template = new RequestTemplate();
@@ -84,6 +85,15 @@ public MethodMetadata bodyIndex(Integer bodyIndex) {
8485
return this;
8586
}
8687

88+
public Integer headerMapIndex() {
89+
return headerMapIndex;
90+
}
91+
92+
public MethodMetadata headerMapIndex(Integer headerMapIndex) {
93+
this.headerMapIndex = headerMapIndex;
94+
return this;
95+
}
96+
8797
public Integer queryMapIndex() {
8898
return queryMapIndex;
8999
}

‎core/src/main/java/feign/ReflectiveFeign.java‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,9 +211,37 @@ public RequestTemplate create(Object[] argv) {
211211
template = addQueryMapQueryParameters(argv, template);
212212
}
213213

214+
if (metadata.headerMapIndex() != null) {
215+
template = addHeaderMapHeaders(argv, template);
216+
}
217+
214218
return template;
215219
}
216220

221+
@SuppressWarnings("unchecked")
222+
private RequestTemplate addHeaderMapHeaders(Object[] argv, RequestTemplate mutable) {
223+
Map<Object, Object> headerMap = (Map<Object, Object>) argv[metadata.headerMapIndex()];
224+
for (Entry<Object, Object> currEntry : headerMap.entrySet()) {
225+
checkState(currEntry.getKey().getClass() == String.class, "HeaderMap key must be a String: %s", currEntry.getKey());
226+
227+
Collection<String> values = new ArrayList<String>();
228+
229+
Object currValue = currEntry.getValue();
230+
if (currValue instanceof Iterable<?>) {
231+
Iterator<?> iter = ((Iterable<?>) currValue).iterator();
232+
while (iter.hasNext()) {
233+
Object nextObject = iter.next();
234+
values.add(nextObject == null ? null : nextObject.toString());
235+
}
236+
} else {
237+
values.add(currValue == null ? null : currValue.toString());
238+
}
239+
240+
mutable.header((String) currEntry.getKey(), values);
241+
}
242+
return mutable;
243+
}
244+
217245
@SuppressWarnings("unchecked")
218246
private RequestTemplate addQueryMapQueryParameters(Object[] argv, RequestTemplate mutable) {
219247
Map<Object, Object> queryMap = (Map<Object, Object>) argv[metadata.queryMapIndex()];

‎core/src/test/java/feign/FeignTest.java‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import java.util.LinkedHashMap;
2727
import okio.Buffer;
2828
import org.assertj.core.api.Fail;
29+
import org.assertj.core.data.MapEntry;
2930
import org.junit.Rule;
3031
import org.junit.Test;
3132
import org.junit.rules.ExpectedException;
@@ -216,6 +217,52 @@ public void customExpander() throws Exception {
216217
.hasPath("/?date=1234");
217218
}
218219

220+
@Test
221+
public void headerMap() throws Exception {
222+
server.enqueue(new MockResponse());
223+
224+
TestInterface api = new TestInterfaceBuilder().target("http://localhost:" + server.getPort());
225+
226+
Map<String, Object> headerMap = new LinkedHashMap<String, Object>();
227+
headerMap.put("Content-Type", "myContent");
228+
headerMap.put("Custom-Header", "fooValue");
229+
api.headerMap(headerMap);
230+
231+
assertThat(server.takeRequest())
232+
.hasHeaders(
233+
MapEntry.entry("Content-Type", Arrays.asList("myContent")),
234+
MapEntry.entry("Custom-Header", Arrays.asList("fooValue")));
235+
}
236+
237+
@Test
238+
public void headerMapWithHeaderAnnotations() throws Exception {
239+
server.enqueue(new MockResponse());
240+
241+
TestInterface api = new TestInterfaceBuilder().target("http://localhost:" + server.getPort());
242+
243+
Map<String, Object> headerMap = new LinkedHashMap<String, Object>();
244+
headerMap.put("Custom-Header", "fooValue");
245+
api.headerMapWithHeaderAnnotations(headerMap);
246+
247+
// header map should be additive for headers provided by annotations
248+
assertThat(server.takeRequest())
249+
.hasHeaders(
250+
MapEntry.entry("Content-Encoding", Arrays.asList("deflate")),
251+
MapEntry.entry("Custom-Header", Arrays.asList("fooValue")));
252+
253+
server.enqueue(new MockResponse());
254+
headerMap.put("Content-Encoding", "overrideFromMap");
255+
256+
api.headerMapWithHeaderAnnotations(headerMap);
257+
258+
// if header map has entry that collides with annotation, value specified
259+
// by header map should be used
260+
assertThat(server.takeRequest())
261+
.hasHeaders(
262+
MapEntry.entry("Content-Encoding", Arrays.asList("overrideFromMap")),
263+
MapEntry.entry("Custom-Header", Arrays.asList("fooValue")));
264+
}
265+
219266
@Test
220267
public void queryMap() throws Exception {
221268
server.enqueue(new MockResponse());
@@ -609,6 +656,13 @@ void form(
609656
@RequestLine("POST /?date={date}")
610657
void expand(@Param(value = "date", expander = DateToMillis.class) Date date);
611658

659+
@RequestLine("GET /")
660+
void headerMap(@HeaderMap Map<String, Object> headerMap);
661+
662+
@RequestLine("GET /")
663+
@Headers("Content-Encoding: deflate")
664+
void headerMapWithHeaderAnnotations(@HeaderMap Map<String, Object> headerMap);
665+
612666
@RequestLine("GET /")
613667
void queryMap(@QueryMap Map<String, Object> queryMap);
614668

0 commit comments

Comments
 (0)