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