defmodule AccessGrid.Client do
@moduledoc """
Holds configuration for AccessGrid API authentication.
A client can be created explicitly with `new/1` or loaded from application config with `from_config/0`.
## Examples
# Explicit credentials
client = AccessGrid.Client.new(
account_id: "acct_123",
api_secret: "secret_456"
)
# From application config (uses Gestalt for process isolation)
client = AccessGrid.Client.from_config()
"""
@type t :: %__MODULE__{
account_id: String.t(),
api_secret: String.t(),
api_host: String.t()
}
@default_host "https://api.accessgrid.com"
@enforce_keys [:account_id, :api_secret]
defstruct [:account_id, :api_secret, api_host: @default_host]
@doc """
Creates a new client with explicit credentials.
## Options
* `:account_id` - Required. The AccessGrid account ID.
* `:api_secret` - Required. The API secret for signing requests.
* `:api_host` - Optional. API host URL. Defaults to `#{@default_host}`.
## Examples
iex> AccessGrid.Client.new(account_id: "acct_123", api_secret: "secret_456")
%AccessGrid.Client{account_id: "acct_123", api_secret: "secret_456", api_host: "https://api.accessgrid.com"}
"""
@spec new(keyword()) :: t()
def new(opts) do
account_id = Keyword.get(opts, :account_id)
api_secret = Keyword.get(opts, :api_secret)
api_host = Keyword.get(opts, :api_host, @default_host)
validate_required!(:account_id, account_id)
validate_required!(:api_secret, api_secret)
%__MODULE__{
account_id: account_id,
api_secret: api_secret,
api_host: api_host
}
end
@doc """
Creates a client from application configuration.
Uses Gestalt for process-specific config overrides, enabling async test isolation.
## Configuration
config :accessgrid,
account_id: "acct_123",
api_secret: "secret_456",
api_host: "https://api.accessgrid.com" # optional
"""
@spec from_config() :: t()
def from_config do
pid = self()
account_id = Gestalt.get_config(:accessgrid, :account_id, pid)
api_secret = Gestalt.get_config(:accessgrid, :api_secret, pid)
api_host = Gestalt.get_config(:accessgrid, :api_host, pid) || @default_host
:ok = validate_required!(:account_id, account_id)
:ok = validate_required!(:api_secret, api_secret)
%__MODULE__{
account_id: account_id,
api_secret: api_secret,
api_host: api_host
}
end
@type method :: :get | :post | :put | :patch | :delete | :head
@doc """
Makes an authenticated request to the AccessGrid API.
Handles URL construction, payload signing, and header generation.
## Options
* `:body` - Request body (map). Will be JSON encoded for POST/PUT/PATCH.
* `:params` - Query parameters (map).
* `:headers` - Additional headers (map).
## Examples
client = AccessGrid.Client.new(account_id: "acct_123", api_secret: "secret")
# POST with body
Client.request(client, :post, "/v1/key-cards", body: %{name: "Test"})
# GET with params
Client.request(client, :get, "/v1/key-cards", params: %{"page" => "2"})
# Using config (pass nil for client)
Client.request(nil, :get, "/v1/key-cards/card_123")
"""
@spec request(t() | nil, method(), String.t(), keyword()) ::
{:ok, AccessGrid.HttpResponse.t()} | {:error, AccessGrid.HttpFailure.t()}
def request(client, method, path, opts \\ [])
def request(nil, method, path, opts) do
request(from_config(), method, path, opts)
end
def request(%__MODULE__{} = client, method, path, opts) do
body = Keyword.get(opts, :body)
params = Keyword.get(opts, :params, %{})
custom_headers = Keyword.get(opts, :headers, %{})
payload = compute_payload(method, path, body)
signature = sign(client.api_secret, payload)
headers =
auth_headers(client.account_id, signature)
|> Map.merge(custom_headers)
params =
if body_empty?(body) do
Map.put(params, "sig_payload", payload)
else
params
end
url = build_url(client.api_host, path)
request_opts = %{headers: headers, params: params, body: body}
apply(AccessGrid.HttpClient, method, [url, request_opts])
end
# --- Private helpers ---
@action_segments ~w(suspend resume unlink delete publish)
defp compute_payload(:get, path, _body), do: id_payload(path)
defp compute_payload(:delete, path, _body), do: id_payload(path)
defp compute_payload(:post, path, nil), do: id_payload(path)
defp compute_payload(:post, path, body) when body == %{}, do: id_payload(path)
defp compute_payload(_method, _path, body) when is_map(body), do: Jason.encode!(body)
defp compute_payload(_method, _path, nil), do: "{}"
defp id_payload(path) do
case extract_resource_id(path) do
nil -> "{}"
id -> ~s({"id":"#{id}"})
end
end
defp extract_resource_id(path) do
parts =
path
|> String.trim()
|> String.split("/")
|> Enum.reject(&(&1 == ""))
case parts do
[] ->
nil
[_single] ->
nil
parts ->
last = List.last(parts)
second_to_last = Enum.at(parts, -2)
if last in @action_segments do
second_to_last
else
last
end
end
end
defp sign(secret, payload) do
encoded_payload = Base.encode64(payload)
:crypto.mac(:hmac, :sha256, secret, encoded_payload)
|> Base.encode16(case: :lower)
end
defp auth_headers(account_id, signature) do
%{
"X-ACCT-ID" => account_id,
"X-PAYLOAD-SIG" => signature,
"Content-Type" => "application/json",
"User-Agent" => "accessgrid-ex/#{version()}"
}
end
defp version do
Application.spec(:accessgrid, :vsn) |> to_string()
end
defp build_url(host, path) do
host = String.trim_trailing(host, "/")
path = if String.starts_with?(path, "/"), do: path, else: "/" <> path
host <> path
end
# Whether the HTTP body is effectively empty for signature-verification purposes.
# When true, the server falls back to verifying `sig_payload` from the query string
# (see `Api::ApiController#valid_payload_signature?`), so the client must
# include `sig_payload` in the request params. Covers GET (no body), DELETE
# (no body), and POST/PUT/PATCH with nil or empty-map bodies.
defp body_empty?(nil), do: true
defp body_empty?(body) when body == %{}, do: true
defp body_empty?(_), do: false
defp validate_required!(key, nil) do
raise ArgumentError, "#{key} is required"
end
defp validate_required!(_key, _value), do: :ok
end