Naming things is famously difficult. I can’t solve that. DNS makes it even more difficult. Your names need name servers 🙄. There are many domain registrars. I primarily use name.com, which helpfully has an api. For this post, I will start a client for the name.com API with Req to get information on domain names I’ve bought and update the name servers.

I am going to loosely follow the Explicit Contract methodology laid out by Wojtek Mach in his Elixir Conf EU presentation on Building API Clients with Req. In it, he mentions a blog post by José Valim on Mocks and Explicit Contracts, which adds context to the why of this. My tl;dr is that it makes testing and long-term maintenance easier.

What I really like about the explicit contract is that the api reads almost like pseudo code. My test project is called Kit, and here is the API implementation.

# lib/kit/namecom/api.ex
defmodule Kit.Namecom.Api do
  @behaviour Kit.Namecom.Behaviour

  alias Kit.Namecom.Req

  @impl true
  def list_domains() do
    Req.request("/domains")
  end

  @impl true
  def domain(name) do
    Req.request("/domains/#{name}")
  end

  @impl true
  def set_name_servers(name, servers) do
    Req.request("/domains/#{name}:setNameservers", json: servers)
  end
end

It looks so simple and easy to read. Describing the behavior involves a bunch of typing, and that’s not as fun, but this is why LLMs exist 😀. It makes quick work of the effort.

# lib/kit/namecom/behaviour.ex
defmodule Kit.Namecom.Behaviour do
  @callback list_domains() :: {:ok, Req.Response.t()} | {:error, Exception.t()}
  @module Application.compile_env!(:kit, :namecom_api)
  defdelegate list_domains(), to: @module

  @callback domain(String.t()) :: {:ok, Req.Response.t()} | {:error, Exception.t()}
  defdelegate domain(name), to: @module

  @callback set_name_servers(String.t(), list()) ::
              {:ok, Req.Response.t()} | {:error, Exception.t()}
  defdelegate set_name_servers(name, servers), to: @module
end

The actual implementation of the Req HTTP client is straightforward. The API has a few quirks, but the power of Req makes it easy to fix and hide them neatly, for example, through the Req.Request.append_request_steps, it is easy to decode the body from JSON, even though the API doesn’t set the content header for it to happen automatically.

# lib/kit/namecom/req.ex
defmodule Kit.Namecom.Req do
  def new(options \\ []) do
    options()
    |> Req.new()
    |> Req.Request.append_request_steps(
      post: fn req ->
        case Map.fetch(req.options, :json) do
          {:ok, value} when is_map(value) -> %{req | method: :post}
          _ -> req
        end
      end
    )
    |> Req.Request.append_response_steps(
      # api responds with incorrect content-type, so auto decoding doesn't work.
      fix_decode_json: fn {req, resp} ->
        if resp.status in [200, 201] do
          case Jason.decode(resp.body) do
            {:ok, decoded} -> {req, %{resp | body: decoded}}
            _ -> {req, resp}
          end
        else
          {req, resp}
        end
      end
    )
    |> Req.merge(Application.fetch_env!(:kit, :namecom_req_options))
    |> Req.merge(options)
  end

  def request(url, options \\ []) do
    new(url: url) |> Req.request(options)
  end

  def request!(url, options \\ []) do
    new(url: url) |> Req.request(options)
  end

  defp user_agent, do: "kit-namecom/#{Application.spec(:kit, :vsn)}"

  defp user_info() do
    Application.fetch_env!(:kit, :namecom_user) <>
      ":" <>
      Application.fetch_env!(:kit, :namecom_api_key)
  end

  defp options do
    [
      base_url: "https://api.name.com/v4",
      auth: {:basic, user_info()},
      headers: [
        {"accept", "application/json"},
        {"User-Agent", user_agent()}
      ]
    ]
  end
end

Mr. Mach’s orginal presentation went on to give more detail about using Mox to write tests for the client. You should definitely check out the presentation linked above.