Repository: square/retrofit
Issue: Add @HeadersMap
Labels: Enhancement

**> First:**

``` java
    public interface ApiService {
        @Headers({
                "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/49.0.2623.110 Safari/537.36",
                "Accept: text/html,text/plain,application/json",
                "Accept-Language: zh-cn,zh,en-US,en",
                "Accept: text/html,text/plain,application/json",
                "Accept-Language: zh-cn,zh,en-US,en",
                "Accept-Charset: utf-8,GB2312",
                "Connection: keep-alive"
        })
        @GET("Api-{module}-{method}")
        Call<TokenEntity> asyncGet(@Path("module") String pModule, @Path("method") String pMethod, @QueryMap Map<String, String> pParameterOptions);
    }
```

"Headers" modify to "HeaderMap" easy reading

**> Second:**
    This feature is exist or implement new feature?

``` java
    private static class SingletonHolder {
        private static final OkHttpClient S_CLIENT;
        private static final Retrofit.Builder S_BUILDER;

        static {
            OkHttpClient.Builder nClientBuilder = new OkHttpClient.Builder();
            /* 连接超时2分钟 */
            nClientBuilder.connectTimeout(2 * 60 * 1000, TimeUnit.MILLISECONDS);
            /* 数据读取超时10分钟 */
            nClientBuilder.readTimeout(10 * 60 * 1000, TimeUnit.MILLISECONDS);
            /* 数据上传超时10分钟 */
            nClientBuilder.writeTimeout(10 * 60 * 1000, TimeUnit.MILLISECONDS);
            /* 连接失败后尝试重新连接 */
            nClientBuilder.retryOnConnectionFailure(true);
            S_CLIENT = nClientBuilder.build();

            /* Retrofit结合OkHttpClient*/
            S_BUILDER = new Retrofit.Builder();
            S_BUILDER.baseUrl("http://ybirds.lp/").addConverterFactory(GsonConverterFactory.create()).validateEagerly(true).client(S_CLIENT);
        }
    }

    public static Retrofit.Builder getRetrofitBuilder(){
        return SingletonHolder.S_BUILDER;
    }

    public interface ApiService {
        @GET("Api-{module}-{method}")
        <T> Call<T> asyncGet(@Path("module") String pModule, @Path("method") String pMethod);
    }

    Retrofit nRetrofit = getRetrofitBuilder().build();
    ApiService nApiService =  nRetrofit.create(ApiService.class);
    Map<String, String> nParameters = new HashMap<>();
    nParameters.put("appid","9CA630FF6AA2EE3E99");

    Call<TokenEntity> nTokenCall = nApiService.asyncGet("Token", "getToken", nParameters);
    // ------------------------------------
    Call<UserInfoEntity> nTokenCall = nApiService.asyncGet("user", "getUserInfo", nParameters);
    // and so on
```


Issue comments:
I'm not opposed to `@HeadersMap`.

Merged as adfe99884bfc01dd3a1164a768fef96d6550208b.

Thanks, @JakeWharton 


Pull request review comments:
We cannot depend on Guava

Oh okay. Should we have our own `MultiMap`? Or should we support `Map<String, Collection<?>>` instead?

Just ignore that case for now, we're going to look into it as part of #626. 

Sure, thanks!

This is false, but I suspect this is copy/paste from other map annotations so I'll fix them all once this is merged.


