Repository: wojtekmach/req
Pull request #105 (2022-05-11 14:29:40)

Add `Req.Request.put_header/3` function
It implements the same behaviour as [`Plug.Conn.put_req_header/3`](https://hexdocs.pm/plug/Plug.Conn.html#put_req_header/3) does.


Patch:
diff --git a/lib/req.ex b/lib/req.ex
index 16454d4..05c6f48 100644
--- a/lib/req.ex
+++ b/lib/req.ex
@@ -417,7 +417,9 @@ defmodule Req do
         * atom header names are turned into strings, replacing `-` with `_`. For example,
           `:user_agent` becomes `"user-agent"`
 
-        * string header names are left as is
+        * string header names are left as is. Because header keys are case-insensitive
+          in both HTTP/1.1 and HTTP/2, it is recommended for header keys to be in
+          lowercase, to avoid sending duplicate keys in a request.
 
         * `NaiveDateTime` and `DateTime` header values are encoded as "HTTP date". Otherwise,
           the header value is encoded with `String.Chars.to_string/1`.
diff --git a/lib/req/request.ex b/lib/req/request.ex
index 1c4504f..efa0c12 100644
--- a/lib/req/request.ex
+++ b/lib/req/request.ex
@@ -470,6 +470,41 @@ defmodule Req.Request do
     update_in(request.options, &Map.merge(&1, Map.new(options)))
   end
 
+  @doc """
+  Adds a new request header (`key`) if not present, otherwise replaces the
+  previous value of that header with `value`.
+
+  Because header keys are case-insensitive in both HTTP/1.1 and HTTP/2,
+  it is recommended for header keys to be in lowercase, to avoid sending
+  duplicate keys in a request.
+
+  Additionally, requests with mixed-case headers served over HTTP/2 are not
+  considered valid by common clients, resulting in dropped requests.
+
+  ## Examples
+
+      iex> req = Req.new()
+      iex> Req.Request.put_header(req, "accept", "application/json").headers
+      [{"accept", "application/json"}]
+
+  """
+  @spec put_header(t(), binary(), binary()) :: t()
+  def put_header(%Req.Request{headers: headers} = request, key, value)
+      when is_binary(key) and is_binary(value) do
+    %{request | headers: List.keystore(headers, key, 0, {key, value})}
+  end
+
+  @doc false
+  @spec put_new_header(t(), binary(), binary()) :: t()
+  def put_new_header(%Req.Request{headers: headers} = request, key, value)
+      when is_binary(key) and is_binary(value) do
+    if Enum.any?(headers, &(String.downcase(elem(&1, 0)) == key)) do
+      request
+    else
+      put_header(request, key, value)
+    end
+  end
+
   @doc """
   Registers options to be used by a custom steps.
 
diff --git a/lib/req/steps.ex b/lib/req/steps.ex
index 79e2c38..533dd47 100644
--- a/lib/req/steps.ex
+++ b/lib/req/steps.ex
@@ -95,12 +95,12 @@ defmodule Req.Steps do
   end
 
   defp auth(request, {:bearer, token}) when is_binary(token) do
-    put_new_header(request, "authorization", "Bearer #{token}")
+    Req.Request.put_new_header(request, "authorization", "Bearer #{token}")
   end
 
   defp auth(request, {username, password}) when is_binary(username) and is_binary(password) do
     value = Base.encode64("#{username}:#{password}")
-    put_new_header(request, "authorization", "Basic #{value}")
+    Req.Request.put_new_header(request, "authorization", "Basic #{value}")
   end
 
   defp auth(request, :netrc) do
@@ -169,7 +169,7 @@ defmodule Req.Steps do
   @doc step: :request
   def put_user_agent(request) do
     user_agent = Map.get(request.options, :user_agent, @user_agent)
-    put_new_header(request, "user-agent", user_agent)
+    Req.Request.put_new_header(request, "user-agent", user_agent)
   end
 
   @doc """
@@ -227,10 +227,10 @@ defmodule Req.Steps do
   def compressed(request) do
     case Map.fetch(request.options, :compressed) do
       :error ->
-        put_new_header(request, "accept-encoding", supported_accept_encoding())
+        Req.Request.put_new_header(request, "accept-encoding", supported_accept_encoding())
 
       {:ok, true} ->
-        put_new_header(request, "accept-encoding", supported_accept_encoding())
+        Req.Request.put_new_header(request, "accept-encoding", supported_accept_encoding())
 
       {:ok, false} ->
         request
@@ -276,11 +276,11 @@ defmodule Req.Steps do
     cond do
       data = request.options[:form] ->
         %{request | body: URI.encode_query(data)}
-        |> put_new_header("content-type", "application/x-www-form-urlencoded")
+        |> Req.Request.put_new_header("content-type", "application/x-www-form-urlencoded")
 
       data = request.options[:json] ->
         %{request | body: Jason.encode_to_iodata!(data)}
-        |> put_new_header("content-type", "application/json")
+        |> Req.Request.put_new_header("content-type", "application/json")
 
       true ->
         request
@@ -341,11 +341,11 @@ defmodule Req.Steps do
   """
   @doc step: :request
   def put_range(%{options: %{range: range}} = request) when is_binary(range) do
-    put_header(request, "range", range)
+    Req.Request.put_header(request, "range", range)
   end
 
   def put_range(%{options: %{range: first..last}} = request) do
-    put_header(request, "range", "bytes=#{first}-#{last}")
+    Req.Request.put_header(request, "range", "bytes=#{first}-#{last}")
   end
 
   def put_range(request) do
@@ -393,7 +393,7 @@ defmodule Req.Steps do
     case File.stat(cache_path(dir, request)) do
       {:ok, stat} ->
         datetime = stat.mtime |> NaiveDateTime.from_erl!() |> format_http_datetime()
-        put_new_header(request, "if-modified-since", datetime)
+        Req.Request.put_new_header(request, "if-modified-since", datetime)
 
       _ ->
         request
@@ -429,7 +429,7 @@ defmodule Req.Steps do
     if request.options[:compress_body] do
       request
       |> Map.update!(:body, &:zlib.gzip/1)
-      |> put_header("content-encoding", "gzip")
+      |> Req.Request.put_header("content-encoding", "gzip")
     else
       request
     end
@@ -1134,18 +1134,6 @@ defmodule Req.Steps do
 
   ## Utilities
 
-  defp put_new_header(struct, name, value) do
-    if Enum.any?(struct.headers, fn {key, _} -> String.downcase(key) == name end) do
-      struct
-    else
-      put_header(struct, name, value)
-    end
-  end
-
-  defp put_header(struct, name, value) do
-    update_in(struct.headers, &[{name, value} | &1])
-  end
-
   defp get_content_encoding_header(headers) do
     if value = get_header(headers, "content-encoding") do
       value


Test patch:
diff --git a/test/req/request_test.exs b/test/req/request_test.exs
index 02ddf16..2e86fb5 100644
--- a/test/req/request_test.exs
+++ b/test/req/request_test.exs
@@ -42,9 +42,9 @@ defmodule Req.RequestTest do
     authorization = "Basic " <> Base.encode64("foo:bar")
 
     assert [
-             {"authorization", ^authorization},
+             {"user-agent", "req/" <> _},
              {"accept-encoding", "br, gzip, deflate"},
-             {"user-agent", "req/" <> _}
+             {"authorization", ^authorization}
            ] = request.headers
   end