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.Api
  • Shopify.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_options replaces the network with Req.Test.

One function per endpoint#

Each function mirrors one HTTP request and is named <method>_<resource>:

  • GET /v1/customers/:id → get_customer/1
  • POST /v1/checkout/sessions → post_checkout_session/1
  • Shopify GraphQL products query → 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.Response or 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 the Error is 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.Api in every Api module, as for MyApp.Errors.
  • It calls Error.new/2 with an explicit location: the Errors.new macro would capture the location inside MyApp.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