defmodule Braintree.HTTP do
@moduledoc """
Base client for all server interaction, used by all endpoint specific
modules.
This request wrapper coordinates the remote server, headers, authorization
and SSL options.
Using `Braintree.HTTP` requires the presence of three config values:
* `merchant_id` - Braintree merchant id
* `private_key` - Braintree private key
* `public_key` - Braintree public key
All three values must be set or a `Braintree.ConfigError` will be raised at
runtime. All those config values support the `{:system, "VAR_NAME"}` as a
value - in which case the value will be read from the system environment with
`System.get_env("VAR_NAME")`.
"""
require Logger
alias Braintree.ErrorResponse, as: Error
alias Braintree.XML.{Decoder, Encoder}
@type response ::
{:ok, map | {:error, atom}}
| {:error, Error.t()}
| {:error, binary}
@production_endpoint "https://api.braintreegateway.com/merchants/"
@sandbox_endpoint "https://api.sandbox.braintreegateway.com/merchants/"
@cacertfile "/certs/api_braintreegateway_com.ca.crt"
@headers [
{"Accept", "application/xml"},
{"User-Agent", "Braintree Elixir/0.1"},
{"Accept-Encoding", "gzip"},
{"X-ApiVersion", "4"},
{"Content-Type", "application/xml"}
]
@statuses %{
400 => :bad_request,
401 => :unauthorized,
403 => :forbidden,
404 => :not_found,
422 => :unprocessable_entity,
426 => :upgrade_required,
429 => :too_many_requests,
500 => :server_error,
503 => :service_unavailable,
504 => :connect_timeout
}
@doc """
Centralized request handling function. All convenience structs use this
function to interact with the Braintree servers. This function can be used
directly to supplement missing functionality.
## Example
defmodule MyApp.Disbursement do
alias Braintree.HTTP
def disburse(params \\ %{}) do
HTTP.request(:get, "disbursements", params)
end
end
"""
@spec request(atom, binary, binary | map, Keyword.t()) :: response
def request(method, path, body \\ %{}, opts \\ []) do
emit_start(method, path)
start_time = System.monotonic_time()
try do
:hackney.request(
method,
build_url(path, opts),
build_headers(opts),
encode_body(body),
build_options()
)
catch
kind, reason ->
duration = System.monotonic_time() - start_time
emit_exception(duration, method, path, %{
kind: kind,
reason: reason,
stacktrace: __STACKTRACE__
})
:erlang.raise(kind, reason, __STACKTRACE__)
else
{:ok, code, _headers, body} when code in 200..399 ->
duration = System.monotonic_time() - start_time
emit_stop(duration, method, path, code)
{:ok, decode_body(body)}
{:ok, 422, _headers, body} ->
duration = System.monotonic_time() - start_time
emit_stop(duration, method, path, 422)
{
:error,
body
|> decode_body()
|> resolve_error_response()
}
{:ok, code, _headers, _body} when code in 400..504 ->
duration = System.monotonic_time() - start_time
emit_stop(duration, method, path, code)
{:error, code_to_reason(code)}
{:error, reason} ->
duration = System.monotonic_time() - start_time
emit_error(duration, method, path, reason)
{:error, reason}
end
end
for method <- ~w(get delete post put)a do
@spec unquote(method)(binary) :: response
@spec unquote(method)(binary, map | list) :: response
@spec unquote(method)(binary, map, list) :: response
def unquote(method)(path) do
request(unquote(method), path, %{}, [])
end
def unquote(method)(path, payload) when is_map(payload) do
request(unquote(method), path, payload, [])
end
def unquote(method)(path, opts) when is_list(opts) do
request(unquote(method), path, %{}, opts)
end
def unquote(method)(path, payload, opts) do
request(unquote(method), path, payload, opts)
end
end
## Helper Functions
@doc false
@spec build_url(binary, Keyword.t()) :: binary
def build_url(path, opts) do
environment = opts |> get_lazy_env(:environment) |> maybe_to_atom()
merchant_id = get_lazy_env(opts, :merchant_id)
Keyword.fetch!(endpoints(), environment) <> merchant_id <> "/" <> path
end
defp maybe_to_atom(value) when is_binary(value), do: String.to_existing_atom(value)
defp maybe_to_atom(value) when is_atom(value), do: value
@doc false
@spec encode_body(binary | map) :: binary
def encode_body(body) when body == "" or body == %{}, do: ""
def encode_body(body), do: Encoder.dump(body)
@doc false
@spec decode_body(binary) :: map
def decode_body(body) do
body
|> :zlib.gunzip()
|> String.trim()
|> Decoder.load()
rescue
ErlangError -> Logger.error("unprocessable response")
end
@doc false
@spec build_headers(Keyword.t()) :: [tuple]
def build_headers(opts) do
auth_header =
case get_lazy_env(opts, :access_token, :none) do
token when is_binary(token) ->
"Bearer " <> token
_ ->
username = get_lazy_env(opts, :public_key)
password = get_lazy_env(opts, :private_key)
"Basic " <> :base64.encode("#{username}:#{password}")
end
[{"Authorization", auth_header} | @headers]
end
defp get_lazy_env(opts, key, default \\ nil) do
Keyword.get_lazy(opts, key, fn -> Braintree.get_env(key, default) end)
end
@doc false
@spec build_options() :: [...]
def build_options do
cacertfile = Path.join(:code.priv_dir(:braintree), @cacertfile)
http_opts = Braintree.get_env(:http_options, [])
[:with_body, ssl_options: [cacertfile: cacertfile]] ++ http_opts
end
@doc false
@spec code_to_reason(integer) :: atom
def code_to_reason(integer)
for {code, status} <- @statuses do
def code_to_reason(unquote(code)), do: unquote(status)
end
defp resolve_error_response(%{"api_error_response" => api_error_response}) do
Error.new(api_error_response)
end
defp resolve_error_response(%{"unprocessable_entity" => _}) do
Error.new(%{message: "Unprocessable Entity"})
end
defp endpoints do
[production: @production_endpoint, sandbox: sandbox_endpoint()]
end
defp sandbox_endpoint do
Application.get_env(
:braintree,
:sandbox_endpoint,
@sandbox_endpoint
)
end
defp emit_start(method, path) do
:telemetry.execute(
[:braintree, :request, :start],
%{system_time: System.system_time()},
%{method: method, path: path}
)
end
defp emit_exception(duration, method, path, error_data) do
:telemetry.execute(
[:braintree, :request, :exception],
%{duration: duration},
%{method: method, path: path, error: error_data}
)
end
defp emit_error(duration, method, path, error_reason) do
:telemetry.execute(
[:braintree, :request, :error],
%{duration: duration},
%{method: method, path: path, error: error_reason}
)
end
defp emit_stop(duration, method, path, code) do
:telemetry.execute(
[:braintree, :request, :stop],
%{duration: duration},
%{method: method, path: path, http_status: code}
)
end
end