Normally, to talk with an external API we will search some library to do the work for us. Sometimes, this search is infructuous for some of the next reasons:
- Exist one or more libraries but are unmaintained.
- There is no library at all.
- Existing libraries do not fit our requirements.
At this point, you could take the following paths.
1 Build a service module in your app
Maybe you just need to consume some endpoints and already use some HTTP client like Tesla. In this case, you can use the usual path:
defmodule GitHub do
use Tesla
plug Tesla.Middleware.BaseUrl, "https://api.github.com"
plug Tesla.Middleware.Headers, [{"authorization", "token xyz"}]
plug Tesla.Middleware.JSON
def user_repos(login) do
get("/users/" <> login <> "/repos")
end
end
2 Build a custom API client
Sometimes you want/need to do it yourself in a library way :). In this case you can follow the pattern mentioned in the Goth redesign.
Use an HTTP client contract:
Using behaviours you can define a contract for the HTTP client. With this contract you can implement the contract and use the HTTP client library that you want:
defmodule API.HTTPClient do
@type method() :: atom()
@type url() :: binary()
@type status() :: non_neg_integer()
@type header() :: {binary(), binary()}
@type body() :: binary()
@doc """
Callback to make an HTTP request.
"""
@callback request(method(), url(), [header()], body(), opts :: keyword()) ::
{:ok, %{status: status, headers: [header()], body: body()}}
| {:error, Exception.t()}
@doc false
def request(module, method, url, headers, body, opts) do
module.request(method, url, headers, body, opts)
end
end
Implement the HTTP Client contract
Hackney is a popular HTTP client library, so this can be the default implementation.
defmodule API.HTTPClient.Hackney do
@behaviour API.HTTPClient
@impl true
@spec request(atom(), String.t(), list(), String.t(), Keyword.t()) ::
{:ok, map()} | {:error, any()}
def request(method, url, headers, body, opts) do
with {:ok, status, headers, body_ref} <- :hackney.request(method, url, headers, body, opts),
{:ok, body} <- :hackney.body(body_ref) do
{:ok, %{status: status, headers: headers, body: body}}
end
end
end
Then you can set this as default in the api client config:
config :api, http_client: API.HTTPClient.Hackney
Using the HTTP client
defmodule API.Resource do
def get_resource() do
url = "https://#{Application.fetch_env!(:api, :base_url)}/api/<resource>"
headers = build_headers()
result =
:api
|> Application.fetch_env!(:http_client)
|> HTTPClient.request(:get, url, headers, "", [])
|> handle_response()
case result do
{:ok, response} -> {:ok, response}
{:error, error} -> {:error, error}
end
end
defp build_headers() do
[
{"content-type", "application/json"}
]
end
defp handle_response({:ok, %{status: status, body: body}}) when status in [200, 201] do
case Jason.decode(body) do
{:ok, attrs} -> {:ok, attrs}
{:error, reason} -> {:error, reason}
end
end
defp handle_response({:ok, response}) do
{:error, Jason.decode!(response.body)}
end
defp handle_response({:error, exception}) do
{:error, exception}
end
end
Custom implementation
If you don’t use hackney and you use another client like Finch or Mint instead, you can implement the contract yourself:
defmodule MyApp.Extensions.API.FinchClient do
@behaviour API.HTTPClient
@impl true
def request(method, url, headers, body, opts, initial_state) do
opts = Keyword.merge(initial_state.default_opts, opts)
Finch.build(method, url, headers, body)
|> Finch.request(initial_state.name, opts)
end
end
Then you can use the new implementation in your app config:
config :api,
http_client: MyApp.Extensions.API.FinchClient,
base_url: "..."
Advantages:
You can make the default http client dependency optional in your API client: {:hackney, "~> 1.7", optional: true}
Who use the API client can pick their HTTP client, exists a lot of options to choose. Read some at: The State of Elixir HTTP Clients
Avoid duplication of dependencies. If you’re already using hackney great! But if you are already using another client, you are not forced to download and use Hackney additionally.
Who use the API client can use the http client contract to test inside their app using Mox. More about this in: Mocks and explicit contracts
Share code between the organization projects. Preventing writing the same functionality multiple times
Finally, you can see another example in the Tesla library:
- Tesla.Adapter (behaviour)
- Tesla.Adapter.Hackney (contract implementation)