Patch that resolved the issue:
diff --git a/retrofit/src/main/java/retrofit2/Converter.java b/retrofit/src/main/java/retrofit2/Converter.java
index 9fd8eddafa..604bcf0864 100644
--- a/retrofit/src/main/java/retrofit2/Converter.java
+++ b/retrofit/src/main/java/retrofit2/Converter.java
@@ -66,8 +66,8 @@ public Converter<?, RequestBody> requestBodyConverter(Type type,
      * Returns a {@link Converter} for converting {@code type} to a {@link String}, or null if
      * {@code type} cannot be handled by this factory. This is used to create converters for types
      * specified by {@link Field @Field}, {@link FieldMap @FieldMap} values,
-     * {@link Header @Header}, {@link Path @Path}, {@link Query @Query}, and
-     * {@link QueryMap @QueryMap} values.
+     * {@link Header @Header}, {@link HeaderMap @HeaderMap}, {@link Path @Path},
+     * {@link Query @Query}, and {@link QueryMap @QueryMap} values.
      */
     public Converter<?, String> stringConverter(Type type, Annotation[] annotations,
         Retrofit retrofit) {
diff --git a/retrofit/src/main/java/retrofit2/ParameterHandler.java b/retrofit/src/main/java/retrofit2/ParameterHandler.java
index 38e21e5598..3221790357 100644
--- a/retrofit/src/main/java/retrofit2/ParameterHandler.java
+++ b/retrofit/src/main/java/retrofit2/ParameterHandler.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2015 Square, Inc.
+ * Copyright (C) 2015-2016 Square, Inc.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -18,6 +18,7 @@
 import java.io.IOException;
 import java.lang.reflect.Array;
 import java.util.Map;
+
 import okhttp3.Headers;
 import okhttp3.MultipartBody;
 import okhttp3.RequestBody;
@@ -139,6 +140,33 @@ static final class QueryMap<T> extends ParameterHandler<Map<String, T>> {
     }
   }
 
+  static final class HeaderMap<T> extends ParameterHandler<Map<String, T>> {
+    private final Converter<T, String> valueConverter;
+
+    HeaderMap(Converter<T, String> valueConverter) {
+      this.valueConverter = valueConverter;
+    }
+
+    @Override void apply(RequestBuilder builder, Map<String, T> value) throws IOException {
+      if (value == null) {
+        throw new IllegalArgumentException("Header map was null.");
+      }
+
+      for (Map.Entry<String, T> entry : value.entrySet()) {
+        String headerName = entry.getKey();
+        if (headerName == null) {
+          throw new IllegalArgumentException("Header map contained null key.");
+        }
+        T headerValue = entry.getValue();
+        if (headerValue == null) {
+          throw new IllegalArgumentException(
+              "Header map contained null value for key '" + headerName + "'.");
+        }
+        builder.addHeader(headerName, valueConverter.convert(headerValue));
+      }
+    }
+  }
+
   static final class Field<T> extends ParameterHandler<T> {
     private final String name;
     private final Converter<T, String> valueConverter;
diff --git a/retrofit/src/main/java/retrofit2/ServiceMethod.java b/retrofit/src/main/java/retrofit2/ServiceMethod.java
index a055688599..c9ab874efd 100644
--- a/retrofit/src/main/java/retrofit2/ServiceMethod.java
+++ b/retrofit/src/main/java/retrofit2/ServiceMethod.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2015 Square, Inc.
+ * Copyright (C) 2015-2016 Square, Inc.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -26,6 +26,7 @@
 import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+
 import okhttp3.Headers;
 import okhttp3.HttpUrl;
 import okhttp3.MediaType;
@@ -42,6 +43,7 @@
 import retrofit2.http.HEAD;
 import retrofit2.http.HTTP;
 import retrofit2.http.Header;
+import retrofit2.http.HeaderMap;
 import retrofit2.http.Multipart;
 import retrofit2.http.OPTIONS;
 import retrofit2.http.PATCH;
@@ -471,6 +473,26 @@ private ParameterHandler<?> parseParameterAnnotation(
           return new ParameterHandler.Header<>(name, converter);
         }
 
+      } else if (annotation instanceof HeaderMap) {
+        Class<?> rawParameterType = Utils.getRawType(type);
+        if (!Map.class.isAssignableFrom(rawParameterType)) {
+          throw parameterError(p, "@HeaderMap parameter type must be Map.");
+        }
+        Type mapType = Utils.getSupertype(type, rawParameterType, Map.class);
+        if (!(mapType instanceof ParameterizedType)) {
+          throw parameterError(p, "Map must include generic types (e.g., Map<String, String>)");
+        }
+        ParameterizedType parameterizedType = (ParameterizedType) mapType;
+        Type keyType = Utils.getParameterUpperBound(0, parameterizedType);
+        if (String.class != keyType) {
+          throw parameterError(p, "@HeaderMap keys must be of type String: " + keyType);
+        }
+        Type valueType = Utils.getParameterUpperBound(1, parameterizedType);
+        Converter<?, String> valueConverter =
+            retrofit.stringConverter(valueType, annotations);
+
+        return new ParameterHandler.HeaderMap<>(valueConverter);
+
       } else if (annotation instanceof Field) {
         if (!isFormEncoded) {
           throw parameterError(p, "@Field parameters can only be used with form encoding.");
diff --git a/retrofit/src/main/java/retrofit2/http/Header.java b/retrofit/src/main/java/retrofit2/http/Header.java
index 8ce5cb8d05..b8f7a70ea0 100644
--- a/retrofit/src/main/java/retrofit2/http/Header.java
+++ b/retrofit/src/main/java/retrofit2/http/Header.java
@@ -33,6 +33,9 @@
  * <p>
  * <strong>Note:</strong> Headers do not overwrite each other. All headers with the same name will
  * be included in the request.
+ *
+ * @see Headers
+ * @see HeaderMap
  */
 @Documented
 @Retention(RUNTIME)
diff --git a/retrofit/src/main/java/retrofit2/http/HeaderMap.java b/retrofit/src/main/java/retrofit2/http/HeaderMap.java
new file mode 100644
index 0000000000..aa1facd399
--- /dev/null
+++ b/retrofit/src/main/java/retrofit2/http/HeaderMap.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright (C) 2016 Square, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package retrofit2.http;
+
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+import java.util.Map;
+
+/**
+ * Adds headers specified in the {@link Map}.
+ * <p>
+ * Values are converted to strings using {@link String#valueOf(Object)}.
+ * <p>
+ * Simple Example:
+ * <pre>
+ * &#64;GET("/search")
+ * void list(@HeaderMap Map&lt;String, String&gt; headers);
+ *
+ * ...
+ *
+ * // The following call yields /search with headers
+ * // Accept: text/plain and Accept-Charset: utf-8
+ * foo.list(ImmutableMap.of("Accept", "text/plain", "Accept-Charset", "utf-8"));
+ * </pre>
+ *
+ * @see Header
+ * @see Headers
+ */
+@Documented
+@Target(PARAMETER)
+@Retention(RUNTIME)
+public @interface HeaderMap {
+
+}
diff --git a/retrofit/src/main/java/retrofit2/http/Headers.java b/retrofit/src/main/java/retrofit2/http/Headers.java
index 8b2adea1a1..b360f3ec3d 100644
--- a/retrofit/src/main/java/retrofit2/http/Headers.java
+++ b/retrofit/src/main/java/retrofit2/http/Headers.java
@@ -38,6 +38,9 @@
  * </code></pre>
  * <strong>Note:</strong> Headers do not overwrite each other. All headers with the same name will
  * be included in the request.
+ *
+ * @see Header
+ * @see HeaderMap
  */
 @Documented
 @Target(METHOD)


Test patch:
diff --git a/retrofit/src/test/java/retrofit2/RequestBuilderTest.java b/retrofit/src/test/java/retrofit2/RequestBuilderTest.java
index 65595b9180..3621343aba 100644
--- a/retrofit/src/test/java/retrofit2/RequestBuilderTest.java
+++ b/retrofit/src/test/java/retrofit2/RequestBuilderTest.java
@@ -1,5 +1,5 @@
 /*
- * Copyright (C) 2013 Square, Inc.
+ * Copyright (C) 2013-2016 Square, Inc.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -35,6 +35,7 @@
 import okio.Buffer;
 import org.junit.Ignore;
 import org.junit.Test;
+
 import retrofit2.helpers.ToStringConverterFactory;
 import retrofit2.http.Body;
 import retrofit2.http.DELETE;
@@ -45,6 +46,7 @@
 import retrofit2.http.HEAD;
 import retrofit2.http.HTTP;
 import retrofit2.http.Header;
+import retrofit2.http.HeaderMap;
 import retrofit2.http.Headers;
 import retrofit2.http.Multipart;
 import retrofit2.http.OPTIONS;
@@ -531,6 +533,119 @@ Call<ResponseBody> method(@QueryMap Map<String, String> a) {
     }
   }
 
+  @Test public void getWithHeaderMap() {
+    class Example {
+      @GET("/search")
+      Call<ResponseBody> method(@HeaderMap Map<String, Object> headers) {
+        return null;
+      }
+    }
+
+    Map<String, Object> headers = new LinkedHashMap<>();
+    headers.put("Accept", "text/plain");
+    headers.put("Accept-Charset", "utf-8");
+
+    Request request = buildRequest(Example.class, headers);
+    assertThat(request.method()).isEqualTo("GET");
+    assertThat(request.url().toString()).isEqualTo("http://example.com/search");
+    assertThat(request.body()).isNull();
+    assertThat(request.headers().size()).isEqualTo(2);
+    assertThat(request.header("Accept")).isEqualTo("text/plain");
+    assertThat(request.header("Accept-Charset")).isEqualTo("utf-8");
+  }
+
+  @Test public void headerMapMustBeAMap() {
+    class Example {
+      @GET("/")
+      Call<ResponseBody> method(@HeaderMap List<String> headers) {
+        return null;
+      }
+    }
+    try {
+      buildRequest(Example.class);
+      fail();
+    } catch (IllegalArgumentException e) {
+      assertThat(e).hasMessage(
+          "@HeaderMap parameter type must be Map. (parameter #1)\n    for method Example.method");
+    }
+  }
+
+  @Test public void headerMapSupportsSubclasses() {
+    class Foo extends HashMap<String, String> {
+    }
+
+    class Example {
+      @GET("/search")
+      Call<ResponseBody> method(@HeaderMap Foo headers) {
+        return null;
+      }
+    }
+
+    Foo headers = new Foo();
+    headers.put("Accept", "text/plain");
+
+    Request request = buildRequest(Example.class, headers);
+    assertThat(request.url().toString()).isEqualTo("http://example.com/search");
+    assertThat(request.headers().size()).isEqualTo(1);
+    assertThat(request.header("Accept")).isEqualTo("text/plain");
+  }
+
+  @Test public void headerMapRejectsNull() {
+    class Example {
+      @GET("/")
+      Call<ResponseBody> method(@HeaderMap Map<String, String> headers) {
+        return null;
+      }
+    }
+
+    try {
+      buildRequest(Example.class, (Map<String, String>) null);
+      fail();
+    } catch (IllegalArgumentException e) {
+      assertThat(e).hasMessage("Header map was null.");
+    }
+  }
+
+  @Test public void headerMapRejectsNullKeys() {
+    class Example {
+      @GET("/")
+      Call<ResponseBody> method(@HeaderMap Map<String, String> headers) {
+        return null;
+      }
+    }
+
+    Map<String, String> headers = new LinkedHashMap<>();
+    headers.put("Accept", "text/plain");
+    headers.put(null, "utf-8");
+
+    try {
+      buildRequest(Example.class, headers);
+      fail();
+    } catch (IllegalArgumentException e) {
+      assertThat(e).hasMessage("Header map contained null key.");
+    }
+  }
+
+  @Test public void headerMapRejectsNullValues() {
+    class Example {
+      @GET("/")
+      Call<ResponseBody> method(@HeaderMap Map<String, String> headers) {
+        return null;
+      }
+    }
+
+    Map<String, String> headers = new LinkedHashMap<>();
+    headers.put("Accept", "text/plain");
+    headers.put("Accept-Charset", null);
+
+    try {
+      buildRequest(Example.class, headers);
+      fail();
+    } catch (IllegalArgumentException e) {
+      assertThat(e).hasMessage("Header map contained null value for key 'Accept-Charset'.");
+    }
+  }
+
   @Test public void twoBodies() {
     class Example {
       @PUT("/") //