Current section
Files
Jump to
Current section
Files
lib/http_ex.ex
defmodule HTTPEx do
@moduledoc """
HTTPEx provides common functionality to do HTTP calls like GET and POST via a HTTP client (like HTTPoison, Finch etc.).
It adds a couple of additional features on top of a HTTP library:
- Adds OT tracing
- Adds a standarized Request, Response and Error struct
- Switch between different HTTP clients (like HTTPoison, Finch etc.)
- Adds a standarized response
- Adds retry mechanism when timeout of connection closed is encountered
- Adds a stub HTTP client that is able to mock requests for all possibile implementations that use this module
See HTTPEx.TestBackend to see how stubbing out requests and responses work.
"""
alias HTTPEx.Error
alias HTTPEx.Logging
alias HTTPEx.Request
alias HTTPEx.Response
alias HTTPEx.Shared
import HTTPEx.Clients, only: [def_to_response: 0]
@backend Application.compile_env(:http_ex, :backend, HTTPEx.Backend.Default)
@retry_wait 100
@doc """
Executes GET request
## Examples
iex> HTTPEx.get("http://www.example.com", backend: MockBackend)
{:ok, %HTTPEx.Response{body: "OK!", client: :httpoison, retries: 1, status: 200, parsed_body: nil, headers: []}}
iex> HTTPEx.get("http://www.example.com", backend: MockBackend, client: :finch)
{:ok, %HTTPEx.Response{body: "OK!", client: :httpoison, retries: 1, status: 200, parsed_body: nil, headers: []}}
iex> HTTPEx.get("http://www.example.com/json", headers: [{"Content-Type", "application/json"}], backend: MockBackend)
{
:ok,
%HTTPEx.Response{
body: "{\\"payload\\":{\\"items\\":[1,2,3]}}",
client: :httpoison,
headers: [],
parsed_body: %{"payload" => %{"items" => [1, 2, 3]}},
retries: 1,
status: 202
}
}
iex> HTTPEx.get("http://www.example.com/error", backend: MockBackend)
{
:error,
%HTTPEx.Error{
body: "{\\"errors\\":[{\\"code\\":\\"invalid_payload\\"}]}",
client: :httpoison,
headers: [],
parsed_body: %{"errors" => [%{"code" => "invalid_payload"}]},
retries: 1,
status: 422,
reason: :unprocessable_entity
}
}
"""
@spec get(String.t(), Keyword.t()) :: {:ok, Response.t()} | {:error, Error.t()}
def get(url, options \\ []) when is_binary(url) and is_list(options),
do:
request(%Request{
client: Keyword.get(options, :client),
headers: Keyword.get(options, :headers, []),
method: :get,
url: url,
options: options |> Keyword.delete(:headers) |> Keyword.delete(:client)
})
@doc """
Executes POST request
## Examples
iex> HTTPEx.post("http://www.example.com", JSON.encode!(%{"data" => true}), backend: MockBackend)
{:ok, %HTTPEx.Response{body: "OK!", client: :httpoison, retries: 1, status: 200, parsed_body: nil, headers: []}}
"""
@spec post(
String.t(),
iodata()
| {:stream, Enumerable.t()}
| {:multipart, Enumerable.t()}
| {:form, Enumerable.t()}
| nil,
Keyword.t()
) :: {:ok, Response.t()} | {:error, Error.t()}
def post(url, body, options \\ [])
when is_binary(url) and
(is_nil(body) or is_list(body) or is_binary(body) or
(is_tuple(body) and tuple_size(body) == 2)) and
is_list(options),
do:
request(%Request{
client: Keyword.get(options, :client),
body: body,
headers: Keyword.get(options, :headers, []),
method: :post,
url: url,
options: request_options(options)
})
@doc """
Executes a Request
## Examples
iex> request = %HTTPEx.Request{
...> method: :get,
...> client: :finch,
...> url: "http://www.example.com",
...> options: [backend: MockBackend],
...> }
...> HTTPEx.request(request)
{:ok, %HTTPEx.Response{body: "OK!", client: :httpoison, retries: 1, status: 200, parsed_body: nil, headers: []}}
iex> request = %HTTPEx.Request{
...> method: :post,
...> body: JSON.encode!(%{"data" => true}),
...> url: "http://www.example.com/json",
...> headers: [{"Content-Type", "application/json"}],
...> options: [backend: MockBackend],
...> }
...> HTTPEx.request(request)
{
:ok,
%HTTPEx.Response{
body: "{\\"payload\\":{\\"label\\":\\"ABCD\\"}}",
client: :httpoison,
headers: [],
parsed_body: %{"payload" => %{"label" => "ABCD"}},
retries: 1,
status: 200
}
}
"""
@spec request(Request.t()) :: {:ok, Response.t()} | {:error, Error.t()}
def request(%Request{} = request) do
# Make sure the request body contains correct data
validate_body!(request)
# Wraps the entire HTTP call into one single `HTTP` span
# Each retry will spawn with a `[method]` underneath it.
Logging.span("HTTP", fn ->
request
|> Request.init()
|> Logging.trace()
|> request_with_retries()
|> Logging.trace()
end)
end
@doc """
Gets value of request, response or error headers.
## Examples
iex> HTTPEx.get_header(%Request{method: :get, url: "http://example.com", headers: [{"Content-Type", "application/json"}, {"Secret", "123"}]}, "content-type")
"application/json"
iex> HTTPEx.get_header(%Response{headers: [{"Content-Type", "application/json"}, {"Secret", "123"}]}, "Secret")
"123"
iex> HTTPEx.get_header(%Response{headers: [{"Content-Type", "application/json"}, {"Secret", "123"}]}, "non-existent-key")
nil
"""
@spec get_header(Request.t() | Response.t() | Error.t() | list(tuple()), String.t()) ::
String.t() | nil
def get_header(%Request{} = request, key) when is_binary(key),
do: get_header(request.headers, key)
def get_header(%Response{} = response, key) when is_binary(key),
do: get_header(response.headers, key)
def get_header(%Error{} = error, key) when is_binary(key), do: get_header(error.headers, key)
def get_header(headers, key) when is_list(headers) and is_binary(key) do
case Enum.find(headers, fn {existing_key, _} ->
String.downcase(key) == String.downcase(existing_key)
end) do
{_key, value} -> value
_ -> nil
end
end
def_to_response()
defp retry?(_request, {:ok, %Response{}}), do: false
defp retry?(request, {:error, %Error{} = error}) do
retry_timeout = Keyword.get(request.options, :transport_retry_timeout)
max_retries = Keyword.get(request.options, :transport_max_retries)
reached_retries? = request.retries >= max_retries
time_diff = :erlang.monotonic_time(:millisecond) - request.start_time
reached_timeout? = time_diff >= retry_timeout
retry_enabled?() and can_be_retried?(request, error) and
not reached_retries? and not reached_timeout?
end
defp can_be_retried?(%Request{} = request, %Error{status: status}) when is_number(status),
do: status in Keyword.get(request.options, :retry_status_codes, [])
defp can_be_retried?(%Request{} = request, %Error{reason: reason}) when is_atom(reason),
do: reason in Keyword.get(request.options, :retry_error_codes)
defp can_be_retried?(_request, _error), do: false
defp request_with_retries(%Request{} = request) do
# Wrap the actual call in a span underneath the HTTP span with the name of the method
# This results in the following trace:
#
# - HTTP
# - POST
# - retry_sleep
# - POST
if request.retries >= 2 do
Logging.span("retry_sleep", fn ->
Process.sleep(@retry_wait * request.retries)
end)
end
backend = request.options[:backend] || @backend
response =
Logging.span(Request.trace_method(request.method), fn ->
request
|> Logging.trace()
|> Logging.log()
|> backend.request()
|> to_response(request.retries)
|> validate_response(request)
|> Logging.trace()
|> Logging.log()
end)
if retry?(request, response) do
request
|> Request.increase_retries()
|> request_with_retries()
else
response
end
end
defp retry_enabled?, do: Shared.config(:retry, true) == true
defp request_options(options),
do: options |> Keyword.delete(:headers) |> Keyword.delete(:client)
defp validate_response({:ok, %Response{} = response} = result, %Request{} = request) do
if request.options[:allow_redirect] == false && response.status in [301, 302, 303, 307] do
{:error,
%Error{
body: response.body,
client: response.client,
headers: response.headers,
parsed_body: response.parsed_body,
reason: :redirect_not_allowed,
retries: response.retries,
status: response.status
}}
else
result
end
end
defp validate_response({:error, %Error{}} = result, _request), do: result
defp validate_body!(%{body: {:stream, _}}), do: :ok
defp validate_body!(%{body: {:multipart, _}}), do: :ok
defp validate_body!(%{body: {:form, _}}), do: :ok
defp validate_body!(%{body: body}) when is_binary(body) or is_list(body), do: :ok
defp validate_body!(%{body: nil}), do: :ok
defp validate_body!(_), do: raise(ArgumentError, "Incorrect body given")
end