Skip to main content

lib/coffrify.ex

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