defmodule Coffrify do
@moduledoc """
Official Elixir client for the [Coffrify](https://coffrify.com) API.
Coffrify is encrypted file-transfer infrastructure. This SDK is the
canonical Elixir client and mirrors the JavaScript SDK
(`@coffrify/sdk` v0.9.0) feature-for-feature.
## Quickstart
client = Coffrify.new(api_key: System.fetch_env!("COFFRIFY_API_KEY"))
{:ok, page} = Coffrify.Resources.Transfers.list(client, limit: 20)
{:ok, %{webhook: wh, secret: secret}} =
Coffrify.Resources.Webhooks.create(client,
name: "My CI",
url: "https://ci.example.com/hooks/coffrify",
events: ["transfer.created", "transfer.downloaded"]
)
IO.puts("Store this secret in your secret manager: " <> secret)
## Configuration
Pass options to `new/1`:
* `:api_key` (required) — API key starting with `cof_` (or legacy
`cfy_` / `fxa_`).
* `:api_url` (default `"https://api.coffrify.com"`) — override for
staging or private deployments.
* `:workspace_id` — pin requests to a specific workspace. Defaults to
the key's workspace.
* `:timeout_ms` (default `30_000`) — per-request timeout.
* `:user_agent` — custom User-Agent (defaults to
`coffrify-elixir/0.9.0`).
* `:max_retries` (default `3`) — number of retries when `retry_policy`
is not set.
* `:retry_base_delay_ms` (default `500`) — initial backoff delay.
* `:retry_policy` — custom `Coffrify.Runtime.Retry.Policy`; overrides
the legacy options above.
* `:auto_idempotency` (default `true`) — auto-generate
`Idempotency-Key` on POST/PUT/PATCH/DELETE.
* `:idempotency_store` — a `Coffrify.Runtime.Idempotency` adapter for
crash-safe replay.
* `:idempotency_ttl_ms` (default 24h) — how long cached idempotent
responses are kept.
* `:circuit_breaker` — a `Coffrify.Runtime.CircuitBreaker` PID.
* `:rate_limiter` — a `Coffrify.Runtime.RateLimit` adapter
(`TokenBucket` or `LeakyBucket`).
* `:telemetry_metadata` (default `%{}`) — static metadata merged into
every `:telemetry.execute/3` payload.
* `:finch_name` — optional Finch pool name passed to `Req`.
* `:hooks` — keyword list of `:before_request`, `:after_response`,
`:on_retry`, `:on_error` callbacks.
"""
alias Coffrify.Error
@version "0.9.0"
@default_api_url "https://api.coffrify.com"
@valid_api_key_prefixes ["cof_", "cfy_", "fxa_"]
@type request_method :: :get | :post | :put | :patch | :delete
@type request_opts :: [
idempotency_key: String.t() | nil,
skip_retry: boolean(),
headers: [{String.t(), String.t()}],
timeout_ms: pos_integer() | nil,
query: keyword() | map()
]
@type t :: %__MODULE__{
api_key: String.t(),
api_url: String.t(),
workspace_id: String.t() | nil,
timeout_ms: pos_integer(),
user_agent: String.t(),
retry_policy: Coffrify.Runtime.Retry.Policy.t(),
auto_idempotency: boolean(),
idempotency_store: term() | nil,
idempotency_ttl_ms: pos_integer(),
circuit_breaker: pid() | atom() | nil,
rate_limiter: term() | nil,
telemetry_metadata: map(),
finch_name: atom() | nil,
hooks: keyword()
}
defstruct [
:api_key,
:api_url,
:workspace_id,
:timeout_ms,
:user_agent,
:retry_policy,
:auto_idempotency,
:idempotency_store,
:idempotency_ttl_ms,
:circuit_breaker,
:rate_limiter,
:telemetry_metadata,
:finch_name,
:hooks
]
@doc """
Build a new Coffrify client.
Raises `Coffrify.Error.InvalidApiKey` when the provided API key does not
start with a known prefix (`cof_`, `cfy_`, `fxa_`).
"""
@spec new(keyword()) :: t()
def new(opts) when is_list(opts) do
api_key = Keyword.fetch!(opts, :api_key)
validate_api_key!(api_key)
retry_policy =
Keyword.get(opts, :retry_policy) ||
Coffrify.Runtime.Retry.ExponentialBackoff.new(
max_attempts: Keyword.get(opts, :max_retries, 3),
base_delay_ms: Keyword.get(opts, :retry_base_delay_ms, 500)
)
%__MODULE__{
api_key: api_key,
api_url: opts |> Keyword.get(:api_url, @default_api_url) |> String.trim_trailing("/"),
workspace_id: Keyword.get(opts, :workspace_id),
timeout_ms: Keyword.get(opts, :timeout_ms, 30_000),
user_agent: Keyword.get(opts, :user_agent) || "coffrify-elixir/#{@version}",
retry_policy: retry_policy,
auto_idempotency: Keyword.get(opts, :auto_idempotency, true),
idempotency_store: Keyword.get(opts, :idempotency_store),
idempotency_ttl_ms: Keyword.get(opts, :idempotency_ttl_ms, 86_400_000),
circuit_breaker: Keyword.get(opts, :circuit_breaker),
rate_limiter: Keyword.get(opts, :rate_limiter),
telemetry_metadata: Keyword.get(opts, :telemetry_metadata, %{}),
finch_name: Keyword.get(opts, :finch_name),
hooks: Keyword.get(opts, :hooks, [])
}
end
@doc """
Issue an HTTP request against the Coffrify API.
Returns `{:ok, body}` on a 2xx response, or `{:error, %Coffrify.Error{}}`
for every other outcome (4xx, 5xx, transport errors, circuit-open).
## Examples
Coffrify.request(client, :get, "/transfers", nil, query: [limit: 10])
Coffrify.request(client, :post, "/webhooks", %{name: "ci", url: "..."})
"""
@spec request(t(), request_method(), String.t(), term(), request_opts()) ::
{:ok, term()} | {:error, Exception.t()}
def request(%__MODULE__{} = client, method, path, body \\ nil, opts \\ []) do
Coffrify.Client.request(client, method, path, body, opts)
end
@doc """
Same as `request/5` but raises on error.
"""
@spec request!(t(), request_method(), String.t(), term(), request_opts()) :: term()
def request!(client, method, path, body \\ nil, opts \\ []) do
case request(client, method, path, body, opts) do
{:ok, body} -> body
{:error, %{__struct__: _} = err} -> raise err
end
end
@doc """
Return the static SDK version as a string.
"""
@spec version() :: String.t()
def version, do: @version
defp validate_api_key!(key) when is_binary(key) do
if Enum.any?(@valid_api_key_prefixes, &String.starts_with?(key, &1)) do
:ok
else
raise Error.InvalidApiKey,
message:
"Coffrify api_key must start with `cof_` (or legacy `cfy_` / `fxa_`). Got: #{String.slice(key, 0, 8)}…"
end
end
defp validate_api_key!(_),
do: raise(Error.InvalidApiKey, message: "Coffrify api_key must be a string")
end