defmodule AccessGrid.HttpClient.Req do
@moduledoc """
HTTP client implementation using Req.
"""
@behaviour AccessGrid.HttpClient.Behaviour
alias AccessGrid.HttpFailure
alias AccessGrid.HttpResponse
@default_receive_timeout 30_000
@doc """
Callback implementation for `c:AccessGrid.HttpClient.Behaviour.delete/2`
"""
@impl true
def delete(url, opts \\ %{}) do
url
|> build_request(opts)
|> Req.delete()
|> handle_response()
end
@doc """
Callback implementation for `c:AccessGrid.HttpClient.Behaviour.get/2`
"""
@impl true
def get(url, opts \\ %{}) do
url
|> build_request(opts)
|> Req.get()
|> handle_response()
end
@doc """
Callback implementation for `c:AccessGrid.HttpClient.Behaviour.head/2`
"""
@impl true
def head(url, opts \\ %{}) do
url
|> build_request(opts)
|> Req.head()
|> handle_response()
end
@doc """
Callback implementation for `c:AccessGrid.HttpClient.Behaviour.patch/2`
"""
@impl true
def patch(url, opts \\ %{}) do
url
|> build_request(opts)
|> Req.patch()
|> handle_response()
end
@doc """
Callback implementation for `c:AccessGrid.HttpClient.Behaviour.post/2`
"""
@impl true
def post(url, opts \\ %{}) do
url
|> build_request(opts)
|> Req.post()
|> handle_response()
end
@doc """
Callback implementation for `c:AccessGrid.HttpClient.Behaviour.put/2`
"""
@impl true
def put(url, opts \\ %{}) do
url
|> build_request(opts)
|> Req.put()
|> handle_response()
end
# # #
defp build_request(url, opts) do
req_opts =
[url: url, decode_body: false, receive_timeout: @default_receive_timeout]
|> maybe_add(:headers, opts[:headers])
|> maybe_add(:params, opts[:params])
|> maybe_add(:receive_timeout, opts[:timeout])
|> maybe_add(:retry, opts[:retry])
|> maybe_add(:redirect, opts[:redirect])
|> add_body(opts[:body], opts[:body_format])
|> maybe_add_test_plug()
Req.new(req_opts)
end
defp maybe_add(req_opts, _key, nil), do: req_opts
defp maybe_add(req_opts, key, value), do: Keyword.put(req_opts, key, value)
# Hook for Req.Test (test env only). The :req_plug config is unset in dev/prod
# so this is a no-op outside of tests. See config/test.exs.
defp maybe_add_test_plug(req_opts) do
case Application.get_env(:accessgrid, :req_plug) do
nil -> req_opts
plug -> Keyword.put(req_opts, :plug, plug)
end
end
defp add_body(req_opts, nil, _format), do: req_opts
defp add_body(req_opts, body, :raw), do: Keyword.put(req_opts, :body, body)
defp add_body(req_opts, body, _format), do: Keyword.put(req_opts, :json, body)
# # #
defp handle_response({:ok, %Req.Response{status: status} = response})
when status >= 200 and status < 300 do
{:ok, build_http_response(response)}
end
defp handle_response({:ok, %Req.Response{} = response}) do
{:error, build_http_failure_from_response(response)}
end
defp handle_response({:error, %Req.TransportError{reason: reason} = error}) do
{:error,
%HttpFailure{
reason: reason,
message: Exception.message(error),
original: error
}}
end
defp handle_response({:error, error}) do
{:error,
%HttpFailure{
reason: :unknown,
message: inspect(error),
original: error
}}
end
# # #
defp build_http_response(%Req.Response{} = response) do
%HttpResponse{
body_decoded: decode_body(response.body, get_content_type(response)),
body_raw: response.body,
content_type: get_content_type(response),
headers: normalize_headers(response.headers),
status: response.status
}
end
defp build_http_failure_from_response(%Req.Response{} = response) do
content_type = get_content_type(response)
%HttpFailure{
body_decoded: decode_body(response.body, content_type),
body_raw: response.body,
content_type: content_type,
message: nil,
original: response,
reason: status_to_reason(response.status),
status: response.status
}
end
# # #
defp get_content_type(%Req.Response{headers: headers}) do
case Map.get(headers, "content-type") do
[content_type | _] -> content_type
_ -> nil
end
end
defp decode_body(body, content_type) when is_binary(body) do
if json_content_type?(content_type) do
case Jason.decode(body) do
{:ok, decoded} -> decoded
{:error, _} -> body
end
else
body
end
end
defp decode_body(body, _content_type), do: body
defp json_content_type?(nil), do: false
defp json_content_type?(content_type), do: String.contains?(content_type, "json")
defp normalize_headers(headers) do
Enum.flat_map(headers, fn {key, values} ->
values
|> List.wrap()
|> Enum.map(&{key, &1})
end)
end
# # #
defp status_to_reason(301), do: :redirect
defp status_to_reason(302), do: :redirect
defp status_to_reason(303), do: :redirect
defp status_to_reason(304), do: :not_modified
defp status_to_reason(307), do: :redirect
defp status_to_reason(308), do: :redirect
defp status_to_reason(status) when status >= 300 and status < 400, do: :redirect
defp status_to_reason(400), do: :bad_request
defp status_to_reason(401), do: :unauthorized
defp status_to_reason(403), do: :forbidden
defp status_to_reason(404), do: :not_found
defp status_to_reason(408), do: :request_timeout
defp status_to_reason(409), do: :conflict
defp status_to_reason(422), do: :unprocessable_entity
defp status_to_reason(429), do: :too_many_requests
defp status_to_reason(status) when status >= 400 and status < 500, do: :client_error
defp status_to_reason(500), do: :internal_server_error
defp status_to_reason(502), do: :bad_gateway
defp status_to_reason(503), do: :service_unavailable
defp status_to_reason(504), do: :gateway_timeout
defp status_to_reason(status) when status >= 500 and status < 600, do: :server_error
defp status_to_reason(_status), do: :unexpected_status
end