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