diff --git a/CHANGELOG.md b/CHANGELOG.md index fb269d23..31b758c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +### Version 8.16 +* Adds `@HeaderMap` annotation to support dynamic header fields and values + ### Version 8.15 * Adds `@QueryMap` annotation to support dynamic query parameters * Supports runtime injection of `Param.Expander` via `MethodMetadata.indexToExpander` diff --git a/README.md b/README.md index b8e48453..a40820d5 100644 --- a/README.md +++ b/README.md @@ -273,9 +273,18 @@ Methods can specify dynamic content for static headers using using variable expa void post(@Param("token") String token); ``` -These approaches specify specific header entries as part of the api without requiring any customizations -when buildling Feing clients. It is not currently possible to customize the header entries themselves -on a per-request basis at the api level. +In cases where both the header field keys and values are dynamic and the range of possible keys cannot +be known ahead of time and may vary between different method calls in the same api/client (e.g. custom +metadata header fields such as "x-amz-meta-\*" or "x-goog-meta-\*"), a Map parameter can be annotated +with `HeaderMap` to construct a query that uses the contents of the map as its header parameters. + +```java + @RequestLine("POST /") + void post(@HeaderMap Map headerMap); +``` + +These approaches specify header entries as part of the api and do not require any customizations +when building the Feign client. #### Setting headers per target 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 ```java @RequestLine("GET /find") -V find(@QueryMap Map); +V find(@QueryMap Map queryMap); ``` diff --git a/core/src/main/java/feign/Contract.java b/core/src/main/java/feign/Contract.java index ffd8cb4d..adecaf09 100644 --- a/core/src/main/java/feign/Contract.java +++ b/core/src/main/java/feign/Contract.java @@ -114,6 +114,11 @@ public interface Contract { } } + if (data.headerMapIndex() != null) { + checkState(Map.class.isAssignableFrom(parameterTypes[data.headerMapIndex()]), + "HeaderMap parameter must be a Map: %s", parameterTypes[data.headerMapIndex()]); + } + if (data.queryMapIndex() != null) { checkState(Map.class.isAssignableFrom(parameterTypes[data.queryMapIndex()]), "QueryMap parameter must be a Map: %s", parameterTypes[data.queryMapIndex()]); @@ -258,6 +263,10 @@ public interface Contract { checkState(data.queryMapIndex() == null, "QueryMap annotation was present on multiple parameters."); data.queryMapIndex(paramIndex); isHttpAnnotation = true; + } else if (annotationType == HeaderMap.class) { + checkState(data.queryMapIndex() == null, "HeaderMap annotation was present on multiple parameters."); + data.headerMapIndex(paramIndex); + isHttpAnnotation = true; } } return isHttpAnnotation; diff --git a/core/src/main/java/feign/HeaderMap.java b/core/src/main/java/feign/HeaderMap.java new file mode 100644 index 00000000..3da400be --- /dev/null +++ b/core/src/main/java/feign/HeaderMap.java @@ -0,0 +1,55 @@ +package feign; + +import java.lang.annotation.Retention; +import java.util.List; +import java.util.Map; + +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * A template parameter that can be applied to a Map that contains header + * entries, where the keys are Strings that are the header field names and the + * values are the header field values. The headers specified by the map will be + * applied to the request after all other processing, and will take precedence + * over any previously specified header parameters. + *
+ * This parameter is useful in cases where different header fields and values + * need to be set on an API method on a per-request basis in a thread-safe manner + * and independently of Feign client construction. A concrete example of a case + * like this are custom metadata header fields (e.g. as "x-amz-meta-*" or + * "x-goog-meta-*") where the header field names are dynamic and the range of keys + * cannot be determined a priori. The {@link Headers} annotation does not allow this + * because the header fields that it defines are static (it is not possible to add or + * remove fields on a per-request basis), and doing this using a custom {@link Target} + * or {@link RequestInterceptor} can be cumbersome (it requires more code for per-method + * customization, it is difficult to implement in a thread-safe manner and it requires + * customization when the Feign client for the API is built). + *
+ *
+ * ...
+ * @RequestLine("GET /servers/{serverId}")
+ * void get(@Param("serverId") String serverId, @HeaderMap Map);
+ * ...
+ * 
+ * The annotated parameter must be an instance of {@link Map}, and the keys must + * be Strings. The header field value of a key will be the value of its toString + * method, except in the following cases: + *
+ *
+ *
    + *
  • if the value is null, the value will remain null (rather than converting + * to the String "null") + *
  • if the value is an {@link Iterable}, it is converted to a {@link List} of + * String objects where each value in the list is either null if the original + * value was null or the value's toString representation otherwise. + *
