lib/cloudflare_api.ex

defmodule CloudflareApi do
  @moduledoc ~S"""
  Top-level entry point for the Cloudflare API client.

  `CloudflareApi` is a thin, explicit wrapper around the
  [Cloudflare v4 API](https://api.cloudflare.com/). It provides convenient
  functions and Elixir idioms so you don't have to work with raw HTTP
  requests by hand.

  ## Status

  _Note:_ this package is under active development and may be refactored in
  substantive ways. If you need to get to production quickly, consider
  pinning to a specific version and watching the changelog.

  ## Philosophy

  This library assumes that a well-designed REST API does not need a heavy
  abstraction layer on top:

  - Request/response shapes stay close to the official Cloudflare JSON.
  - Functions generally return `{:ok, result}` / `{:error, reason}` tuples.
  - Modules and function names mirror Cloudflare's own endpoint groupings.

  The upside is that you can keep the official Cloudflare documentation open
  alongside this library instead of learning a separate abstraction. The
  downside is that if you *want* to be fully shielded from the underlying API
  details, this probably is not the package for you.

  ## Getting started

  The usual way to use the library is:

      token  = System.fetch_env!("CLOUDFLARE_API_TOKEN")
      client = CloudflareApi.new(token)

      {:ok, zones} = CloudflareApi.Zones.list(client)

  or, using the zero-arity client function:

      client_fun = CloudflareApi.client("api-token")
      {:ok, records} = CloudflareApi.DnsRecords.list(client_fun, "zone-id", name: "www.example.com")

  You may also wish to look through the Livebooks in `livebooks/` for
  additional end-to-end examples.
  """

  use CloudflareApi.Typespecs

  @typedoc "A configured Tesla client or a zero-arity function that returns one."
  @type client :: Tesla.Client.t() | (-> Tesla.Client.t())

  @typedoc "Common keyword or map options passed to Cloudflare endpoints."
  @type options :: keyword() | map() | nil

  @typedoc "Standard error reasons returned by this library when wrapping HTTP calls."
  @type error_reason :: Tesla.Env.t() | %Tesla.Error{} | term()

  @typedoc "Helper type for `{:ok, value}` / `{:error, reason}` tuples."
  @type result(result) :: {:ok, result} | {:error, error_reason()}

  @typedoc "Generic identifier helper for Cloudflare resource IDs."
  @type id :: String.t()

  @doc ~S"""
  Build a `Tesla.Client.t()` configured for the Cloudflare v4 API.

  The returned client can be passed to any of the `CloudflareApi.*` endpoint
  modules.

  - `cloudflare_api_token` – a Cloudflare API token with the permissions your
    calls require

  ## Examples

      iex> client = CloudflareApi.new("api-token")
      iex> is_struct(client, Tesla.Client)
      true

  You may also enable opt-in rate-limit retries by passing
  `rate_limit_retry: true` (or a keyword list of retry options) as the second
  argument. See `CloudflareApi.RateLimitRetry` for available options.

  ## Examples

      iex> client = CloudflareApi.new("api-token", rate_limit_retry: true)
      iex> is_struct(client, Tesla.Client)
      true

  """
  def new(cloudflare_api_token, opts \\ []) do
    middlewares =
      [
        {Tesla.Middleware.BaseUrl, "https://api.cloudflare.com/client/v4"},
        Tesla.Middleware.JSON,
        {Tesla.Middleware.BearerAuth, token: cloudflare_api_token}
      ]
      |> maybe_add_rate_limit_retry(Keyword.get(opts, :rate_limit_retry))

    Tesla.client(middlewares)
  end

  @doc ~S"""
  Build a reusable zero-arity client function.

  This wraps `new/1` and returns a function that, when called, yields a
  configured `Tesla.Client.t()`. It is convenient when you want to inject a
  "current Cloudflare client" into other modules without threading the
  `%Tesla.Client{}` struct manually.

  - `cloudflare_api_token` – a Cloudflare API token with the permissions your
    calls require

  ## Examples

      iex> client_fun = CloudflareApi.client("api-token")
      iex> client1 = client_fun.()
      iex> client2 = client_fun.()
      iex> is_struct(client1, Tesla.Client) and client1 == client2
      true

  """
  def client(cloudflare_api_token, opts \\ []) do
    c = CloudflareApi.new(cloudflare_api_token, opts)
    fn -> c end
  end

  @doc false
  def uri_encode_opts(opts) do
    URI.encode_query(opts, :rfc3986)
  end

  defp maybe_add_rate_limit_retry(middleware, nil), do: middleware
  defp maybe_add_rate_limit_retry(middleware, false), do: middleware

  defp maybe_add_rate_limit_retry(middleware, true) do
    [{CloudflareApi.RateLimitRetry, []} | middleware]
  end

  defp maybe_add_rate_limit_retry(middleware, retry_opts) when is_list(retry_opts) do
    [{CloudflareApi.RateLimitRetry, retry_opts} | middleware]
  end

  defp maybe_add_rate_limit_retry(_middleware, other) do
    raise ArgumentError,
          "rate_limit_retry must be a boolean or keyword list, got: #{inspect(other)}"
  end
end