How I write a client for an external API in Elixir
How I write a client for an external API in Elixir#
The pattern I use for every external API (Stripe, Shopify, …): one module per API, a root Api module with the shared utilities, a test fixture with its mocks.
The Api module#
One module per API, inside the folder of its feature or context: Feature.Api, or Feature.<Name>Api when a service has more than one API.
Stripe.ApiShopify.AdminApi,Shopify.PartnerApi
get_req/0#
Builds the Req for this API: base URL, credentials and options from the application config.
defmodule MyApp.Stripe.Api do
require MyApp.Api
require MyApp.Errors
alias MyApp.Api
alias MyApp.Errors
def get_req do
config = Application.get_env(:my_app, __MODULE__, [])
case config[:api_key] do
key when is_binary(key) and key != "" ->
[base_url: "https://api.stripe.com", auth: {:bearer, key}, retry: :transient]
# Extra options from the config, e.g. the Req.Test plug in tests
|> Keyword.merge(Keyword.get(config, :req_options, []))
|> Req.new()
|> then(&{:ok, &1})
_ ->
{:error,
Errors.new(:internal_server_error, %{message: "Missing Stripe API key", internal: true})}
end
end
end
# config/runtime.exs
config :my_app, MyApp.Stripe.Api, api_key: System.get_env("STRIPE_API_KEY")
# config/test.exs
config :my_app, MyApp.Stripe.Api,
api_key: "sk_test_mock",
req_options: [plug: {Req.Test, MyApp.Stripe.Api}, retry: false]
- A missing credential is an error, not a crash: it goes up like any other error.
- In tests,
req_optionsreplaces the network withReq.Test.
One function per endpoint#
Each function mirrors one HTTP request and is named <method>_<resource>:
GET /v1/customers/:id→get_customer/1POST /v1/checkout/sessions→post_checkout_session/1- Shopify GraphQL
productsquery →get_products/1
With GraphQL every request is a POST, but I keep the HTTP meaning: a query that reads is get_, a mutation that creates is post_ (e.g. productCreate → post_product/1).
Each function maps the response to my Error (see Errors: model them once, catch them once) with Api.map_response:
# GET /v1/customers/:id
def get_customer(id) do
with {:ok, req} <- get_req() do
case Req.get(req, url: "/v1/customers/#{id}") do
# A status the caller can act on: a public error
{:ok, %Req.Response{status: 404}} ->
{:error,
Errors.new(:not_found, %{
message: "Customer %{customer_id} not found",
details: %{customer_id: id}
})}
result ->
Api.map_response(result, %{customer_id: id})
end
end
end
- The rest of the app never sees a
Req.Responseor a Stripe error body: only{:ok, body}or{:error, %Error{}}. - The caller passes only its own
details; any failure not matched before is an internal error. - No logging here: the foreign error is only transformed, with all its data in
details. It’s logged once, where theErroris finally caught.
The root Api module#
MyApp.Api holds the utilities shared by all the Api modules. The main one is the map_response macro: like Errors.new, it captures the caller’s location at compile time, so the error points to the endpoint function (get_customer/1), not to a shared helper.
defmodule MyApp.Api do
alias MyApp.Errors.Error
defmacro map_response(result, details \\ quote(do: %{})) do
[location] = Macro.Env.stacktrace(__CALLER__)
quote do
MyApp.Api.do_map_response(
unquote(result),
unquote(details),
unquote(Macro.escape(location))
)
end
end
# Public because the macro calls it from the caller's module
@doc false
def do_map_response(result, details, location) do
case result do
{:ok, %Req.Response{status: status, body: body}} when status in 200..299 ->
{:ok, body}
{:ok, %Req.Response{status: status, headers: headers, body: body}} ->
error(Map.merge(%{status: status, headers: headers, body: body}, details), location)
{:error, exception} ->
error(Map.merge(%{exception: Exception.message(exception)}, details), location)
end
end
defp error(details, location) do
{:error,
Error.new(:internal_server_error, %{
message: "External API request failed",
details: details,
internal: true,
location: location
})}
end
end
- The default details come from the response (status, headers and whole body, or the exception), merged with the caller’s details.
require MyApp.Apiin every Api module, as forMyApp.Errors.- It calls
Error.new/2with an explicitlocation: theErrors.newmacro would capture the location insideMyApp.Api.
The test fixture#
A fixture module with a configurable mock for each endpoint, ready to import in any test module:
defmodule MyApp.StripeFixtures do
import ExUnit.Assertions
def customer_fixture(attrs \\ %{}) do
Map.merge(%{"id" => "cus_mock", "email" => "mock@example.com"}, attrs)
end
@doc "Mock for `GET /v1/customers/:id`. Opts: `:status`, `:response`."
def expect_get_customer(times \\ 1, opts \\ []) do
Req.Test.expect(MyApp.Stripe.Api, times, fn conn ->
assert conn.method == "GET"
assert "/v1/customers/" <> _id = conn.request_path
conn
|> Plug.Conn.put_status(Keyword.get(opts, :status, 200))
|> Req.Test.json(Keyword.get(opts, :response, customer_fixture()))
end)
end
end
alias MyApp.Errors.Error
alias MyApp.Stripe
setup {Req.Test, :verify_on_exit!}
test "returns the customer" do
expect_get_customer()
assert {:ok, %{"id" => "cus_mock"}} = Stripe.Api.get_customer("cus_mock")
end
test "maps a 404 to not_found" do
expect_get_customer(1, status: 404)
assert {:error, %Error{reason: :not_found}} = Stripe.Api.get_customer("cus_missing")
end
- Defaults return a valid response, so most tests just call
expect_get_customer(). - Opts override the status and the body, to test the error paths.
- The same fixture works in the tests of the contexts that use the API, not only in the tests of the Api module.
Ref: Req.Test