+ *
+ * Once this conversion is applied, the query keys and resulting String values + * follow the same contract as if they were set using + * {@link RequestTemplate#header(String, String...)}. + */ +@Retention(RUNTIME) +@java.lang.annotation.Target(PARAMETER) +public @interface HeaderMap { +} diff --git a/core/src/main/java/feign/MethodMetadata.java b/core/src/main/java/feign/MethodMetadata.java index 19720deb..358469d4 100644 --- a/core/src/main/java/feign/MethodMetadata.java +++ b/core/src/main/java/feign/MethodMetadata.java @@ -32,6 +32,7 @@ public final class MethodMetadata implements Serializable { private transient Type returnType; private Integer urlIndex; private Integer bodyIndex; + private Integer headerMapIndex; private Integer queryMapIndex; private transient Type bodyType; private RequestTemplate template = new RequestTemplate(); @@ -84,6 +85,15 @@ public final class MethodMetadata implements Serializable { return this; } + public Integer headerMapIndex() { + return headerMapIndex; + } + + public MethodMetadata headerMapIndex(Integer headerMapIndex) { + this.headerMapIndex = headerMapIndex; + return this; + } + public Integer queryMapIndex() { return queryMapIndex; } diff --git a/core/src/main/java/feign/ReflectiveFeign.java b/core/src/main/java/feign/ReflectiveFeign.java index 3da064a1..e2384379 100644 --- a/core/src/main/java/feign/ReflectiveFeign.java +++ b/core/src/main/java/feign/ReflectiveFeign.java @@ -211,9 +211,37 @@ public class ReflectiveFeign extends Feign { template = addQueryMapQueryParameters(argv, template); } + if (metadata.headerMapIndex() != null) { + template = addHeaderMapHeaders(argv, template); + } + return template; } + @SuppressWarnings("unchecked") + private RequestTemplate addHeaderMapHeaders(Object[] argv, RequestTemplate mutable) { + Map headerMap = (Map) argv[metadata.headerMapIndex()]; + for (Entry currEntry : headerMap.entrySet()) { + checkState(currEntry.getKey().getClass() == String.class, "HeaderMap key must be a String: %s", currEntry.getKey()); + + Collection values = new ArrayList(); + + Object currValue = currEntry.getValue(); + if (currValue instanceof Iterable) { + Iterator iter = ((Iterable) currValue).iterator(); + while (iter.hasNext()) { + Object nextObject = iter.next(); + values.add(nextObject == null ? null : nextObject.toString()); + } + } else { + values.add(currValue == null ? null : currValue.toString()); + } + + mutable.header((String) currEntry.getKey(), values); + } + return mutable; + } + @SuppressWarnings("unchecked") private RequestTemplate addQueryMapQueryParameters(Object[] argv, RequestTemplate mutable) { Map queryMap = (Map) argv[metadata.queryMapIndex()]; diff --git a/core/src/test/java/feign/FeignTest.java b/core/src/test/java/feign/FeignTest.java index 4d32ad5c..f5fc057a 100644 --- a/core/src/test/java/feign/FeignTest.java +++ b/core/src/test/java/feign/FeignTest.java @@ -26,6 +26,7 @@ import java.util.Collection; import java.util.LinkedHashMap; import okio.Buffer; import org.assertj.core.api.Fail; +import org.assertj.core.data.MapEntry; import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; @@ -216,6 +217,52 @@ public class FeignTest { .hasPath("/?date=1234"); } + @Test + public void headerMap() throws Exception { + server.enqueue(new MockResponse()); + + TestInterface api = new TestInterfaceBuilder().target("http://localhost:" + server.getPort()); + + Map headerMap = new LinkedHashMap(); + headerMap.put("Content-Type", "myContent"); + headerMap.put("Custom-Header", "fooValue"); + api.headerMap(headerMap); + + assertThat(server.takeRequest()) + .hasHeaders( + MapEntry.entry("Content-Type", Arrays.asList("myContent")), + MapEntry.entry("Custom-Header", Arrays.asList("fooValue"))); + } + + @Test + public void headerMapWithHeaderAnnotations() throws Exception { + server.enqueue(new MockResponse()); + + TestInterface api = new TestInterfaceBuilder().target("http://localhost:" + server.getPort()); + + Map headerMap = new LinkedHashMap(); + headerMap.put("Custom-Header", "fooValue"); + api.headerMapWithHeaderAnnotations(headerMap); + + // header map should be additive for headers provided by annotations + assertThat(server.takeRequest()) + .hasHeaders( + MapEntry.entry("Content-Encoding", Arrays.asList("deflate")), + MapEntry.entry("Custom-Header", Arrays.asList("fooValue"))); + + server.enqueue(new MockResponse()); + headerMap.put("Content-Encoding", "overrideFromMap"); + + api.headerMapWithHeaderAnnotations(headerMap); + + // if header map has entry that collides with annotation, value specified + // by header map should be used + assertThat(server.takeRequest()) + .hasHeaders( + MapEntry.entry("Content-Encoding", Arrays.asList("overrideFromMap")), + MapEntry.entry("Custom-Header", Arrays.asList("fooValue"))); + } + @Test public void queryMap() throws Exception { server.enqueue(new MockResponse()); @@ -609,6 +656,13 @@ public class FeignTest { @RequestLine("POST /?date={date}") void expand(@Param(value = "date", expander = DateToMillis.class) Date date); + @RequestLine("GET /") + void headerMap(@HeaderMap Map headerMap); + + @RequestLine("GET /") + @Headers("Content-Encoding: deflate") + void headerMapWithHeaderAnnotations(@HeaderMap Map headerMap); + @RequestLine("GET /") void queryMap(@QueryMap Map queryMap);