# A small, complete MatterFirst integration. # # It shows the four things every integration has to get right: # # 1. authenticate with an API key and find out who it acts for; # 2. read a list page by page, following `links.next`; # 3. create something with an `Idempotency-Key`, so a retry cannot make two, # waiting for `Retry-After` on 429 and backing off on 5xx; # 4. receive webhooks and verify each one's signature and timestamp before # trusting it. # # Run it against your firm with `run.exs` in this folder. MatterFirst's own test # suite loads this same file and runs it against the real API on every build, so # it cannot fall behind the API it describes. # # Only `Req` and `Jason` are needed. defmodule MatterFirstSample do @moduledoc false @tolerance_seconds 300 @max_attempts 4 @doc """ A client for one firm. `opts`: * `:base_url`: e.g. "https://matterfirst.com" * `:api_key`: an `mf_live_…` key * `:req_options`: extra `Req` options (the test suite passes its router here) * `:sleep`: how to wait between retries; defaults to `Process.sleep/1` """ def client(opts) do req = Req.new( base_url: String.trim_trailing(Keyword.fetch!(opts, :base_url), "/") <> "/api/v1", auth: {:bearer, Keyword.fetch!(opts, :api_key)}, headers: [{"accept", "application/json"}], # This client does its own retrying, so it can honour Retry-After and # keep the same Idempotency-Key across attempts. retry: false, receive_timeout: 30_000 ) |> Req.merge(Keyword.get(opts, :req_options, [])) %{req: req, sleep: Keyword.get(opts, :sleep, &Process.sleep/1)} end @doc "Who the key acts for: `{:ok, %{\"email\" => ..., ...}}`." def whoami(client) do with {:ok, body} <- request(client, :get, "/me") do {:ok, get_in(body, ["data", "attributes"])} end end @doc "Every open matter, following `links.next` until there is none." def list_open_matters(client, page_size \\ 50) do collect(client, "/matters?filter[status]=open&page_size=#{page_size}", []) end defp collect(_client, nil, acc), do: {:ok, acc} defp collect(client, path, acc) do with {:ok, body} <- request(client, :get, path) do next = get_in(body, ["links", "next"]) next_path = if next && next != path, do: relative(next), else: nil collect(client, next_path, acc ++ Map.get(body, "data", [])) end end # `links.next` is a path under /api/v1 (or a full URL); the client's base URL # already ends in /api/v1. defp relative(link) do %URI{path: path, query: query} = URI.parse(link) path = String.replace_prefix(path || "", "/api/v1", "") if query, do: path <> "?" <> query, else: path end @doc """ Creates a matter. The same `idempotency_key` always means the same matter: retrying after a timeout replays the first answer instead of creating a second one. """ def create_matter(client, attributes, idempotency_key) do with {:ok, body} <- request(client, :post, "/matters", json: %{"data" => %{"attributes" => attributes}}, idempotency_key: idempotency_key ) do {:ok, body["data"]} end end @doc """ Registers an https address for the given events. Returns the endpoint and its signing secret, which MatterFirst shows only this once: store it. """ def register_webhook(client, url, events, idempotency_key) do with {:ok, body} <- request(client, :post, "/webhooks", json: %{"data" => %{"attributes" => %{"url" => url, "events" => events}}}, idempotency_key: idempotency_key ) do {:ok, %{endpoint: body["data"], secret: get_in(body, ["meta", "secret"])}} end end @doc """ Verifies one delivery exactly as MatterFirst signs it, before anything in it is trusted. `headers` are the request's headers (lower-case names). Returns `{:ok, event}`, `{:error, :missing_headers}`, `{:error, :stale}` or `{:error, :bad_signature}`. `now` is unix seconds, for tests. """ def verify_delivery(raw_body, headers, secret, now \\ System.os_time(:second)) do headers = Map.new(headers, fn {name, value} -> {String.downcase(name), value} end) with {:ok, timestamp} <- fetch_integer(headers, "x-matterfirst-timestamp"), {:ok, signature} <- fetch(headers, "x-matterfirst-signature"), :ok <- fresh(timestamp, now), :ok <- signed?(raw_body, secret, timestamp, signature) do {:ok, Jason.decode!(raw_body)} end end defp signed?(raw_body, secret, timestamp, "v1=" <> given) do expected = :crypto.mac(:hmac, :sha256, secret, "v1.#{timestamp}.#{raw_body}") |> Base.encode16(case: :lower) # Constant time, so a receiver does not leak how much of a guess was right. if byte_size(expected) == byte_size(given) and :crypto.hash_equals(expected, given), do: :ok, else: {:error, :bad_signature} end defp signed?(_raw_body, _secret, _timestamp, _other_scheme), do: {:error, :bad_signature} defp fetch(headers, name) do case Map.fetch(headers, name) do {:ok, value} -> {:ok, value} :error -> {:error, :missing_headers} end end defp fetch_integer(headers, name) do with {:ok, value} <- fetch(headers, name) do case Integer.parse(value) do {int, ""} -> {:ok, int} _other -> {:error, :missing_headers} end end end defp fresh(timestamp, now) do if abs(now - timestamp) <= @tolerance_seconds, do: :ok, else: {:error, :stale} end @doc """ One request with safe retries: 429 waits for `Retry-After`; a 5xx or a dropped connection waits 1, 2 then 4 seconds. POSTs carry the same `Idempotency-Key` on every attempt. Any other 4xx is returned at once as `{:error, %{status:, code:, detail:}}`. """ def request(client, method, path, opts \\ []) do {key, opts} = Keyword.pop(opts, :idempotency_key) headers = if key, do: [{"idempotency-key", key}], else: [] attempt(client, [method: method, url: path, headers: headers] ++ opts, 1) end defp attempt(client, options, n) do case Req.request(client.req, options) do {:ok, %Req.Response{status: status, body: body}} when status in 200..299 -> {:ok, body} {:ok, %Req.Response{status: 429} = response} when n < @max_attempts -> client.sleep.(retry_after_ms(response)) attempt(client, options, n + 1) {:ok, %Req.Response{status: status}} when status >= 500 and n < @max_attempts -> client.sleep.(backoff_ms(n)) attempt(client, options, n + 1) {:ok, %Req.Response{status: status, body: body}} -> {:error, error(status, body)} {:error, _transport} when n < @max_attempts -> client.sleep.(backoff_ms(n)) attempt(client, options, n + 1) {:error, exception} -> {:error, %{status: nil, code: "transport", detail: Exception.message(exception)}} end end defp retry_after_ms(response) do case Req.Response.get_header(response, "retry-after") do [seconds | _] -> case Integer.parse(seconds) do {s, _} when s >= 0 -> s * 1_000 _other -> 1_000 end [] -> 1_000 end end defp backoff_ms(n), do: 1_000 * Integer.pow(2, n - 1) defp error(status, %{"errors" => [first | _]}) do %{status: status, code: first["code"], detail: first["detail"]} end defp error(status, body), do: %{status: status, code: nil, detail: inspect(body)} @doc """ The whole walk-through: who am I, what is open, and (with `:webhook_url`) register an address for new matters. Prints what it does. """ def run(opts) do client = client(opts) with {:ok, me} <- whoami(client), {:ok, matters} <- list_open_matters(client) do IO.puts("This key acts for #{me["full_name"] || me["email"]}.") IO.puts("#{length(matters)} open matters.") case Keyword.get(opts, :webhook_url) do nil -> :ok url -> {:ok, %{secret: secret}} = register_webhook(client, url, ["matter.created"], "sample-webhook-#{url}") IO.puts("Registered #{url}. Store this signing secret now: #{secret}") end end end end