diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2fe03d1..7efb086 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -3,17 +3,34 @@ name: CI on: [push, pull_request] jobs: - format: - name: Format and compile with warnings as errors + test: runs-on: ubuntu-latest + env: + MIX_ENV: test + strategy: + fail-fast: false + matrix: + include: + - pair: + elixir: 1.15.x + otp: 26.x + - pair: + elixir: 1.18.x + otp: 27.x steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Install OTP and Elixir uses: erlef/setup-beam@v1 with: - otp-version: 24.1 - elixir-version: 1.12.3 + otp-version: ${{matrix.pair.otp}} + elixir-version: ${{matrix.pair.elixir}} + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: deps + key: mix-deps-${{ hashFiles('**/mix.lock') }} - name: Install dependencies run: mix deps.get @@ -21,31 +38,11 @@ jobs: - name: Run "mix format" run: mix format --check-formatted + - name: Check unused dependencies + run: mix deps.unlock --check-unused + - name: Compile with --warnings-as-errors run: mix compile --warnings-as-errors - test: - name: Test - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - include: - - erlang: 24.1 - elixir: 1.12.3 - - erlang: 24.1 - elixir: 1.13.0 - steps: - - uses: actions/checkout@v2 - - - name: Install OTP and Elixir - uses: erlef/setup-elixir@v1 - with: - otp-version: ${{matrix.erlang}} - elixir-version: ${{matrix.elixir}} - - - name: Install dependencies - run: mix deps.get - - name: Run tests run: mix test --trace diff --git a/lib/http_client/adapter.ex b/lib/http_client/adapter.ex index 7300577..e1955c5 100644 --- a/lib/http_client/adapter.ex +++ b/lib/http_client/adapter.ex @@ -16,7 +16,7 @@ defmodule HTTPClient.Adapter do """ alias HTTPClient.Adapters.{Finch, HTTPoison} - alias HTTPClient.{Error, Response, Telemetry} + alias HTTPClient.{Request, Steps} alias NimbleOptions.ValidationError @typedoc """ @@ -123,50 +123,39 @@ defmodule HTTPClient.Adapter do @doc false def request(adapter, method, url, body, headers, options) do - perform(adapter, :request, [method, url, body, headers, options]) + perform(adapter, method, url, body: body, headers: headers, options: options) end @doc false def get(adapter, url, headers, options) do - perform(adapter, :get, [url, headers, options]) + perform(adapter, :get, url, headers: headers, options: options) end @doc false def post(adapter, url, body, headers, options) do - perform(adapter, :post, [url, body, headers, options]) + perform(adapter, :post, url, body: body, headers: headers, options: options) end @doc false def put(adapter, url, body, headers, options) do - perform(adapter, :put, [url, body, headers, options]) + perform(adapter, :put, url, body: body, headers: headers, options: options) end @doc false def patch(adapter, url, body, headers, options) do - perform(adapter, :patch, [url, body, headers, options]) + perform(adapter, :patch, url, body: body, headers: headers, options: options) end @doc false def delete(adapter, url, headers, options) do - perform(adapter, :delete, [url, headers, options]) + perform(adapter, :delete, url, headers: headers, options: options) end - defp perform(adapter, method, args) do - metadata = %{adapter: adapter, args: args, method: method} - start_time = Telemetry.start(:request, metadata) - - case apply(adapter, method, args) do - {:ok, %Response{status: status, headers: headers} = response} -> - metadata = Map.put(metadata, :status_code, status) - Telemetry.stop(:request, start_time, metadata) - headers = Enum.map(headers, fn {key, value} -> {String.downcase(key), value} end) - {:ok, %{response | headers: headers}} - - {:error, %Error{reason: reason}} = error_response -> - metadata = Map.put(metadata, :error, reason) - Telemetry.stop(:request, start_time, metadata) - error_response - end + defp perform(adapter, method, url, options) do + adapter + |> Request.build(method, url, options) + |> Steps.put_default_steps() + |> Request.run() end defp adapter_mod(:finch), do: HTTPClient.Adapters.Finch diff --git a/lib/http_client/adapters/finch.ex b/lib/http_client/adapters/finch.ex index 5dba10d..3ce81db 100644 --- a/lib/http_client/adapters/finch.ex +++ b/lib/http_client/adapters/finch.ex @@ -3,7 +3,7 @@ defmodule HTTPClient.Adapters.Finch do Implementation of `HTTPClient.Adapter` behaviour using Finch HTTP client. """ - alias HTTPClient.{Error, Response} + alias HTTPClient.{Request, Response} @type method() :: Finch.Request.method() @type url() :: Finch.Request.url() @@ -11,110 +11,89 @@ defmodule HTTPClient.Adapters.Finch do @type body() :: Finch.Request.body() @type options() :: keyword() - @behaviour HTTPClient.Adapter + @doc """ + Performs the request using `Finch`. + """ + def perform_request(request) do + options = prepare_options(request.options) - @delay 1000 + request.method + |> Finch.build(request.url, request.headers, request.body) + |> Finch.request(request.private.finch_name, options) + |> case do + {:ok, %{status: status, body: body, headers: headers}} -> + {request, + Response.new(status: status, body: body, headers: headers, request_url: request.url)} - @impl true - def request(method, url, body, headers, options) do - perform_request(method, url, headers, body, options) + {:error, exception} -> + {request, exception} + end end - @impl true - def get(url, headers, options) do - perform_request(:get, url, headers, nil, options) + @doc false + def proxy(request) do + Request.put_private(request, :finch_name, get_client()) end - @impl true - def post(url, body, headers, options) do - perform_request(:post, url, headers, body, options) + defp prepare_options(options) do + Enum.map(options, &normalize_option/1) end - @impl true - def put(url, body, headers, options) do - perform_request(:put, url, headers, body, options) - end + defp normalize_option({:timeout, value}), do: {:pool_timeout, value} + defp normalize_option({:recv_timeout, value}), do: {:receive_timeout, value} + defp normalize_option({key, value}), do: {key, value} - @impl true - def patch(url, body, headers, options) do - perform_request(:patch, url, headers, body, options) + defp get_client() do + :http_client + |> Application.get_env(:proxy) + |> get_client_name() end - @impl true - def delete(url, headers, options) do - perform_request(:delete, url, headers, nil, options) + defp get_client_name(nil), do: HTTPClient.Finch + + defp get_client_name(proxies) when is_list(proxies) do + proxies + |> Enum.random() + |> get_client_name() end - defp perform_request(method, url, headers, body, options, attempt \\ 0) do - {params, options} = Keyword.pop(options, :params) - {basic_auth, options} = Keyword.pop(options, :basic_auth) + defp get_client_name(proxy) when is_map(proxy) do + name = custom_pool_name(proxy) - url = build_request_url(url, params) - headers = add_basic_auth_header(headers, basic_auth) - options = prepare_options(options) + pools = %{ + default: [ + conn_opts: [proxy: compose_proxy(proxy), proxy_headers: compose_proxy_headers(proxy)] + ] + } - method - |> Finch.build(url, headers, body) - |> Finch.request(get_client(), options) - |> case do - {:ok, %{status: status, body: body, headers: headers}} -> - {:ok, %Response{status: status, body: body, headers: headers, request_url: url}} - - {:error, - %Mint.HTTPError{ - reason: {:proxy, _} - }} -> - case attempt < 5 do - true -> - Process.sleep(attempt * @delay) - perform_request(method, url, headers, body, options, attempt + 1) - - false -> - {:error, %Error{reason: :proxy_error}} - end - - {:error, error} -> - {:error, %Error{reason: error.reason}} - end - end - - defp build_request_url(url, nil), do: url + child_spec = {Finch, name: name, pools: pools} - defp build_request_url(url, params) do - cond do - Enum.count(params) === 0 -> url - URI.parse(url).query -> url <> "&" <> URI.encode_query(params) - true -> url <> "?" <> URI.encode_query(params) + case DynamicSupervisor.start_child(HTTPClient.FinchSupervisor, child_spec) do + {:ok, _} -> name + {:error, {:already_started, _}} -> name end end - defp add_basic_auth_header(headers, {username, password}) do - credentials = Base.encode64("#{username}:#{password}") - [{"Authorization", "Basic " <> credentials} | headers || []] + defp compose_proxy_headers(%{opts: opts}) do + Keyword.get(opts, :proxy_headers, []) end - defp add_basic_auth_header(headers, _basic_auth), do: headers + defp compose_proxy_headers(_), do: [] - defp prepare_options(options) do - Enum.map(options, &normalize_option/1) + defp compose_proxy(proxy) do + {proxy.scheme, proxy.address, to_integer(proxy.port), proxy.opts} end - defp normalize_option({:timeout, value}), do: {:pool_timeout, value} - defp normalize_option({:recv_timeout, value}), do: {:receive_timeout, value} - defp normalize_option({key, value}), do: {key, value} - - defp get_client do - case Application.get_env(:http_client, :proxy, nil) do - nil -> FinchHTTPClient - proxies -> get_client_with_proxy(proxies) - end - end + defp to_integer(term) when is_integer(term), do: term + defp to_integer(term) when is_binary(term), do: String.to_integer(term) - defp get_client_with_proxy(proxy) when is_map(proxy) do - FinchHTTPClientWithProxy_0 - end + defp custom_pool_name(opts) do + name = + opts + |> :erlang.term_to_binary() + |> :erlang.md5() + |> Base.url_encode64(padding: false) - defp get_client_with_proxy(proxies) when is_list(proxies) do - :"FinchHTTPClientWithProxy_#{Enum.random(0..length(proxies))}" + Module.concat(HTTPClient.FinchSupervisor, "Pool_#{name}") end end diff --git a/lib/http_client/adapters/finch/config.ex b/lib/http_client/adapters/finch/config.ex deleted file mode 100644 index 307d150..0000000 --- a/lib/http_client/adapters/finch/config.ex +++ /dev/null @@ -1,44 +0,0 @@ -defmodule HTTPClient.Adapters.Finch.Config do - @moduledoc """ - Provide Application.children for Application supervisor - """ - - @doc """ - Returns list of childrens for Application supervisor - """ - def children do - case Application.get_env(:http_client, :proxy, nil) do - nil -> [{Finch, name: FinchHTTPClient}] - proxies -> generate_finch_proxies(proxies) - end - end - - defp generate_finch_proxies(proxy) when is_map(proxy) do - [ - { - Finch, - name: FinchHTTPClientWithProxy_0, - pools: %{ - default: [conn_opts: [proxy: {proxy.scheme, proxy.address, proxy.port, proxy.opts}]] - } - } - ] - end - - defp generate_finch_proxies(proxies) when is_list(proxies) do - proxies - |> Enum.with_index() - |> Enum.map(fn {proxy, index} -> - Supervisor.child_spec( - { - Finch, - name: :"FinchHTTPClientWithProxy_#{index}", - pools: %{ - default: [conn_opts: [proxy: {proxy.scheme, proxy.address, proxy.port, proxy.opts}]] - } - }, - id: :"FinchHTTPClientWithProxy_#{index}" - ) - end) - end -end diff --git a/lib/http_client/adapters/httpoison.ex b/lib/http_client/adapters/httpoison.ex index 73d3578..08f1718 100644 --- a/lib/http_client/adapters/httpoison.ex +++ b/lib/http_client/adapters/httpoison.ex @@ -3,7 +3,7 @@ defmodule HTTPClient.Adapters.HTTPoison do Implementation of `HTTPClient.Adapter` behaviour using HTTPoison HTTP client. """ - alias HTTPClient.{Error, Response} + alias HTTPClient.Response @type method() :: HTTPoison.Request.method() @type url() :: HTTPoison.Request.url() @@ -11,67 +11,25 @@ defmodule HTTPClient.Adapters.HTTPoison do @type body() :: HTTPoison.Request.body() @type options() :: HTTPoison.Request.options() - @behaviour HTTPClient.Adapter - - @delay 1000 - - @impl true - def request(method, url, body, headers, options) do - perform_request(method, url, headers, body, options) - end - - @impl true - def get(url, headers, options) do - perform_request(:get, url, headers, "", options) - end - - @impl true - def post(url, body, headers, options) do - perform_request(:post, url, headers, body, options) - end - - @impl true - def put(url, body, headers, options) do - perform_request(:put, url, headers, body, options) - end - - @impl true - def patch(url, body, headers, options) do - perform_request(:patch, url, headers, body, options) - end - - @impl true - def delete(url, headers, options) do - perform_request(:delete, url, headers, "", options) - end - - defp perform_request(method, url, headers, body, options, attempt \\ 0) do - options = setup_proxy(options) - options = add_basic_auth_option(options, options[:basic_auth]) - - case HTTPoison.request(method, url, body, headers, options) do - {:ok, %{status_code: status, body: body, headers: headers, request: request}} -> - {:ok, %Response{status: status, body: body, headers: headers, request_url: request.url}} - - {:error, %HTTPoison.Error{id: nil, reason: :proxy_error}} -> - case attempt < 5 do - true -> - Process.sleep(attempt * @delay) - perform_request(method, url, headers, body, options, attempt + 1) + @doc """ + Performs the request using `HTTPoison`. + """ + def perform_request(request) do + options = Map.to_list(request.options) - false -> - {:error, %Error{reason: :proxy_error}} - end + case HTTPoison.request(request.method, request.url, request.body, request.headers, options) do + {:ok, %{status_code: status, body: body, headers: headers}} -> + {request, + Response.new(status: status, body: body, headers: headers, request_url: request.url)} - {:error, error} -> - {:error, %Error{reason: error.reason}} + {:error, exception} -> + {request, exception} end end - defp add_basic_auth_option(options, nil), do: options - - defp add_basic_auth_option(options, {username, password}) do - put_in(options, [:hackney], basic_auth: {username, password}) + @doc false + def proxy(request) do + update_in(request.options, &setup_proxy/1) end defp setup_proxy(options) do @@ -82,7 +40,7 @@ defmodule HTTPClient.Adapters.HTTPoison do end defp add_proxy(options, proxy) when is_map(proxy) do - Keyword.put(options, :proxy, "#{proxy.scheme}://#{proxy.address}:#{proxy.port}") + Map.put(options, :proxy, "#{proxy.scheme}://#{proxy.address}:#{proxy.port}") end defp add_proxy(options, proxies) when is_list(proxies) do diff --git a/lib/http_client/application.ex b/lib/http_client/application.ex index f8e1a36..c892df5 100644 --- a/lib/http_client/application.ex +++ b/lib/http_client/application.ex @@ -4,7 +4,10 @@ defmodule HTTPClient.Application do use Application def start(_type, _args) do - children = [] ++ HTTPClient.Adapters.Finch.Config.children() + children = [ + {Finch, name: HTTPClient.Finch}, + {DynamicSupervisor, strategy: :one_for_one, name: HTTPClient.FinchSupervisor} + ] opts = [strategy: :one_for_one, name: HTTPClient.Supervisor] Supervisor.start_link(children, opts) diff --git a/lib/http_client/error.ex b/lib/http_client/error.ex index 272906e..709b808 100644 --- a/lib/http_client/error.ex +++ b/lib/http_client/error.ex @@ -3,9 +3,19 @@ defmodule HTTPClient.Error do An error of a request. """ - defstruct [:reason] + @type t() :: %__MODULE__{reason: atom()} - @type t :: %__MODULE__{reason: term()} + defexception [:reason] + + @impl true + def exception(reason) when is_atom(reason) do + %__MODULE__{reason: reason} + end + + @impl true + def message(%__MODULE__{reason: reason}) do + "#{reason}" + end defimpl Jason.Encoder do def encode(struct, opts) do diff --git a/lib/http_client/request.ex b/lib/http_client/request.ex new file mode 100644 index 0000000..3f5ed22 --- /dev/null +++ b/lib/http_client/request.ex @@ -0,0 +1,242 @@ +defmodule HTTPClient.Request do + @moduledoc """ + The request struct. + + Struct fields: + + * `:adapter` - an implementation of adapter to use + + * `:options` - steps and adapter options + + * `:method` - the HTTP request method + + * `:url` - the HTTP request URL + + * `:headers` - the HTTP request headers + + * `:body` - the HTTP request body + + * `:halted` - whether the request pipeline is halted. See `halt/1` + + * `:request_steps` - the list of request steps + + * `:response_steps` - the list of response steps + + * `:error_steps` - the list of error steps + + * `:private` - a map reserved for internal use. + + """ + + alias HTTPClient.{Error, Request, Response} + + defstruct [ + :adapter, + method: :get, + url: "", + options: [], + headers: [], + body: "", + halted: false, + request_steps: [], + response_steps: [], + error_steps: [], + private: %{} + ] + + @doc """ + Gets the value for a specific private `key`. + """ + def get_private(request, key, default \\ nil) when is_atom(key) do + Map.get(request.private, key, default) + end + + @doc """ + Assigns a private `key` to `value`. + """ + def put_private(request, key, value) when is_atom(key) do + put_in(request.private[key], value) + end + + @doc """ + Halts the request preventing any further steps from executing. + """ + def halt(request) do + %{request | halted: true} + end + + @doc """ + Builds a request. + """ + def build(adapter, method, url, options \\ []) do + %__MODULE__{ + adapter: adapter, + options: prepare_options(options), + method: method, + url: URI.parse(url), + headers: Keyword.get(options, :headers, []), + body: Keyword.get(options, :body, "") + } + end + + @doc """ + Prepends adapter step to request steps. + """ + def prepend_adapter_step(request) do + prepend_request_step(request, &request.adapter.perform_request/1) + end + + @doc """ + Prepends request step. + """ + def prepend_request_step(request, step) do + update_in(request.request_steps, &[step | &1]) + end + + @doc """ + Reverses request steps. + """ + def reverse_request_steps(request) do + update_in(request.request_steps, &Enum.reverse/1) + end + + @doc """ + Prepends response step. + """ + def prepend_response_step(request, step) do + update_in(request.response_steps, &[step | &1]) + end + + @doc """ + Reverses response steps. + """ + def reverse_response_steps(request) do + update_in(request.response_steps, &Enum.reverse/1) + end + + @doc """ + Prepends error step. + """ + def prepend_error_step(request, step) do + update_in(request.error_steps, &[step | &1]) + end + + @doc """ + Reverses error steps. + """ + def reverse_error_steps(request) do + update_in(request.error_steps, &Enum.reverse/1) + end + + @doc """ + Runs a request pipeline. + + Returns `{:ok, response}` or `{:error, exception}`. + """ + def run(request) do + run_request(request.request_steps, request) + end + + defp run_request([step | steps], request) do + case run_step(step, request) do + %Request{} = request -> + run_request(steps, request) + + {%Request{halted: true}, response_or_exception} -> + result(response_or_exception) + + {request, %Response{} = response} -> + run_response(request, response) + + {request, exception} when is_exception(exception) -> + run_error(request, exception) + end + end + + defp run_request([], request) do + case run_step(&request.adapter.perform_request/1, request) do + {request, %Response{} = response} -> + run_response(request, response) + + {request, exception} when is_exception(exception) -> + run_error(request, exception) + + other -> + raise "expected adapter to return {request, response} or {request, exception}, " <> + "got: #{inspect(other)}" + end + end + + defp run_response(request, response) do + steps = request.response_steps + + {_request, response_or_exception} = + Enum.reduce_while(steps, {request, response}, fn step, {request, response} -> + case run_step(step, {request, response}) do + {%Request{halted: true} = request, response_or_exception} -> + {:halt, {request, response_or_exception}} + + {request, %Response{} = response} -> + {:cont, {request, response}} + + {request, exception} when is_exception(exception) -> + {:halt, run_error(request, exception)} + end + end) + + result(response_or_exception) + end + + defp run_error(request, exception) do + steps = request.error_steps + + {_request, response_or_exception} = + Enum.reduce_while(steps, {request, exception}, fn step, {request, exception} -> + case run_step(step, {request, exception}) do + {%Request{halted: true} = request, response_or_exception} -> + {:halt, {request, response_or_exception}} + + {request, exception} when is_exception(exception) -> + {:cont, {request, exception}} + + {request, %Response{} = response} -> + {:halt, run_response(request, response)} + end + end) + + result(response_or_exception) + end + + @doc false + def run_step(step, state) + + def run_step({module, function, args}, state) do + apply(module, function, [state | args]) + end + + def run_step({module, options}, state) do + apply(module, :run, [state | [options]]) + end + + def run_step(module, state) when is_atom(module) do + apply(module, :run, [state, []]) + end + + def run_step(func, state) when is_function(func, 1) do + func.(state) + end + + defp result(%Response{} = response) do + {:ok, response} + end + + defp result(exception) when is_exception(exception) do + {:error, %Error{reason: Exception.message(exception)}} + end + + defp prepare_options(options) do + options + |> Keyword.get(:options, []) + |> Map.new() + end +end diff --git a/lib/http_client/response.ex b/lib/http_client/response.ex index 5880eb2..a2eca9e 100644 --- a/lib/http_client/response.ex +++ b/lib/http_client/response.ex @@ -1,14 +1,48 @@ defmodule HTTPClient.Response do @moduledoc """ A response to a request. + + Fields: + + * `:status` - the HTTP status code + + * `:headers` - the HTTP response headers + + * `:request_url` - the URL of request + + * `:body` - the HTTP response body + + * `:private` - a map reserved for internal use. """ - defstruct [:request_url, :status, body: "", headers: []] + defstruct [:request_url, :status, body: "", headers: [], private: %{}] - @type t :: %__MODULE__{ + @type t() :: %__MODULE__{ body: binary(), headers: keyword(), + private: map(), request_url: binary(), status: non_neg_integer() } + + @doc """ + Builds `HTTPClient.Response` struct with provided data. + """ + def new(data) do + struct(%__MODULE__{}, data) + end + + @doc """ + Gets the value for a specific private `key`. + """ + def get_private(response, key, default \\ nil) when is_atom(key) do + Map.get(response.private, key, default) + end + + @doc """ + Assigns a private `key` to `value`. + """ + def put_private(response, key, value) when is_atom(key) do + put_in(response.private[key], value) + end end diff --git a/lib/http_client/steps.ex b/lib/http_client/steps.ex new file mode 100644 index 0000000..c78b11f --- /dev/null +++ b/lib/http_client/steps.ex @@ -0,0 +1,676 @@ +defmodule HTTPClient.Steps do + @moduledoc """ + A collection of built-in steps. + """ + + require Logger + + alias HTTPClient.{Request, Response, Telemetry} + + @doc """ + Adds default steps. + """ + def put_default_steps(request) do + request + |> Request.prepend_request_step(&__MODULE__.encode_headers/1) + |> Request.prepend_request_step(&__MODULE__.put_default_headers/1) + |> Request.prepend_request_step(&__MODULE__.encode_body/1) + |> Request.prepend_request_step(&request.adapter.proxy/1) + |> Request.prepend_request_step(&__MODULE__.auth/1) + |> Request.prepend_request_step(&__MODULE__.put_params/1) + |> Request.prepend_request_step(&__MODULE__.log_request_start/1) + |> Request.prepend_adapter_step() + |> Request.prepend_response_step(&__MODULE__.downcase_headers/1) + |> Request.prepend_response_step(&__MODULE__.follow_redirects/1) + |> Request.prepend_response_step(&__MODULE__.decompress_body/1) + |> Request.prepend_response_step(&__MODULE__.decode_body/1) + |> Request.prepend_response_step(&__MODULE__.retry/1) + |> Request.prepend_response_step(&__MODULE__.log_response_end/1) + |> Request.prepend_error_step(&__MODULE__.retry/1) + |> Request.reverse_request_steps() + |> Request.reverse_response_steps() + |> Request.reverse_error_steps() + end + + @doc """ + Adds common request headers. + + Currently the following headers are added: + + * `"accept-encoding"` - `"gzip"` + + """ + def put_default_headers(request) do + put_new_header(request, "accept-encoding", "gzip") + end + + @doc """ + Sets request authentication. + + * `:auth` - sets the `authorization` header: + + * `string` - sets to this value; + + * `{:basic, tuple}` - uses Basic HTTP authentication; + + * `{:bearer, token}` - uses Bearer HTTP authentication; + + """ + def auth(request) do + auth(request, Map.get(request.options, :auth)) + end + + defp auth(request, nil), do: request + + defp auth(request, authorization) when is_binary(authorization) do + put_new_header(request, "authorization", authorization) + end + + defp auth(request, {:bearer, token}) when is_binary(token) do + put_new_header(request, "authorization", "Bearer #{token}") + end + + defp auth(request, {:basic, data}) when is_tuple(data) do + 0 + |> Range.new(tuple_size(data) - 1) + |> Enum.map_join(":", &"#{elem(data, &1)}") + |> Base.encode64() + |> then(&put_new_header(request, "authorization", "Basic #{&1}")) + end + + @doc """ + Encodes request headers. + + Turns atom header names into strings, replacing `-` with `_`. For example, `:user_agent` becomes + `"user-agent"`. Non-atom header names are kept as is. + + If a header value is a `NaiveDateTime` or `DateTime`, it is encoded as "HTTP date". Otherwise, + the header value is encoded with `String.Chars.to_string/1`. + """ + def encode_headers(request) do + headers = + for {name, value} <- request.headers do + {prepare_header_name(name), prepare_header_value(value)} + end + + %{request | headers: headers} + end + + @doc """ + Encodes the request body. + + ## Request Options + + * `:form` - if set, encodes the request body as form data (using `URI.encode_query/1`). + + * `:json` - if set, encodes the request body as JSON (using `Jason.encode_to_iodata!/1`), sets + the `accept` header to `application/json`, and the `content-type` + header to `application/json`. + + """ + def encode_body(%{body: {:form, data}} = request) do + request + |> Map.put(:body, URI.encode_query(data)) + |> put_new_header("content-type", "application/x-www-form-urlencoded") + end + + def encode_body(%{body: {:json, data}} = request) do + request + |> Map.put(:body, Jason.encode_to_iodata!(data)) + |> put_new_header("content-type", "application/json") + |> put_new_header("accept", "application/json") + end + + def encode_body(request), do: request + + @doc """ + Adds params to request query string. + """ + def put_params(request) do + put_params(request, get_options(request.options, :params)) + end + + defp put_params(request, []) do + request + end + + defp put_params(request, params) do + encoded = URI.encode_query(params) + + update_in(request.url.query, fn + nil -> encoded + query -> query <> "&" <> encoded + end) + end + + @doc false + def log_request_start(request) do + metadata = %{ + adapter: request.adapter, + headers: request.headers, + method: request.method, + url: to_string(request.url) + } + + start_time = Telemetry.start(:request, metadata) + update_in(request.private, &Map.put(&1, :request_start_time, start_time)) + end + + @doc """ + Decodes response body based on the detected format. + + Supported formats: + + | Format | Decoder | + | ------ | ---------------------------------------------------------------- | + | json | `Jason.decode!/1` | + | gzip | `:zlib.gunzip/1` | + + """ + + def decode_body({request, %{body: ""} = response}), do: {request, response} + + def decode_body({request, response}) when request.options.raw == true do + {request, response} + end + + def decode_body({request, response}) when request.options.decode_body == false do + {request, response} + end + + def decode_body({request, response}) do + case format(request, response) do + "json" -> + {request, update_in(response.body, &Jason.decode!/1)} + + "gz" -> + {request, update_in(response.body, &:zlib.gunzip/1)} + + _ -> + {request, response} + end + end + + defp format(_request, response) do + with {_, content_type} <- List.keyfind(response.headers, "content-type", 0) do + case MIME.extensions(content_type) do + [ext | _] -> ext + [] -> nil + end + end + end + + @doc """ + Follows redirects. + + The original request method may be changed to GET depending on the status code: + + | Code | Method handling | + | ------------- | ------------------ | + | 301, 302, 303 | Changed to GET | + | 307, 308 | Method not changed | + + ## Request Options + + * `:follow_redirects` - if set to `false`, disables automatic response redirects. + Defaults to `true`. + + * `:location_trusted` - by default, authorization credentials are only sent + on redirects with the same host, scheme and port. If `:location_trusted` is set + to `true`, credentials will be sent to any host. + + * `:max_redirects` - the maximum number of redirects, defaults to `10`. + If the limit is reached, an error is raised. + + * `:redirect_log_level` - the log level to emit redirect logs at. Can also be set + to `false` to disable logging these messsages. Defaults to `:debug`. + + """ + def follow_redirects(request_response) + + def follow_redirects({request, response}) when request.options.follow_redirects == false do + {request, response} + end + + def follow_redirects({request, %{status: status} = response}) + when status in [301, 302, 303, 307, 308] do + max_redirects = Map.get(request.options, :max_redirects, 10) + redirect_count = Request.get_private(request, :req_redirect_count, 0) + + if redirect_count < max_redirects do + request = + request + |> build_redirect_request(response) + |> Request.put_private(:req_redirect_count, redirect_count + 1) + + {_, result} = Request.run(request) + {Request.halt(request), result} + else + raise "too many redirects (#{max_redirects})" + end + end + + def follow_redirects(other) do + other + end + + defp build_redirect_request(request, response) do + location = get_header(response.headers, "location") + log_level = Map.get(request.options, :redirect_log_level, :debug) + log_redirect(log_level, location) + location_trusted = Map.get(request.options, :location_trusted) + location_url = URI.merge(request.url, URI.parse(location)) + + request + |> remove_params() + |> remove_credentials_if_untrusted(location_trusted, location_url) + |> put_redirect_request_method(response.status) + |> put_redirect_location(location_url) + end + + defp log_redirect(false, _location), do: :ok + + defp log_redirect(level, location) do + Logger.log(level, ["follow_redirects: redirecting to ", location]) + end + + defp put_redirect_location(request, location_url) do + put_in(request.url, location_url) + end + + defp put_redirect_request_method(request, status) when status in 307..308, do: request + defp put_redirect_request_method(request, _), do: %{request | method: :get} + + defp remove_credentials_if_untrusted(request, true, _), do: request + + defp remove_credentials_if_untrusted(request, _, location_url) do + if {location_url.host, location_url.scheme, location_url.port} == + {request.url.host, request.url.scheme, request.url.port} do + request + else + remove_credentials(request) + end + end + + defp remove_credentials(request) do + headers = List.keydelete(request.headers, "authorization", 0) + request = update_in(request.options, &Map.delete(&1, :auth)) + %{request | headers: headers} + end + + defp remove_params(request) do + update_in(request.options, &Map.delete(&1, :params)) + end + + @doc """ + Downcase response headers names. + """ + def downcase_headers({request, response}) when is_exception(response) do + {request, response} + end + + def downcase_headers({request, response}) do + headers = for {name, value} <- response.headers, do: {prepare_header_name(name), value} + {request, %{response | headers: headers}} + end + + @doc """ + Decompresses the response body based on the `content-encoding` header. + """ + def decompress_body(request_response) + + def decompress_body({request, response}) + when request.options.raw == true or + response.body == "" or + not is_binary(response.body) do + {request, response} + end + + def decompress_body({request, response}) do + compression_algorithms = get_content_encoding_header(response.headers) + {request, update_in(response.body, &decompress_body(&1, compression_algorithms))} + end + + defp decompress_body(body, algorithms) do + Enum.reduce(algorithms, body, &decompress_with_algorithm(&1, &2)) + end + + defp decompress_with_algorithm(gzip, body) when gzip in ["gzip", "x-gzip"] do + :zlib.gunzip(body) + end + + defp decompress_with_algorithm("deflate", body) do + :zlib.unzip(body) + end + + defp decompress_with_algorithm("identity", body) do + body + end + + defp decompress_with_algorithm(algorithm, _body) do + raise("unsupported decompression algorithm: #{inspect(algorithm)}") + end + + @default_retry_delay :timer.seconds(2) + + @doc """ + Retries a request in face of errors. + + This function can be used as either or both response and error step. + + ## Request Options + + * `:retry` - can be one of the following: + + * `:safe` (default) - retry GET/HEAD requests on HTTP 408/429/5xx + responses or exceptions + + * `:always` - always retry + + * `:condition_step` - step on the execution of which depends on whether + to repeat the request + + * `:delay` - sleep this number of milliseconds before making another + attempt, defaults to `#{@default_retry_delay}`. If the response is + HTTP 429 and contains the `retry-after` header, the value of the header + is used as the next retry delay. + + * `:max_retries` - maximum number of retry attempts, defaults to `2` + (for a total of `3` requests to the server, including the initial one.) + + """ + def retry({request, exception}) when is_exception(exception) do + retry(request, exception) + end + + def retry({request, response}) + when not is_map_key(request.options, :retry) or request.options.retry == :safe do + retry_safe(request, response) + end + + def retry({request, response}) when request.options.retry == :always do + retry(request, response) + end + + def retry({request, response}) do + default_condition = fn {_request, response} -> response.status >= 500 end + condition_step = get_options(request.options.retry, :condition_step, default_condition) + + if Request.run_step(condition_step, {request, response}) do + retry(request, response) + else + {request, response} + end + end + + defp retry_safe(request, response_or_exception) do + if request.method in [:get, :head] do + case response_or_exception do + %Response{status: status} when status in [408, 429] or status in 500..599 -> + retry(request, response_or_exception) + + %Response{} -> + {request, response_or_exception} + + exception when is_exception(exception) -> + retry(request, response_or_exception) + end + else + {request, response_or_exception} + end + end + + defp retry(request, response_or_exception) do + retry_count = Request.get_private(request, :retry_count, 0) + + case configure_retry(request, response_or_exception, retry_count) do + %{retry?: true} = retry_params -> + log_retry(response_or_exception, retry_count, retry_params) + Process.sleep(retry_params.delay) + request = Request.put_private(request, :retry_count, retry_count + 1) + + {_, result} = Request.run(request) + {Request.halt(request), result} + + _ -> + {request, response_or_exception} + end + end + + defp configure_retry(request, response_or_exception, retry_count) do + retry_options = get_options(request.options, :retry) + + case get_retry_delay(retry_options, response_or_exception) do + delay when is_integer(delay) -> + max_retries = get_options(retry_options, :max_retries, 2) + retry = retry_count < max_retries + %{delay: delay, max_retries: max_retries, retry?: retry, type: :linear} + + {:retry_after, delay} -> + %{delay: delay, retry?: true, type: :retry_after} + + :exponent -> + max_retries = get_options(retry_options, :max_retries, 30) + max_cap = get_options(retry_options, :max_cap, :timer.minutes(20)) + delays = cap(exponential_backoff(), max_cap) + retry = retry_count < max_retries + %{delay: Enum.at(delays, retry_count), retry?: retry, type: :exponent} + + :x_rate_limit -> + delay = check_x_rate_limit(response_or_exception) + %{delay: delay, retry?: true, type: :x_rate_limit} + end + end + + defp check_x_rate_limit(%Response{headers: headers}) do + case get_headers(headers, ["x-ratelimit-reset", "x-ratelimit-remaining"]) do + %{"x-ratelimit-remaining" => "0", "x-ratelimit-reset" => timestamp} -> + get_x_rate_limit_delay(timestamp) + + %{"x-ratelimit-remaining" => "", "x-ratelimit-reset" => timestamp} -> + get_x_rate_limit_delay(timestamp) + + %{"x-ratelimit-reset" => timestamp} = headers when map_size(headers) == 1 -> + get_x_rate_limit_delay(timestamp) + + _headers -> + @default_retry_delay + end + end + + defp check_x_rate_limit(_response_or_exception), do: @default_retry_delay + + defp get_x_rate_limit_delay(timestamp) do + with {timestamp, ""} <- Integer.parse(timestamp), + {:ok, datetime} <- DateTime.from_unix(timestamp), + seconds when seconds > 0 <- DateTime.diff(datetime, DateTime.utc_now()) do + :timer.seconds(seconds) + else + _ -> @default_retry_delay + end + end + + defp get_retry_delay(options, %Response{status: 429, headers: headers}) do + case get_header(headers, "retry-after") do + nil -> get_options(options, :delay, @default_retry_delay) + header_delay -> {:retry_after, retry_delay_in_ms(header_delay)} + end + end + + defp get_retry_delay(options, _response_or_exception) do + get_options(options, :delay, @default_retry_delay) + end + + defp exponential_backoff(initial_delay \\ :timer.seconds(1), factor \\ 2) do + Stream.unfold(initial_delay, fn last_delay -> + {last_delay, round(last_delay * factor)} + end) + end + + defp cap(delays, max) do + Stream.map(delays, fn + delay when delay <= max -> delay + _ -> max + end) + end + + defp retry_delay_in_ms(delay_value) do + case Integer.parse(delay_value) do + {seconds, ""} -> + :timer.seconds(seconds) + + :error -> + delay_value + |> parse_http_datetime() + |> DateTime.diff(DateTime.utc_now(), :millisecond) + |> max(0) + end + end + + defp log_retry(response_or_exception, retry_count, retry_params) do + message = + case retry_params do + %{type: :retry_after} -> + "Will retry after #{retry_params.delay}ms" + + %{type: :exponent} -> + "Will retry in #{retry_params.delay}ms, retry count: #{retry_count}" + + %{type: :x_rate_limit} -> + "Will retry after #{retry_params.delay}ms" + + %{max_retries: max_retries} when max_retries - retry_count == 1 -> + "Will retry in #{retry_params.delay}ms, 1 attempt left" + + _retry_params -> + attempts = retry_params.max_retries - retry_count + "Will retry in #{retry_params.delay}ms, #{attempts} attempts left" + end + + case response_or_exception do + exception when is_exception(exception) -> + Logger.error(["retry: got exception. ", message]) + Logger.error(["** (#{inspect(exception.__struct__)}) ", Exception.message(exception)]) + + response -> + Logger.error(["retry: got response with status #{response.status}. ", message]) + end + end + + @doc false + def log_response_end({request, response_or_exception}) do + start_time = request.private.request_start_time + metadata = %{adapter: request.adapter, method: request.method, url: to_string(request.url)} + Telemetry.stop(:request, start_time, enrich_metadata(metadata, response_or_exception)) + {request, response_or_exception} + end + + defp enrich_metadata(metadata, exception) when is_exception(exception) do + Map.put(metadata, :error, Exception.message(exception)) + end + + defp enrich_metadata(metadata, response) do + metadata + |> Map.put(:headers, response.headers) + |> Map.put(:status_code, response.status) + end + + ## Utilities + + defp get_options(options, key, default \\ []) + + defp get_options(options, key, default) when is_map_key(options, key) do + Map.get(options, key, default) + end + + defp get_options(options, key, default), do: options[key] || default + + 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 get_headers(headers, keys) when is_list(keys) do + headers + |> Keyword.take(keys) + |> Map.new() + end + + defp get_header(headers, name, default_value \\ nil) do + Enum.find_value(headers, default_value, fn {key, value} -> + if String.downcase(key) == name, do: value + end) + end + + 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 prepare_header_name(name) when is_atom(name) do + name + |> Atom.to_string() + |> String.replace("_", "-") + |> String.downcase() + end + + defp prepare_header_name(name) when is_binary(name), do: String.downcase(name) + + defp prepare_header_value(%NaiveDateTime{} = naive_datetime), do: naive_datetime + + defp prepare_header_value(%DateTime{} = datetime) do + datetime + |> DateTime.shift_zone!("Etc/UTC") + |> format_http_datetime() + end + + defp prepare_header_value(value), do: String.Chars.to_string(value) + + defp format_http_datetime(datetime) do + Calendar.strftime(datetime, "%a, %d %b %Y %H:%M:%S GMT") + end + + @month_numbers %{ + "Jan" => "01", + "Feb" => "02", + "Mar" => "03", + "Apr" => "04", + "May" => "05", + "Jun" => "06", + "Jul" => "07", + "Aug" => "08", + "Sep" => "09", + "Oct" => "10", + "Nov" => "11", + "Dec" => "12" + } + defp parse_http_datetime(datetime) do + [_day_of_week, day, month, year, time, "GMT"] = String.split(datetime, " ") + date = year <> "-" <> @month_numbers[month] <> "-" <> day + + case DateTime.from_iso8601(date <> " " <> time <> "Z") do + {:ok, valid_datetime, 0} -> + valid_datetime + + {:error, reason} -> + raise "could not parse \"Retry-After\" header #{datetime} - #{reason}" + end + end +end diff --git a/lib/http_client/telemetry.ex b/lib/http_client/telemetry.ex index 7f1c2cc..8016e9c 100644 --- a/lib/http_client/telemetry.ex +++ b/lib/http_client/telemetry.ex @@ -13,8 +13,9 @@ defmodule HTTPClient.Telemetry do #### Metadata: * `:adapter` - The name of adapter impelementation. - * `:args` - The arguments passed in the request (url, headers, etc.). + * `:headers` - The headers passed in the request. * `:method` - The method used in the request. + * `:url` - The requested url. * `[:http_client, :request, :stop]` - Executed after a request is finished. @@ -23,8 +24,9 @@ defmodule HTTPClient.Telemetry do #### Metadata: * `:adapter` - The name of adapter impelementation. - * `:args` - The arguments passed in the request (url, headers, etc.). + * `:headers` - The headers passed in the response. * `:method` - The method used in the request. + * `:url` - The requested url. * `:status_code` - This value is optional. The response status code. * `:error` - This value is optional. It includes any errors that occured while making the request. """ diff --git a/mix.exs b/mix.exs index 762dd83..c2f20f2 100644 --- a/mix.exs +++ b/mix.exs @@ -2,14 +2,14 @@ defmodule HTTPClient.MixProject do use Mix.Project @name "HTTPClient" - @version "0.3.7" + @version "0.4.0" @repo_url "https://github.com/ChannexIO/http_client" def project do [ app: :http_client, version: @version, - elixir: "~> 1.12", + elixir: "~> 1.13", description: "Facade for HTTP client.", docs: docs(), start_permanent: Mix.env() == :prod, @@ -28,15 +28,16 @@ defmodule HTTPClient.MixProject do defp deps do [ - {:nimble_options, "~> 0.4"}, - {:httpoison, "~> 1.8"}, - {:finch, "~> 0.11"}, - {:telemetry, "~> 1.0"}, - {:jason, "~> 1.3"}, - {:plug, "~> 1.12", only: :test, override: true}, - {:plug_cowboy, "~> 2.5", only: :test, override: true}, + {:nimble_options, "~> 1.1"}, + {:httpoison, "~> 2.2"}, + {:finch, "~> 0.19"}, + {:telemetry, "~> 1.3"}, + {:jason, "~> 1.4"}, + {:mime, "~> 2.0"}, + {:plug, "~> 1.17", only: :test}, + {:bandit, "~> 1.6", only: :test}, {:bypass, "~> 2.1", only: :test}, - {:ex_doc, "~> 0.27.3", only: :dev, runtime: false} + {:ex_doc, ">= 0.0.0", only: :dev, runtime: false} ] end diff --git a/mix.lock b/mix.lock index 6b93eff..f3d9189 100644 --- a/mix.lock +++ b/mix.lock @@ -1,35 +1,36 @@ %{ + "bandit": {:hex, :bandit, "1.6.11", "2fbadd60c95310eefb4ba7f1e58810aa8956e18c664a3b2029d57edb7d28d410", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "543f3f06b4721619a1220bed743aa77bf7ecc9c093ba9fab9229ff6b99eacc65"}, "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, - "castore": {:hex, :castore, "0.1.16", "2675f717adc700475345c5512c381ef9273eb5df26bdd3f8c13e2636cf4cc175", [:mix], [], "hexpm", "28ed2c43d83b5c25d35c51bc0abf229ac51359c170cba76171a462ced2e4b651"}, - "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, - "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, + "certifi": {:hex, :certifi, "2.14.0", "ed3bef654e69cde5e6c022df8070a579a79e8ba2368a00acf3d75b82d9aceeed", [:rebar3], [], "hexpm", "ea59d87ef89da429b8e905264fdec3419f84f2215bb3d81e07a18aac919026c3"}, + "cowboy": {:hex, :cowboy, "2.13.0", "09d770dd5f6a22cc60c071f432cd7cb87776164527f205c5a6b0f24ff6b38990", [:make, :rebar3], [{:cowlib, ">= 2.14.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e724d3a70995025d654c1992c7b11dbfea95205c047d86ff9bf1cda92ddc5614"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, - "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, - "earmark": {:hex, :earmark, "1.4.4", "4821b8d05cda507189d51f2caeef370cf1e18ca5d7dfb7d31e9cafe6688106a4", [:mix], [], "hexpm", "1f93aba7340574847c0f609da787f0d79efcab51b044bb6e242cae5aca9d264d"}, - "earmark_parser": {:hex, :earmark_parser, "1.4.25", "2024618731c55ebfcc5439d756852ec4e85978a39d0d58593763924d9a15916f", [:mix], [], "hexpm", "56749c5e1c59447f7b7a23ddb235e4b3defe276afc220a6227237f3efe83f51e"}, - "ex_doc": {:hex, :ex_doc, "0.27.3", "d09ed7ab590b71123959d9017f6715b54a448d76b43cf909eb0b2e5a78a977b2", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "ee60b329d08195039bfeb25231a208749be4f2274eae42ce38f9be0538a2f2e6"}, - "finch": {:hex, :finch, "0.11.0", "622d31c224c801444c6003544fa1964a29551895b45c3543f6e4d8b0dab80f71", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "047550448148d828ddea1fb76fa3f0d2d0742eca6919e548fdd8357630cae449"}, - "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, - "hpax": {:hex, :hpax, "0.1.1", "2396c313683ada39e98c20a75a82911592b47e5c24391363343bde74f82396ca", [:mix], [], "hexpm", "0ae7d5a0b04a8a60caf7a39fcf3ec476f35cc2cc16c05abea730d3ce6ac6c826"}, - "httpoison": {:hex, :httpoison, "1.8.1", "df030d96de89dad2e9983f92b0c506a642d4b1f4a819c96ff77d12796189c63e", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "35156a6d678d6d516b9229e208942c405cf21232edd632327ecfaf4fd03e79e0"}, - "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, - "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, - "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, - "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, - "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, + "cowlib": {:hex, :cowlib, "2.15.0", "3c97a318a933962d1c12b96ab7c1d728267d2c523c25a5b57b0f93392b6e9e25", [:make, :rebar3], [], "hexpm", "4f00c879a64b4fe7c8fcb42a4281925e9ffdb928820b03c3ad325a617e857532"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, + "ex_doc": {:hex, :ex_doc, "0.37.3", "f7816881a443cd77872b7d6118e8a55f547f49903aef8747dbcb345a75b462f9", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "e6aebca7156e7c29b5da4daa17f6361205b2ae5f26e5c7d8ca0d3f7e18972233"}, + "finch": {:hex, :finch, "0.19.0", "c644641491ea854fc5c1bbaef36bfc764e3f08e7185e1f084e35e0672241b76d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "fc5324ce209125d1e2fa0fcd2634601c52a787aff1cd33ee833664a5af4ea2b6"}, + "hackney": {:hex, :hackney, "1.23.0", "55cc09077112bcb4a69e54be46ed9bc55537763a96cd4a80a221663a7eafd767", [:rebar3], [{:certifi, "~> 2.14.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "6cd1c04cd15c81e5a493f167b226a15f0938a84fc8f0736ebe4ddcab65c0b44e"}, + "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "httpoison": {:hex, :httpoison, "2.2.3", "a599d4b34004cc60678999445da53b5e653630651d4da3d14675fedc9dd34bd6", [:mix], [{:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "fa0f2e3646d3762fdc73edb532104c8619c7636a6997d20af4003da6cfc53e53"}, + "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, + "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, - "mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"}, - "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, - "mint": {:hex, :mint, "1.4.1", "49b3b6ea35a9a38836d2ad745251b01ca9ec062f7cb66f546bf22e6699137126", [:mix], [{:castore, "~> 0.1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "cd261766e61011a9079cccf8fa9d826e7a397c24fbedf0e11b49312bea629b58"}, - "nimble_options": {:hex, :nimble_options, "0.4.0", "c89babbab52221a24b8d1ff9e7d838be70f0d871be823165c94dd3418eea728f", [:mix], [], "hexpm", "e6701c1af326a11eea9634a3b1c62b475339ace9456c1a23ec3bc9a847bca02d"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, - "nimble_pool": {:hex, :nimble_pool, "0.2.6", "91f2f4c357da4c4a0a548286c84a3a28004f68f05609b4534526871a22053cde", [:mix], [], "hexpm", "1c715055095d3f2705c4e236c18b618420a35490da94149ff8b580a2144f653f"}, - "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, - "plug": {:hex, :plug, "1.13.4", "addb6e125347226e3b11489e23d22a60f7ab74786befb86c14f94fb5f23ca9a4", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "06114c1f2a334212fe3ae567dbb3b1d29fd492c1a09783d52f3d489c1a6f4cf2"}, - "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"}, - "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, - "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, - "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, - "telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"}, + "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, + "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, + "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, + "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, + "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, + "plug": {:hex, :plug, "1.17.0", "a0832e7af4ae0f4819e0c08dd2e7482364937aea6a8a997a679f2cbb7e026b2e", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f6692046652a69a00a5a21d0b7e11fcf401064839d59d6b8787f23af55b1e6bc"}, + "plug_cowboy": {:hex, :plug_cowboy, "2.7.3", "1304d36752e8bdde213cea59ef424ca932910a91a07ef9f3874be709c4ddb94b", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "77c95524b2aa5364b247fa17089029e73b951ebc1adeef429361eab0bb55819d"}, + "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "ranch": {:hex, :ranch, "1.8.1", "208169e65292ac5d333d6cdbad49388c1ae198136e4697ae2f474697140f201c", [:make, :rebar3], [], "hexpm", "aed58910f4e21deea992a67bf51632b6d60114895eb03bb392bb733064594dd0"}, + "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, + "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "thousand_island": {:hex, :thousand_island, "1.3.12", "590ff651a6d2a59ed7eabea398021749bdc664e2da33e0355e6c64e7e1a2ef93", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "55d0b1c868b513a7225892b8a8af0234d7c8981a51b0740369f3125f7c99a549"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, + "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, } diff --git a/test/http_client_test.exs b/test/http_client_test.exs index c66ff25..9f548d7 100644 --- a/test/http_client_test.exs +++ b/test/http_client_test.exs @@ -1,5 +1,6 @@ defmodule HTTPClientTest do - use ExUnit.Case + use ExUnit.Case, async: true + doctest HTTPClient alias HTTPClient.{Error, Response} @@ -35,7 +36,7 @@ defmodule HTTPClientTest do test "get/3 error response", %{bypass: bypass} do Bypass.down(bypass) - assert {:error, %Error{reason: :econnrefused}} == + assert {:error, %Error{reason: "connection refused"}} == TestFinchRequest.get(endpoint(bypass), [], []) end @@ -44,7 +45,7 @@ defmodule HTTPClientTest do response_body = ~s({"right":"here"}) Bypass.expect_once(bypass, "POST", "/", fn conn -> - assert conn.query_string == "a=1&b=2" + assert %{"a" => "1", "b" => "2"} == URI.decode_query(conn.query_string) assert {_, "application/json"} = Enum.find(conn.req_headers, &(elem(&1, 0) == "content-type")) @@ -58,7 +59,7 @@ defmodule HTTPClientTest do end) headers = [{"content-type", "application/json"}] - options = [params: %{a: 1, b: 2}, basic_auth: {"username", "password"}] + options = [params: %{a: 1, b: 2}, auth: {:basic, {"username", "password"}}] assert {:ok, %Response{status: 200, body: ^response_body}} = TestFinchRequest.post(endpoint(bypass), req_body, headers, options) @@ -67,7 +68,7 @@ defmodule HTTPClientTest do test "post/4 error response", %{bypass: bypass} do Bypass.down(bypass) - assert {:error, %Error{reason: :econnrefused}} == + assert {:error, %Error{reason: "connection refused"}} == TestFinchRequest.post(endpoint(bypass), "{}", [], []) end @@ -83,7 +84,7 @@ defmodule HTTPClientTest do test "request/5 error response", %{bypass: bypass} do Bypass.down(bypass) - assert {:error, %Error{reason: :econnrefused}} == + assert {:error, %Error{reason: "connection refused"}} == TestFinchRequest.request(:post, endpoint(bypass), "{}", [], []) end end @@ -109,27 +110,23 @@ defmodule HTTPClientTest do assert is_integer(measurements.system_time) assert meta.adapter == HTTPClient.Adapters.HTTPoison - assert meta.args == [ - endpoint(bypass), - [{"content-type", "application/json"}], - [params: %{a: 1, b: 2}, basic_auth: {"username", "password"}] + assert meta.headers == [ + {"authorization", "Basic dXNlcm5hbWU6cGFzc3dvcmQ="}, + {"accept-encoding", "gzip"}, + {"content-type", "application/json"} ] assert meta.method == :get + assert URI.new!(meta.url) == URI.new!(endpoint(bypass, "/?a=1&b=2")) send(parent, {ref, :start}) [:http_client, :request, :stop] -> assert is_integer(measurements.duration) assert meta.adapter == HTTPClient.Adapters.HTTPoison - - assert meta.args == [ - endpoint(bypass), - [{"content-type", "application/json"}], - [params: %{a: 1, b: 2}, basic_auth: {"username", "password"}] - ] - + assert is_list(meta.headers) assert meta.method == :get assert meta.status_code == 200 + assert URI.new!(meta.url) == URI.new!(endpoint(bypass, "/?a=1&b=2")) send(parent, {ref, :stop}) _ -> @@ -148,7 +145,7 @@ defmodule HTTPClientTest do ) headers = [{"content-type", "application/json"}] - options = [params: %{a: 1, b: 2}, basic_auth: {"username", "password"}] + options = [params: %{a: 1, b: 2}, auth: {:basic, {"username", "password"}}] assert {:ok, %{status: 200}} = TestDefaultRequest.get(endpoint(bypass), headers, options) assert_receive {^ref, :start} @@ -179,7 +176,8 @@ defmodule HTTPClientTest do status: 200 } = default_response - assert request_url == url <> "?a=1&b=2" + assert %{query: query} = URI.parse(request_url) + assert %{"a" => "1", "b" => "2"} == URI.decode_query(query) end end