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