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.