Packages

Production-grade Elixir client for the SaltEdge API v6 (AIS · PIS · Data Enrichment)

Current section

Files

Jump to
salt_edge_client lib salt_edge_client client.ex
Raw

lib/salt_edge_client/client.ex

defmodule SaltEdgeClient.Client do
@moduledoc """
Low-level HTTP client for the SaltEdge API v6.
Handles:
- Authentication headers (`App-id`, `Secret`)
- Optional HMAC-SHA256 request signing
- Exponential-backoff retries on 429 and 5xx responses
- Telemetry events for every request
- Structured `{:ok, decoded}` / `{:error, %SaltEdgeClient.Error{}}` responses
In normal usage you do not call this module directly — use the domain
service modules (`SaltEdgeClient.AIS.Customers`, etc.) instead.
"""
alias SaltEdgeClient.{Config, Error, Signer}
require Logger
@sdk_version "1.0.0"
@user_agent "salt-edge-client-elixir/#{@sdk_version}"
@doc """
Executes a GET request.
Returns `{:ok, decoded_body}` or `{:error, %Error{}}`.
Accepts any `SaltEdgeClient.Config` key as an option to override defaults per-call.
"""
@spec get(String.t(), keyword(), keyword()) :: {:ok, map() | list()} | {:error, Error.t()}
def get(path, params \\ [], opts \\ []) do
request(:get, path, params: params, opts: opts)
end
@doc "Executes a POST request with a JSON-encoded body."
@spec post(String.t(), map(), keyword()) :: {:ok, map() | list()} | {:error, Error.t()}
def post(path, body \\ %{}, opts \\ []) do
request(:post, path, body: body, opts: opts)
end
@doc "Executes a PUT request with a JSON-encoded body."
@spec put(String.t(), map(), keyword()) :: {:ok, map() | list()} | {:error, Error.t()}
def put(path, body \\ %{}, opts \\ []) do
request(:put, path, body: body, opts: opts)
end
@doc "Executes a DELETE request."
@spec delete(String.t(), keyword(), keyword()) :: {:ok, map()} | {:error, Error.t()}
def delete(path, params \\ [], opts \\ []) do
request(:delete, path, params: params, opts: opts)
end
# ── Private ──────────────────────────────────────────────────────────────────
defp request(method, path, args) do
opts = Keyword.get(args, :opts, [])
config = Config.new(opts)
url = config.base_url <> path
body = args |> Keyword.get(:body, %{}) |> Jason.encode!()
params = Keyword.get(args, :params, [])
:telemetry.execute(
[:salt_edge_client, :request, :start],
%{system_time: System.system_time()},
%{method: method, path: path}
)
result = do_request(method, url, body, params, config, 0)
:telemetry.execute(
[:salt_edge_client, :request, :stop],
%{system_time: System.system_time()},
%{method: method, path: path, result: elem(result, 0)}
)
result
end
defp do_request(method, url, body, params, config, attempt) do
headers = build_headers(method, url, body, config)
if config.debug do
Logger.debug("[SaltEdgeClient] #{String.upcase(to_string(method))} #{url}")
end
req_opts = [
method: method,
url: url,
headers: headers,
body: if(method in [:post, :put], do: body, else: ""),
params: params,
receive_timeout: config.timeout,
retry: false
]
# Use map-pattern matching to avoid Req struct compile-time dependency
case Req.request(req_opts) do
{:ok, %{status: status, body: resp_body}} when status in 200..299 ->
decode_success(resp_body)
{:ok, %{status: status, body: resp_body}} ->
error = Error.from_response(status, ensure_binary(resp_body))
maybe_retry(method, url, body, params, config, attempt, error)
{:error, %{reason: reason}} ->
# Covers Req.TransportError and similar transport failures
error = Error.network(reason)
maybe_retry(method, url, body, params, config, attempt, error)
{:error, reason} ->
{:error, Error.network(reason)}
end
end
defp maybe_retry(method, url, body, params, config, attempt, error) do
if attempt < config.max_retries and Error.retryable?(error) do
delay = backoff_ms(attempt, config)
if config.debug do
Logger.debug(
"[SaltEdgeClient] retrying (attempt #{attempt + 1}/#{config.max_retries}) in #{delay}ms"
)
end
Process.sleep(delay)
do_request(method, url, body, params, config, attempt + 1)
else
{:error, error}
end
end
defp decode_success(body) when is_map(body) or is_list(body), do: {:ok, body}
defp decode_success(body) when is_binary(body) and byte_size(body) == 0, do: {:ok, %{}}
defp decode_success(body) when is_binary(body) do
case Jason.decode(body) do
{:ok, decoded} -> {:ok, decoded}
{:error, reason} -> {:error, Error.decode(body, reason)}
end
end
defp decode_success(_), do: {:ok, %{}}
defp build_headers(method, url, body, config) do
base = [
{"App-id", config.app_id},
{"Secret", config.secret},
{"Accept", "application/json"},
{"User-Agent", @user_agent}
]
content_type =
if method in [:post, :put],
do: [{"Content-Type", "application/json"}],
else: []
sign_headers =
if config.private_key do
case Signer.sign_request(config.private_key, to_string(method), url, body) do
{:ok, headers} -> headers
_ -> []
end
else
[]
end
base ++ content_type ++ sign_headers
end
defp backoff_ms(attempt, config) do
delay = round(config.retry_base_delay * :math.pow(2, attempt))
min(delay, config.retry_max_delay)
end
defp ensure_binary(b) when is_binary(b), do: b
defp ensure_binary(b), do: Jason.encode!(b)
end