Skip to main content

lib/codat/idempotency.ex

defmodule Codat.Idempotency do
  @moduledoc """
  Helpers for idempotent write operations.

  When you supply an `Idempotency-Key` header, Codat returns the same response
  for any duplicate request with the same key, preventing accidental double-writes.

  ## Usage

      key = Codat.Idempotency.key()

      {:ok, push_op} = Codat.Accounting.Invoices.create(
        client, company_id, conn_id, invoice_body,
        idempotency_key: key
      )

      # Safe to retry with the same key — Codat returns the original response
      {:ok, push_op} = Codat.Accounting.Invoices.create(
        client, company_id, conn_id, invoice_body,
        idempotency_key: key
      )
  """

  @doc """
  Generates a new UUID v4 idempotency key.

  ## Example

      iex> key = Codat.Idempotency.key()
      iex> String.length(key) == 36
      true
  """
  @spec key() :: String.t()
  def key do
    <<a1, a2, a3, a4, b1, b2, c1_raw, c2, d1_raw, d2, e1, e2, e3, e4, e5, e6>> =
      :crypto.strong_rand_bytes(16)

    c1 = c1_raw |> Bitwise.band(0x0F) |> Bitwise.bor(0x40)
    d1 = d1_raw |> Bitwise.band(0x3F) |> Bitwise.bor(0x80)

    hex = fn b ->
      b |> Integer.to_string(16) |> String.pad_leading(2, "0") |> String.downcase()
    end

    "#{hex.(a1)}#{hex.(a2)}#{hex.(a3)}#{hex.(a4)}" <>
      "-#{hex.(b1)}#{hex.(b2)}" <>
      "-#{hex.(c1)}#{hex.(c2)}" <>
      "-#{hex.(d1)}#{hex.(d2)}" <>
      "-#{hex.(e1)}#{hex.(e2)}#{hex.(e3)}#{hex.(e4)}#{hex.(e5)}#{hex.(e6)}"
  end

  @doc """
  Generates an idempotency key derived deterministically from input data.

  The key is an HMAC-SHA256 of the inputs, hex-encoded. Use when the same
  logical operation should always produce the same key.

  ## Example

      key = Codat.Idempotency.key_for("create_invoice", company_id, invoice_number)
      # Always returns the same key for the same three inputs
  """
  @spec key_for(term(), term()) :: String.t()
  def key_for(namespace, data) do
    payload = :erlang.term_to_binary({namespace, data})
    payload |> then(&:crypto.hash(:sha256, &1)) |> Base.encode16(case: :lower)
  end

  @spec key_for(term(), term(), term()) :: String.t()
  def key_for(namespace, data1, data2), do: key_for(namespace, {data1, data2})

  @spec key_for(term(), term(), term(), term()) :: String.t()
  def key_for(namespace, data1, data2, data3), do: key_for(namespace, {data1, data2, data3})

  @doc """
  Returns `true` if the string is a valid idempotency key (1–255 chars).

  ## Example

      iex> Codat.Idempotency.valid?("abc-123")
      true
      iex> Codat.Idempotency.valid?("")
      false
  """
  @spec valid?(String.t()) :: boolean()
  def valid?(key) when is_binary(key) do
    len = String.length(key)
    len >= 1 and len <= 255
  end

  def valid?(_term), do: false

  @doc """
  Returns the header tuple for use in HTTP requests.

  ## Example

      {name, value} = Codat.Idempotency.header("my-key")
      # => {"idempotency-key", "my-key"}
  """
  @spec header(String.t()) :: {String.t(), String.t()}
  def header(key) when is_binary(key), do: {"idempotency-key", key}
end