Packages
mercury_client
1.0.0
A complete, production-grade Elixir client for the Mercury Banking API (accounts, transactions, recipients, invoices, payments, treasury, webhooks, and more), with typed errors, retry with backoff, and lazy auto-paginating streams.
Current section
Files
Jump to
Current section
Files
lib/mercury/http.ex
defmodule Mercury.HTTP do
@moduledoc false
# Internal HTTP transport shared by every resource module. Not part of the
# public API — use the functions on `Mercury.Client` and the resource
# modules instead.
alias Mercury.{Client, Error, Multipart, QueryParams, Retry}
@spec get(client :: Client.t(), path :: String.t(), params :: keyword() | map() | nil) ::
{:ok, term()} | {:error, Exception.t()}
def get(%Client{} = client, path, params \\ nil) do
request(client, :get, path, query: QueryParams.build(params))
end
@spec post(client :: Client.t(), path :: String.t(), json :: term()) ::
{:ok, term()} | {:error, Exception.t()}
def post(%Client{} = client, path, json \\ nil) do
request(client, :post, path, json: json)
end
@spec put(client :: Client.t(), path :: String.t(), json :: term()) ::
{:ok, term()} | {:error, Exception.t()}
def put(%Client{} = client, path, json \\ nil) do
request(client, :put, path, json: json)
end
@spec delete(client :: Client.t(), path :: String.t()) ::
{:ok, term()} | {:error, Exception.t()}
def delete(%Client{} = client, path) do
request(client, :delete, path, [])
end
@doc "Uploads `content` as a single-file multipart/form-data body."
@spec upload_multipart(
client :: Client.t(),
path :: String.t(),
field_name :: String.t(),
filename :: String.t(),
content :: binary()
) ::
{:ok, term()} | {:error, Exception.t()}
def upload_multipart(%Client{} = client, path, field_name, filename, content)
when is_binary(content) do
{body, content_type} = Multipart.encode(field_name, filename, content)
request_id = new_request_id()
req_opts = [
method: :post,
url: path,
headers: [{"content-type", content_type}],
body: body
]
Retry.run(client.retry, fn ->
dispatch(client, path, req_opts, request_id)
end)
end
@doc "Downloads a binary response body (used for PDF statement/invoice/SAFE downloads)."
@spec download_binary(client :: Client.t(), path :: String.t()) ::
{:ok, binary()} | {:error, Exception.t()}
def download_binary(%Client{} = client, path) do
request(client, :get, path, [])
end
# ── core request/retry pipeline ──────────────────────────────────────────
defp request(client, method, path, opts) do
request_id = new_request_id()
req_opts =
[method: method, url: path]
|> maybe_put(:params, Keyword.get(opts, :query))
|> maybe_put_json(Keyword.get(opts, :json, :__mercury_no_json__))
Retry.run(client.retry, fn ->
dispatch(client, path, req_opts, request_id)
end)
end
defp maybe_put(opts, _key, nil), do: opts
defp maybe_put(opts, _key, []), do: opts
defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value)
defp maybe_put_json(opts, :__mercury_no_json__), do: opts
defp maybe_put_json(opts, nil), do: opts
defp maybe_put_json(opts, json),
do: Keyword.put(opts, :json, Mercury.CaseConverter.camelize_keys(json))
defp dispatch(client, path, req_opts, request_id) do
if client.on_request do
safe_hook(client.on_request, [req_opts[:method], path, request_id])
end
start = System.monotonic_time()
result = Req.request(client.req, req_opts)
duration_ms = System.convert_time_unit(System.monotonic_time() - start, :native, :millisecond)
case result do
{:ok, %Req.Response{} = response} ->
if client.on_response do
safe_hook(client.on_response, [
req_opts[:method],
path,
request_id,
response.status,
duration_ms
])
end
handle_response(response, path, request_id)
{:error, %{reason: :timeout}} ->
{:error, %Mercury.TimeoutError{timeout_ms: client.timeout}}
{:error, exception} ->
{:error, %Mercury.NetworkError{message: Exception.message(exception), reason: exception}}
end
end
defp safe_hook(fun, args) do
apply(fun, args)
rescue
_ -> :ok
end
defp new_request_id do
"req_#{System.system_time(:nanosecond)}_#{random_hex(6)}"
end
defp random_hex(length) do
(div(length, 2) + 1)
|> :crypto.strong_rand_bytes()
|> Base.encode16(case: :lower)
|> binary_part(0, length)
end
# ── response handling / error classification ────────────────────────────
defp handle_response(%Req.Response{status: 204}, _path, _request_id), do: {:ok, nil}
defp handle_response(%Req.Response{status: status, body: body}, _path, _request_id)
when status in 200..299 do
{:ok, body}
end
defp handle_response(
%Req.Response{status: 429, body: body, headers: headers},
_path,
request_id
) do
server_request_id = header(headers, "x-request-id") || request_id
retry_after = headers |> header("retry-after") |> parse_int()
message = extract_message(body, "429 Too Many Requests")
{:error,
%Mercury.RateLimitError{
message: message,
code: :rate_limited,
status: 429,
request_id: server_request_id,
body: safe_map(body),
retry_after: retry_after
}}
end
defp handle_response(
%Req.Response{status: status, body: body, headers: headers},
path,
request_id
) do
server_request_id = header(headers, "x-request-id") || request_id
message = extract_message(body, "HTTP #{status}")
{:error, Error.classify(status, message, server_request_id, path, safe_map(body))}
end
defp header(headers, name) do
case Map.get(headers, name) do
[value | _rest] -> value
value when is_binary(value) -> value
_other -> nil
end
end
defp parse_int(nil), do: 0
defp parse_int(value) when is_binary(value) do
case Integer.parse(value) do
{n, _rest} -> n
:error -> 0
end
end
defp parse_int(_value), do: 0
defp extract_message(%{"message" => message}, _fallback)
when is_binary(message) and message != "" do
message
end
defp extract_message(%{"error" => message}, _fallback)
when is_binary(message) and message != "" do
message
end
defp extract_message(_body, fallback), do: fallback
defp safe_map(body) when is_map(body), do: body
defp safe_map(_body), do: nil
end