diff --git a/CHANGELOG.md b/CHANGELOG.md index 0344b190..924866b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,44 @@ ## HEAD + * Change `request.headers` and `response.headers` to be maps. + + Previously headers were lists of name/value tuples, e.g.: + + [{"content-type", "text/html"}] + + Now they are maps of string names and lists of values, e.g.: + + %{"content-type" => ["text/html"]} + + This is a major breaking change. If you cannot easily update your app + or your dependencies, do: + + # config/config.exs + config :req, legacy_headers_as_lists: true + + This legacy fallback will be removed on Req 1.0. + + * Make `request.registered_options` internal representation private. + + * Make `request.options` internal representation private. + + Currently `request.options` field is a map but it may change in the future. + One possible future change is using keywords lists internally which would + allow, for example, `Req.new(params: [a: 1]) |> Req.update(params: [b: 2])` + to keep duplicate `:params` in `request.options` which would then allow to + decide the duplicate key semantics on a per-step basis. And so, for example, + [`put_params`] would _merge_ params but most steps would simply use the + first value. + + To have some room for manoeuvre in the future we should stop pattern + matching on `request.options`. Calling `request.options[key]`, + `put_in(request.options[key], value)`, and + `update_in(request.options[key], fun)` _is_ allowed. + New functions [`Req.Request.get_option/3`], + [`Req.Request.fetch_option!/2`], and [`Req.Request.delete_option/1`] have been + added for additional ways to manipulate the internal representation. + * Fix typespecs for some functions * [`decompress_body`]: Remove support for `deflate` compression @@ -33,26 +71,6 @@ * [`Req.Request`]: Fix displaying redacted basic authentication - * Make `request.registered_options` internal representation private. - - * Make `request.options` internal representation private. - - Currently `request.options` field is a map but it may change in the future. - One possible future change is using keywords lists internally which would - allow, for example, `Req.new(params: [a: 1]) |> Req.update(params: [b: 2])` - to keep duplicate `:params` in `request.options` which would then allow to - decide the duplicate key semantics on a per-step basis. And so, for example, - [`put_params`] would _merge_ params but most steps would simply use the - first value. - - To have some room for manoeuvre in the future we should stop pattern - matching on `request.options`. Calling `request.options[key]`, - `put_in(request.options[key], value)`, and - `update_in(request.options[key], fun)` _is_ allowed. - New functions [`Req.Request.get_option/3`], - [`Req.Request.fetch_option!/2`], and [`Req.Request.delete_option/1`] have been - added for additional ways to manipulate the internal representation. - ## v0.3.11 (2023-07-24) * Support `Req.get(options)`, `Req.post(options)`, etc diff --git a/lib/req.ex b/lib/req.ex index ae9937b9..321b1ca6 100644 --- a/lib/req.ex +++ b/lib/req.ex @@ -335,14 +335,14 @@ defmodule Req do iex> req = Req.new(headers: [point_x: 1]) iex> req = Req.update(req, headers: [point_y: 2]) iex> req.headers - [{"point-x", "1"}, {"point-y", "2"}] + %{"point-x" => ["1"], "point-y" => ["2"]} The same header names are overwritten however: iex> req = Req.new(headers: [authorization: "bearer foo"]) iex> req = Req.update(req, headers: [authorization: "bearer bar"]) iex> req.headers - [{"authorization", "bearer bar"}] + %{"authorization" => ["bearer bar"]} Similarly to headers, `:params` are merged too: @@ -372,9 +372,13 @@ defmodule Req do {:headers, new_headers}, acc -> update_in(acc.headers, fn old_headers -> - new_headers = encode_headers(new_headers) - new_header_names = Enum.map(new_headers, &elem(&1, 0)) - Enum.reject(old_headers, &(elem(&1, 0) in new_header_names)) ++ new_headers + if unquote(Req.MixProject.legacy_headers_as_lists?()) do + new_headers = encode_headers(new_headers) + new_header_names = Enum.map(new_headers, &elem(&1, 0)) + Enum.reject(old_headers, &(elem(&1, 0) in new_header_names)) ++ new_headers + else + Map.merge(old_headers, encode_headers(new_headers)) + end end) {name, value}, acc -> @@ -942,32 +946,52 @@ defmodule Req do Application.put_env(:req, :default_options, options) end - defp encode_headers(headers) do - for {name, value} <- headers do - name = - case name do - atom when is_atom(atom) -> - atom |> Atom.to_string() |> String.replace("_", "-") |> String.downcase(:ascii) + if Req.MixProject.legacy_headers_as_lists?() do + defp encode_headers(headers) do + for {name, value} <- headers do + {encode_header_name(name), encode_header_value(value)} + end + end + else + defp encode_headers(headers) do + Enum.reduce(headers, %{}, fn {name, value}, acc -> + Map.update( + acc, + encode_header_name(name), + encode_header_values(List.wrap(value)), + &(&1 ++ encode_header_values(List.wrap(value))) + ) + end) + end - binary when is_binary(binary) -> - String.downcase(binary, :ascii) - end + defp encode_header_values([value | rest]) do + [encode_header_value(value) | encode_header_values(rest)] + end - value = - case value do - %DateTime{} = datetime -> - datetime |> DateTime.shift_zone!("Etc/UTC") |> Req.Steps.format_http_datetime() + defp encode_header_values([]) do + [] + end + end - %NaiveDateTime{} = datetime -> - IO.warn("setting header to %NaiveDateTime{} is deprecated, use %DateTime{} instead") - Req.Steps.format_http_datetime(datetime) + defp encode_header_name(name) when is_atom(name) do + name |> Atom.to_string() |> String.replace("_", "-") |> __ensure_header_downcase__() + end - _ -> - String.Chars.to_string(value) - end + defp encode_header_name(name) when is_binary(name) do + __ensure_header_downcase__(name) + end - {name, value} - end + defp encode_header_value(%DateTime{} = datetime) do + datetime |> DateTime.shift_zone!("Etc/UTC") |> Req.Steps.format_http_datetime() + end + + defp encode_header_value(%NaiveDateTime{} = datetime) do + IO.warn("setting header to %NaiveDateTime{} is deprecated, use %DateTime{} instead") + Req.Steps.format_http_datetime(datetime) + end + + defp encode_header_value(value) do + String.Chars.to_string(value) end # Plugins support is experimental, undocumented, and likely won't make the new release. diff --git a/lib/req/request.ex b/lib/req/request.ex index 6d380595..f1fd0a7b 100644 --- a/lib/req/request.ex +++ b/lib/req/request.ex @@ -145,11 +145,11 @@ defmodule Req.Request do Examples: def decode({request, response}) do - case List.keyfind(response.headers, "content-type", 0) do - {_, "application/json" <> _} -> + case Req.Response.get_header(response, "content-type") do + ["application/json" <> _] -> {request, update_in(response.body, &Jason.decode!/1)} - _ -> + [] -> {request, response} end end @@ -320,7 +320,7 @@ defmodule Req.Request do @type t() :: %Req.Request{ method: atom(), url: URI.t(), - headers: [{binary(), binary()}], + headers: %{binary() => [binary()]}, body: iodata() | {:stream, Enumerable.t()} | nil, options: options(), halted: boolean(), @@ -338,7 +338,7 @@ defmodule Req.Request do defstruct method: :get, url: URI.parse(""), - headers: [], + headers: if(Req.MixProject.legacy_headers_as_lists?(), do: [], else: %{}), body: nil, options: %{}, halted: false, @@ -368,20 +368,37 @@ defmodule Req.Request do ## Examples iex> req = Req.Request.new(url: "https://api.github.com/repos/wojtekmach/req") - iex> {request, response} = Req.Request.run_request(req) - iex> request.url.host + iex> {req, resp} = Req.Request.run_request(req) + iex> req.url.host "api.github.com" - iex> response.status + iex> resp.status 200 """ - def new(options) do - options = - options - |> Keyword.validate!([:method, :url, :headers, :body, :adapter, :options]) - |> Keyword.update(:url, URI.new!(""), &URI.new!/1) - |> Keyword.update(:options, %{}, &Map.new/1) - - struct!(__MODULE__, options) + if Req.MixProject.legacy_headers_as_lists?() do + def new(options) do + options = + options + |> Keyword.validate!([:method, :url, :headers, :body, :adapter, :options]) + |> Keyword.update(:url, URI.new!(""), &URI.new!/1) + |> Keyword.update(:options, %{}, &Map.new/1) + + struct!(__MODULE__, options) + end + else + def new(options) do + options = + options + |> Keyword.validate!([:method, :url, :headers, :body, :adapter, :options]) + |> Keyword.update(:url, URI.new!(""), &URI.new!/1) + |> Keyword.update(:headers, %{}, fn headers -> + Map.new(headers, fn {key, value} -> + {key, List.wrap(value)} + end) + end) + |> Keyword.update(:options, %{}, &Map.new/1) + + struct!(__MODULE__, options) + end end @doc """ @@ -654,31 +671,55 @@ defmodule Req.Request do iex> req = Req.new(headers: [{"accept", "application/json"}]) iex> Req.Request.get_header(req, "accept") ["application/json"] + iex> Req.Request.get_header(req, "x-unknown") + [] """ @spec get_header(t(), binary()) :: [binary()] - def get_header(%Req.Request{} = request, name) when is_binary(name) do - name = Req.__ensure_header_downcase__(name) - for {^name, value} <- request.headers, do: value + if Req.MixProject.legacy_headers_as_lists?() do + def get_header(%Req.Request{} = request, name) when is_binary(name) do + name = Req.__ensure_header_downcase__(name) + + for {^name, value} <- request.headers do + value + end + end + else + def get_header(%Req.Request{} = request, name) when is_binary(name) do + name = Req.__ensure_header_downcase__(name) + Map.get(request.headers, name, []) + end end @doc """ - Adds a new request header `name` if not present, otherwise replaces the - previous value of that header with `value`. + Sets the header `key` to `value`. + + The value can be a binary or a list of binaries, + + If the header was previously set, its value is overwritten. ## Examples iex> req = Req.new() + iex> Req.Request.get_header(req, "accept") + [] iex> req = Req.Request.put_header(req, "accept", "application/json") - iex> req.headers - [{"accept", "application/json"}] + iex> Req.Request.get_header(req, "accept") + ["application/json"] """ @spec put_header(t(), binary(), binary()) :: t() - def put_header(%Req.Request{} = request, name, value) - when is_binary(name) and is_binary(value) do - name = Req.__ensure_header_downcase__(name) - %{request | headers: List.keystore(request.headers, name, 0, {name, value})} + if Req.MixProject.legacy_headers_as_lists?() do + def put_header(%Req.Request{} = request, name, value) + when is_binary(name) and is_binary(value) do + %{request | headers: List.keystore(request.headers, name, 0, {name, value})} + end + else + def put_header(%Req.Request{} = request, name, value) + when is_binary(name) and (is_binary(value) or is_list(value)) do + name = Req.__ensure_header_downcase__(name) + put_in(request.headers[name], List.wrap(value)) + end end @doc """ @@ -690,8 +731,10 @@ defmodule Req.Request do iex> req = Req.new() iex> req = Req.Request.put_headers(req, [{"accept", "text/html"}, {"accept-encoding", "gzip"}]) - iex> req.headers - [{"accept", "text/html"}, {"accept-encoding", "gzip"}] + iex> Req.Request.get_header(req, "accept") + ["text/html"] + iex> Req.Request.get_header(req, "accept-encoding") + ["gzip"] """ @spec put_headers(t(), [{binary(), binary()}]) :: t() def put_headers(%Req.Request{} = request, headers) do @@ -711,18 +754,51 @@ defmodule Req.Request do ...> Req.new() ...> |> Req.Request.put_new_header("accept", "application/json") ...> |> Req.Request.put_new_header("accept", "application/html") - iex> req.headers - [{"accept", "application/json"}] + iex> Req.Request.get_header(req, "accept") + ["application/json"] """ @spec put_new_header(t(), binary(), binary()) :: t() - def put_new_header(%Req.Request{} = request, name, value) - when is_binary(name) and is_binary(value) do - case get_header(request, name) do - [] -> - put_header(request, name, value) - - _ -> - request + if Req.MixProject.legacy_headers_as_lists?() do + def put_new_header(%Req.Request{} = request, name, value) do + case get_header(request, name) do + [] -> + put_header(request, name, value) + + _ -> + request + end + end + else + def put_new_header(%Req.Request{} = request, name, value) + when is_binary(name) and (is_binary(value) or is_list(value)) do + name = Req.__ensure_header_downcase__(name) + update_in(request.headers, &Map.put_new(&1, name, List.wrap(value))) + end + end + + @doc """ + Deletes the header given by `name`. + + All occurences of the header are deleted, in case the header is repeated multiple times. + + ## Examples + + iex> Req.Request.get_header(req, "cache-control") + ["max-age=600", "no-transform"] + iex> req = Req.Request.delete_header(req, "cache-control") + iex> Req.Request.get_header(req, "cache-control") + [] + + """ + if Req.MixProject.legacy_headers_as_lists?() do + def delete_header(%Req.Request{} = request, name) when is_binary(name) do + name = Req.__ensure_header_downcase__(name) + %{request | headers: List.keydelete(request.headers, name, 0)} + end + else + def delete_header(%Req.Request{} = request, name) when is_binary(name) do + name = Req.__ensure_header_downcase__(name) + update_in(request.headers, &Map.delete(&1, name)) end end @@ -905,11 +981,22 @@ defmodule Req.Request do {headers, options} = if Req.Request.get_option(request, :redact_auth, true) do headers = - for {name, value} <- request.headers do - if name in ["authorization", "Authorization"] do - {name, "[redacted]"} - else - {name, value} + if Req.MixProject.legacy_headers_as_lists?() do + for {name, value} <- request.headers do + if Req.__ensure_header_downcase__(name) == "authorization" do + {name, "[redacted]"} + else + {name, value} + end + end + else + for {name, values} <- request.headers, into: %{} do + if Req.__ensure_header_downcase__(name) == "authorization" do + [_] = values + {name, ["[redacted]"]} + else + {name, values} + end end end diff --git a/lib/req/response.ex b/lib/req/response.ex index 1c8124a4..b7f93fff 100644 --- a/lib/req/response.ex +++ b/lib/req/response.ex @@ -17,13 +17,13 @@ defmodule Req.Response do @type t() :: %__MODULE__{ status: non_neg_integer(), - headers: [{binary(), binary()}], + headers: %{binary() => [binary()]}, body: binary() | term(), private: map() } defstruct status: 200, - headers: [], + headers: if(Req.MixProject.legacy_headers_as_lists?(), do: [], else: %{}), body: "", private: %{} @@ -35,11 +35,11 @@ defmodule Req.Response do ## Example iex> Req.Response.new(status: 200, body: "body") - %Req.Response{status: 200, headers: [], body: "body"} + %Req.Response{status: 200, headers: %{}, body: "body"} iex> finch_response = %Finch.Response{status: 200} iex> Req.Response.new(finch_response) - %Req.Response{status: 200, headers: [], body: ""} + %Req.Response{status: 200, headers: %{}, body: ""} """ @spec new(options :: keyword() | map() | struct()) :: t() @@ -47,9 +47,27 @@ defmodule Req.Response do def new(options) when is_list(options), do: new(Map.new(options)) - def new(options) do - options = Map.take(options, [:status, :headers, :body]) - struct!(__MODULE__, options) + if Req.MixProject.legacy_headers_as_lists?() do + def new(options) do + options = Map.take(options, [:status, :headers, :body]) + struct!(__MODULE__, options) + end + else + def new(options) do + options = + Map.take(options, [:status, :headers, :body]) + |> Map.update(:headers, %{}, fn + map when is_map(map) -> + map + + list when is_list(list) -> + Enum.reduce(list, %{}, fn {name, value}, acc -> + Map.update(acc, name, [value], &(&1 ++ [value])) + end) + end) + + struct!(__MODULE__, options) + end end @doc """ @@ -60,7 +78,7 @@ defmodule Req.Response do iex> Req.Response.json(%{hello: 42}) %Req.Response{ status: 200, - headers: [{"content-type", "application/json"}], + headers: %{"content-type" => ["application/json"]}, body: ~s|{"hello":42}| } @@ -68,7 +86,7 @@ defmodule Req.Response do iex> Req.Response.json(resp, %{hello: 42}) %Req.Response{ status: 200, - headers: [{"content-type", "application/json"}], + headers: %{"content-type" => ["application/json"]}, body: ~s|{"hello":42}| } @@ -79,7 +97,7 @@ defmodule Req.Response do iex> |> Req.Response.json(%{hello: 42}) %Req.Response{ status: 200, - headers: [{"content-type", "application/vnd.api+json; charset=utf-8"}], + headers: %{"content-type" => ["application/vnd.api+json; charset=utf-8"]}, body: ~s|{"hello":42}| } """ @@ -122,9 +140,19 @@ defmodule Req.Response do ["application/json"] """ @spec get_header(t(), binary()) :: [binary()] - def get_header(%Req.Response{} = response, name) when is_binary(name) do - name = Req.__ensure_header_downcase__(name) - for {^name, value} <- response.headers, do: value + if Req.MixProject.legacy_headers_as_lists?() do + def get_header(%Req.Response{} = response, name) when is_binary(name) do + name = Req.__ensure_header_downcase__(name) + + for {^name, value} <- response.headers do + value + end + end + else + def get_header(%Req.Response{} = response, name) when is_binary(name) do + name = Req.__ensure_header_downcase__(name) + Map.get(response.headers, name, []) + end end @doc """ @@ -138,14 +166,22 @@ defmodule Req.Response do [{"content-type", "application/json"}] """ @spec put_header(t(), binary(), binary()) :: t() - def put_header(%Req.Response{} = response, name, value) - when is_binary(name) and is_binary(value) do - name = Req.__ensure_header_downcase__(name) - %{response | headers: List.keystore(response.headers, name, 0, {name, value})} + if Req.MixProject.legacy_headers_as_lists?() do + def put_header(%Req.Response{} = response, name, value) + when is_binary(name) and is_binary(value) do + name = Req.__ensure_header_downcase__(name) + %{response | headers: List.keystore(response.headers, name, 0, {name, value})} + end + else + def put_header(%Req.Response{} = response, name, value) + when is_binary(name) and is_binary(value) do + name = Req.__ensure_header_downcase__(name) + put_in(response.headers[name], List.wrap(value)) + end end @doc """ - Deletes the header given by `key` + Deletes the header given by `name`. All occurences of the header are deleted, in case the header is repeated multiple times. @@ -159,17 +195,8 @@ defmodule Req.Response do """ def delete_header(%Req.Response{} = response, name) when is_binary(name) do - name_to_delete = Req.__ensure_header_downcase__(name) - - %Req.Response{ - response - | headers: - for( - {name, value} <- response.headers, - name != name_to_delete, - do: {name, value} - ) - } + name = Req.__ensure_header_downcase__(name) + update_in(response.headers, &Map.delete(&1, name)) end @doc """ diff --git a/lib/req/steps.ex b/lib/req/steps.ex index 0d3ae33e..c7b3b3b1 100644 --- a/lib/req/steps.ex +++ b/lib/req/steps.ex @@ -648,8 +648,18 @@ defmodule Req.Steps do def run_finch(request) do finch_name = finch_name(request) + request_headers = + if unquote(Req.MixProject.legacy_headers_as_lists?()) do + request.headers + else + for {name, values} <- request.headers, + value <- values do + {name, value} + end + end + finch_request = - Finch.build(request.method, request.url, request.headers, request.body) + Finch.build(request.method, request.url, request_headers, request.body) |> Map.replace!(:unix_socket, request.options[:unix_socket]) finch_options = @@ -809,7 +819,7 @@ defmodule Req.Steps do end defp run_plug(request) do - body = + req_body = case request.body do nil -> "" @@ -821,16 +831,27 @@ defmodule Req.Steps do IO.iodata_to_binary(iodata) end + req_headers = + if unquote(Req.MixProject.legacy_headers_as_lists?()) do + request.headers + else + for {name, values} <- request.headers, + value <- values do + {name, value} + end + end + conn = - Plug.Test.conn(request.method, request.url, body) - |> Map.replace!(:req_headers, request.headers) + Plug.Test.conn(request.method, request.url, req_body) + |> Map.replace!(:req_headers, req_headers) |> call_plug(request.options[:plug]) - response = %Req.Response{ - status: conn.status, - headers: conn.resp_headers, - body: conn.resp_body - } + response = + Req.Response.new( + status: conn.status, + headers: conn.resp_headers, + body: conn.resp_body + ) {request, response} end @@ -906,7 +927,7 @@ defmodule Req.Steps do if request.options[:raw] do {request, response} else - codecs = get_content_encoding_header(response.headers) + codecs = compression_algorithms(Req.Response.get_header(response, "content-encoding")) {decompressed_body, unknown_codecs} = decompress_body(codecs, response.body, []) decompressed_content_length = decompressed_body |> byte_size() |> to_string() @@ -961,6 +982,17 @@ defmodule Req.Steps do {body, acc} end + defp compression_algorithms(values) do + values + |> Enum.flat_map(fn value -> + value + |> String.downcase() + |> String.split(",", trim: true) + |> Enum.map(&String.trim/1) + end) + |> Enum.reverse() + end + defmacrop nimble_csv_loaded? do if Code.ensure_loaded?(NimbleCSV) do true @@ -1120,14 +1152,18 @@ defmodule Req.Steps do end defp format(request, response) do - with {_, content_type} <- List.keyfind(response.headers, "content-type", 0) do - # remove ` || ` when we require Elixir v1.13 - path = request.url.path || "" + case Req.Response.get_header(response, "content-type") do + [content_type] -> + # TODO: remove ` || ` when we require Elixir v1.13 + path = request.url.path || "" + + case extensions(content_type, path) do + [ext | _] -> ext + [] -> nil + end - case extensions(content_type, path) do - [ext | _] -> ext - [] -> nil - end + [] -> + [] end end @@ -1234,7 +1270,7 @@ defmodule Req.Steps do end defp build_redirect_request(request, response) do - {_, location} = List.keyfind(response.headers, "location", 0) + [location] = Req.Response.get_header(response, "location") log_level = Req.Request.get_option(request, :redirect_log_level, :debug) log_redirect(log_level, location) @@ -1275,16 +1311,12 @@ defmodule Req.Steps do {request.url.host, request.url.scheme, request.url.port} do request else - remove_credentials(request) + request + |> Req.Request.delete_header("authorization") + |> Req.Request.delete_option(:auth) end end - defp remove_credentials(request) do - headers = List.keydelete(request.headers, "authorization", 0) - request = Req.Request.delete_option(request, :auth) - %{request | headers: headers} - end - @doc """ Handles HTTP 4xx/5xx error responses. @@ -1550,21 +1582,6 @@ defmodule Req.Steps do ## Utilities - defp get_content_encoding_header(headers) do - headers - |> Enum.flat_map(fn {name, value} -> - if String.downcase(name) == "content-encoding" do - value - |> String.downcase() - |> String.split(",", trim: true) - |> Stream.map(&String.trim/1) - else - [] - end - end) - |> Enum.reverse() - end - defp cache_path(cache_dir, request) do cache_key = Enum.join( diff --git a/mix.exs b/mix.exs index 8ca92adf..fd2ef84c 100644 --- a/mix.exs +++ b/mix.exs @@ -81,4 +81,8 @@ defmodule Req.MixProject do ] ] end + + def legacy_headers_as_lists? do + Application.get_env(:req, :legacy_headers_as_lists, false) + end end diff --git a/test/req/httpc_test.exs b/test/req/httpc_test.exs index 39943a09..542a5b3e 100644 --- a/test/req/httpc_test.exs +++ b/test/req/httpc_test.exs @@ -113,7 +113,9 @@ defmodule Req.HttpcTest do httpc_url = request.url |> URI.to_string() |> String.to_charlist() httpc_headers = - for {name, value} <- request.headers do + for {name, values} <- request.headers, + # TODO: values will always be a list on Req 1.0 + value <- List.wrap(values) do {String.to_charlist(name), String.to_charlist(value)} end diff --git a/test/req/request_test.exs b/test/req/request_test.exs index be0344be..7bc38433 100644 --- a/test/req/request_test.exs +++ b/test/req/request_test.exs @@ -1,6 +1,6 @@ defmodule Req.RequestTest do use ExUnit.Case, async: true - doctest Req.Request + doctest Req.Request, except: [delete_header: 2] setup do bypass = Bypass.open() @@ -242,11 +242,19 @@ defmodule Req.RequestTest do authorization = "Basic " <> Base.encode64("foo:bar") - assert [ - {"user-agent", "req/" <> _}, - {"accept-encoding", "zstd, br, gzip"}, - {"authorization", ^authorization} - ] = request.headers + if Req.MixProject.legacy_headers_as_lists?() do + assert [ + {"user-agent", "req/0.3.11"}, + {"accept-encoding", "zstd, br, gzip"}, + {"authorization", ^authorization} + ] = request.headers + else + assert %{ + "user-agent" => ["req/" <> _], + "accept-encoding" => ["zstd, br, gzip"], + "authorization" => [^authorization] + } = request.headers + end end ## Helpers diff --git a/test/req/steps_test.exs b/test/req/steps_test.exs index 625d3f7b..e4f7afa8 100644 --- a/test/req/steps_test.exs +++ b/test/req/steps_test.exs @@ -50,21 +50,19 @@ defmodule Req.StepsTest do test "string" do req = Req.new(auth: "foo") |> Req.Request.prepare() - assert List.keyfind(req.headers, "authorization", 0) == {"authorization", "foo"} + assert Req.Request.get_header(req, "authorization") == ["foo"] end test "basic" do req = Req.new(auth: {"foo", "bar"}) |> Req.Request.prepare() - assert List.keyfind(req.headers, "authorization", 0) == - {"authorization", "Basic #{Base.encode64("foo:bar")}"} + assert Req.Request.get_header(req, "authorization") == ["Basic #{Base.encode64("foo:bar")}"] end test "bearer" do req = Req.new(auth: {:bearer, "abcd"}) |> Req.Request.prepare() - assert List.keyfind(req.headers, "authorization", 0) == - {"authorization", "Bearer abcd"} + assert Req.Request.get_header(req, "authorization") == ["Bearer abcd"] end @tag :tmp_dir @@ -240,14 +238,10 @@ defmodule Req.StepsTest do test "put_range" do req = Req.new(range: "bytes=0-10") |> Req.Request.prepare() - - assert List.keyfind(req.headers, "range", 0) == - {"range", "bytes=0-10"} + assert Req.Request.get_header(req, "range") == ["bytes=0-10"] req = Req.new(range: 0..20) |> Req.Request.prepare() - - assert List.keyfind(req.headers, "range", 0) == - {"range", "bytes=0-20"} + assert Req.Request.get_header(req, "range") == ["bytes=0-20"] end describe "compress_body" do @@ -257,7 +251,7 @@ defmodule Req.StepsTest do req = Req.new(method: :post, json: %{a: 1}, compress_body: true) |> Req.Request.prepare() assert :zlib.gunzip(req.body) |> Jason.decode!() == %{"a" => 1} - assert List.keyfind(req.headers, "content-encoding", 0) == {"content-encoding", "gzip"} + assert Req.Request.get_header(req, "content-encoding") == ["gzip"] end test "stream", c do @@ -723,24 +717,25 @@ defmodule Req.StepsTest do adapter = fn request -> case request.url.host do "original" -> - assert List.keyfind(request.headers, "authorization", 0) + assert [_] = Req.Request.get_header(request, "authorization") - response = %Req.Response{ - status: 301, - headers: [{"location", "http://untrusted"}], - body: "redirecting" - } + response = + Req.Response.new( + status: 301, + headers: [{"location", "http://untrusted"}], + body: "redirecting" + ) {request, response} "untrusted" -> - assert List.keyfind(request.headers, "authorization", 0) + assert [_] = Req.Request.get_header(request, "authorization") - response = %Req.Response{ - status: 200, - headers: [], - body: "bad things" - } + response = + Req.Response.new( + status: 200, + body: "bad things" + ) {request, response} end @@ -902,29 +897,30 @@ defmodule Req.StepsTest do fn request -> case Map.get(request.url, component) do ^original_value -> - assert List.keyfind(request.headers, "authorization", 0) + assert [_] = Req.Request.get_header(request, "authorization") new_url = request.url |> Map.put(component, updated_value) |> to_string() - response = %Req.Response{ - status: 301, - headers: [{"location", new_url}], - body: "redirecting" - } + response = + Req.Response.new( + status: 301, + headers: [{"location", new_url}], + body: "redirecting" + ) {request, response} ^updated_value -> - refute List.keyfind(request.headers, "authorization", 0) + assert [] = Req.Request.get_header(request, "authorization") - response = %Req.Response{ - status: 200, - headers: [], - body: "bad things" - } + response = + Req.Response.new( + status: 200, + body: "bad things" + ) {request, response} end @@ -1402,7 +1398,7 @@ defmodule Req.StepsTest do fun = fn req, finch_request, finch_name, finch_opts -> {:ok, resp} = Finch.request(finch_request, finch_name, finch_opts) send(pid, resp) - {req, %Req.Response{status: resp.status, headers: resp.headers, body: "finch_request"}} + {req, Req.Response.new(status: resp.status, headers: resp.headers, body: "finch_request")} end assert Req.get!(c.url <> "/ok", finch_request: fun).body == "finch_request" diff --git a/test/req_test.exs b/test/req_test.exs index 0f6b82f2..19f1d183 100644 --- a/test/req_test.exs +++ b/test/req_test.exs @@ -35,7 +35,11 @@ defmodule ReqTest do assert headers == [{"x-a", "1"}, {"x-b", "Fri, 01 Jan 2021 09:00:00 GMT"}] req = Req.new(headers: [x_a: 1, x_a: 2]) - assert req.headers == [{"x-a", "1"}, {"x-a", "2"}] + + unless Req.MixProject.legacy_headers_as_lists?() do + assert req.headers == %{"x-a" => ["1", "2"]} + end + Req.get!(req, url: c.url) assert_receive {:headers, headers} assert headers == [{"x-a", "1, 2"}] @@ -51,7 +55,12 @@ defmodule ReqTest do assert inspect(Req.new(auth: {"foo", "bar"})) =~ ~s|auth: {"[redacted]", "[redacted]"}| - assert inspect(Req.new(headers: [authorization: "bearer foo"])) =~ - ~s|{"authorization", "[redacted]"}| + if Req.MixProject.legacy_headers_as_lists?() do + assert inspect(Req.new(headers: [authorization: "bearer foo"])) =~ + ~s|{"authorization", "[redacted]"}| + else + assert inspect(Req.new(headers: [authorization: "bearer foo"])) =~ + ~s|"authorization" => ["[redacted]"]| + end end end