Building a Professional API Integration in Elixir with TDD
Integrating third-party APIs in a robust way is essential for building maintainable applications. In this post, we’ll demonstrate how to integrate with Dreamland’s API—which provides endpoints for inviting and deleting users—in a professional manner using Elixir and a Test-Driven Development (TDD) approach.
We’ll cover:
- How to structure your code using behaviors, adapters, and service modules.
- Centralizing configuration (like the base URL).
- Creating a custom HTTP adapter that handles HTTP calls, JSON encoding/decoding, and error logging.
- Writing tests using Mox so you can verify your integration without hitting the real API.
1. The Professional Architecture
A professional approach typically breaks the integration into three layers:
A. Define a Behavior (Contract)
First, you create a behavior that acts like an interface. This behavior defines the functions your Dreamland integration must support—for example, inviting and deleting a user.
File: lib/new_app/services/dreamland_provider.ex
defmodule NewApp.Services.DreamlandProvider do
@moduledoc """
Behavior defining the contract for Dreamland API interactions.
"""
@callback invite_user(map()) :: {:ok, any()} | {:error, any()}
@callback delete_user(String.t()) :: {:ok, any()} | {:error, any()}
end
Explanation:
This behavior ensures that any module claiming to be a Dreamland provider implements both invite_user/1 and delete_user/1. This way, your business logic can depend on a known contract.
B. Implement the Adapter
Next, you build an adapter that actually makes HTTP calls. This module:
- Retrieves a centralized base URL and API key from configuration.
- Constructs URLs, headers, and payloads.
- Uses our custom HTTP adapter (see below) to perform the requests.
- Maps responses and errors into a consistent format.
File: lib/new_app/services/dreamland_provider_impl.exdefmodule NewApp.Services.DreamlandProviderImpl do
@moduledoc """
Implementation of the DreamlandProvider behavior using a custom HTTP adapter.
"""
@behaviour NewApp.Services.DreamlandProvider
@http_client Application.get_env(:new_app, :http_client, NewApp.HttpAdapter)
@base_url Application.get_env(:new_app, :dreamland_base_url, "https://dreamland.demo")
@invite_endpoint "#{@base_url}/api/external/v1/users/invite"
@delete_endpoint "#{@base_url}/api/external/v1/users"
def invite_user(user_params) when is_map(user_params) do
body = %{"users" => [user_params]}
headers = [
{"accept", "application/json"},
{"content-type", "application/json"},
{"api-key", get_api_key()}
]
@http_client.post(@invite_endpoint, body, headers, [])
|> handle_response()
end
def delete_user(user_identifier) when is_binary(user_identifier) do
# Construct the delete URL by appending the encoded user identifier.
url = "#{@delete_endpoint}/#{URI.encode(user_identifier)}"
headers = [
{"accept", "application/json"},
{"api-key", get_api_key()}
]
@http_client.delete(url, headers, [])
|> handle_response()
end
defp get_api_key do
# Retrieve the API key from configuration.
Application.fetch_env!(:new_app, __MODULE__)[:api_key]
end
defp handle_response({:ok, response}), do: {:ok, response}
defp handle_response({:error, %{status_code: code, body: body}}) when code == 400, do: {:error, body}
defp handle_response({:error, error}), do: {:error, error}
end
Explanation:
This module uses a centralized base URL (read from configuration) and the API key to build HTTP headers. The adapter makes the HTTP calls using our custom HTTP adapter (which we’ll create next) and then handles the response in a consistent manner.
C. Create a Service Module (Facade)
Finally, you create a service module that delegates calls to the adapter. This module hides implementation details and makes it easy to swap out the adapter later.
File: lib/new_app/services/dreamland_service.exdefmodule NewApp.Services.DreamlandService do
@moduledoc """
Provides a unified interface for consuming Dreamland APIs.
Delegates calls to the configured Dreamland provider adapter.
"""
alias NewApp.Services.DreamlandProvider
defp impl do
Application.get_env(:new_app, DreamlandProvider)[:adapter] ||
NewApp.Services.DreamlandProviderImpl
end
def invite_user(params), do: impl().invite_user(params)
def delete_user(identifier), do: impl().delete_user(identifier)
end
Explanation:
The service module reads the adapter from configuration (with a default fallback) and provides simple functions (invite_user/1 and delete_user/1) that the rest of your application can use without knowing the details of HTTP requests.
2. Centralizing Configuration
Centralizing the base URL and other settings is crucial for maintainability. Instead of scattering URLs and keys throughout your code, you define them once in your configuration.
File: config/config.exsimport Config
config :new_app,
dreamland_base_url: "https://dreamland.demo"
config :new_app, NewApp.Services.DreamlandProviderImpl,
api_key: System.get_env("DREAMLAND_API_KEY") || "8844bfa96fb0c8ede73"
config :new_app, DreamlandProvider,
adapter: NewApp.Services.DreamlandProviderImpl
Explanation:
Now, if Dreamland changes its domain or you need to use a different key in production, you update these values in one place.
3. Building a Professional HTTP Adapter
Since referring to an internal adapter (like Sk.HttpAdapter) might confuse readers, we create a professional, self-contained HTTP adapter module. This module uses HTTPoison for HTTP calls and Jason for JSON handling, with basic logging for errors.
File: lib/new_app/http_adapter.exdefmodule NewApp.HttpAdapter do
@moduledoc """
A professional HTTP adapter using opentelemetry_httppoison.
This adapter leverages opentelemetry_httppoison to automatically instrument
HTTP calls, allowing us to track latency, response times, and errors via OpenTelemetry.
"""
require Logger
alias Jason
def get(url, headers \\ [], opts \\ []) do
case OpentelemetryHTTPoison.get(url, headers, opts) do
{:ok, %HTTPoison.Response{status_code: code, body: body}} when code < 300 ->
decode(body)
{:ok, %HTTPoison.Response{status_code: code, body: body}} ->
{:error, %{status_code: code, body: decode(body)}}
{:error, reason} ->
Logger.error("HTTP GET error: #{inspect(reason)}")
{:error, reason}
end
end
def post(url, body, headers \\ [], opts \\ []) do
headers = [{"Content-Type", "application/json"} | headers]
encoded = Jason.encode!(body)
case OpentelemetryHTTPoison.post(url, encoded, headers, opts) do
{:ok, %HTTPoison.Response{status_code: code, body: body}} when code < 300 ->
decode(body)
{:ok, %HTTPoison.Response{status_code: code, body: body}} ->
{:error, %{status_code: code, body: decode(body)}}
{:error, reason} ->
Logger.error("HTTP POST error: #{inspect(reason)}")
{:error, reason}
end
end
def delete(url, headers \\ [], opts \\ []) do
case OpentelemetryHTTPoison.delete(url, headers, opts) do
{:ok, %HTTPoison.Response{status_code: code, body: body}} when code < 300 ->
decode(body)
{:ok, %HTTPoison.Response{status_code: code, body: body}} ->
{:error, %{status_code: code, body: decode(body)}}
{:error, reason} ->
Logger.error("HTTP DELETE error: #{inspect(reason)}")
{:error, reason}
end
end
defp decode(body) do
case Jason.decode(body) do
{:ok, data} -> {:ok, data}
_ -> {:ok, body}
end
end
end
Explanation:
This adapter wraps HTTPoison calls for GET, POST, and DELETE. It encodes request bodies as JSON, decodes responses, and logs errors. Using this adapter throughout our Dreamland integration makes the HTTP layer consistent and easy to maintain.
4. Test-Driven Development (TDD)
Using a TDD approach means writing tests before (or along with) your implementation. With dependency injection and Mox, we can test our integration without hitting the real Dreamland API.
Setting Up Mox
File: test/test_helper.exsExUnit.start()
Mox.defmock(NewApp.HttpClientMock, for: NewApp.HttpClient)
Application.put_env(:new_app, :http_client, NewApp.HttpClientMock)
Explanation:
This code initializes ExUnit, defines a mock for our HTTP adapter behavior, and injects it so our tests use the mock instead of making real HTTP calls.
Writing Tests for the Dreamland Adapter
Below is our HTTP adapter that uses opentelemetry_httppoison instead of plain HTTPoison. Using opentelemetry_httppoison automatically instruments each HTTP call, so that every request creates an OpenTelemetry span. This lets you track important metrics—such as latency, error rates, and request details—in your distributed tracing system (e.g. Jaeger or Zipkin).
Advantages of Using opentelemetry_httppoison
Automatic Instrumentation:
Every HTTP call is automatically wrapped in an OpenTelemetry span. You don’t need to write extra instrumentation code—spans are created with metadata (URL, HTTP method, status code, etc.) automatically.
Improved Observability:
With telemetry data, you can see the end-to-end flow of requests, measure latency, and detect where errors occur. This helps in performance monitoring and troubleshooting.
Seamless Integration:
It integrates easily with your existing HTTP calls. By replacing HTTPoison with opentelemetry_httppoison in your adapter, you add tracing without much code change.
File: test/new_app/services/dreamland_provider_impl_test.exsdefmodule NewApp.Services.DreamlandProviderImplTest do
use ExUnit.Case, async: true
import Mox
alias NewApp.Services.DreamlandProviderImpl
setup :verify_on_exit!
test "invite_user/1 returns a successful response" do
user_params = %{
"firstName" => "test first name",
"lastName" => "test last name",
"email" => "test1@gmail.com",
"password" => "123456"
}
expected_body = %{"users" => [user_params]}
expected_headers = [
{"accept", "application/json"},
{"content-type", "application/json"},
{"api-key", "test_api_key"}
]
NewApp.HttpClientMock
|> expect(:post, fn url, body, headers, _opts ->
assert url == "https://dreamland.demo/api/users/invite"
assert body == expected_body
assert headers == expected_headers
{:ok, %{"createdUsers" => [], "failedUsers" => []}}
end)
Application.put_env(:new_app, NewApp.Services.DreamlandProviderImpl, api_key: "test_api_key")
assert {:ok, response} = DreamlandProviderImpl.invite_user(user_params)
assert response == %{"createdUsers" => [], "failedUsers" => []}
end
test "delete_user/1 returns an error for a non-existent user" do
user_identifier = "nonexistent@example.com"
expected_url = "https://dreamland.demo/api/users/#{URI.encode(user_identifier)}"
expected_headers = [
{"accept", "application/json"},
{"api-key", "test_api_key"}
]
NewApp.HttpClientMock
|> expect(:delete, fn url, headers, _opts ->
assert url == expected_url
assert headers == expected_headers
{:error, %{status_code: 400, body: %{"error" => "User not found"}}}
end)
Application.put_env(:new_app, NewApp.Services.DreamlandProviderImpl, api_key: "test_api_key")
assert {:error, %{"error" => "User not found"}} = DreamlandProviderImpl.delete_user(user_identifier)
end
end
Explanation:
These tests simulate both a successful invite and a delete error by setting expectations on our mock HTTP adapter. They verify that the correct URL, headers, and payload are being built and that responses are handled as expected.
How to Track Events with OpenTelemetry
Because opentelemetry_httppoison wraps your HTTP calls, each request automatically generates an OpenTelemetry span. These spans contain useful information, such as:
HTTP Method & URL:
Identify which endpoint was called.
Status Code:
See if the call succeeded (e.g., 200) or failed (e.g., 400, 500).
Latency:
Measure how long each request took.
Custom Attributes (if needed):
You can later enrich these spans with additional metadata if required.
Once these spans are generated, they are exported to your configured OpenTelemetry collector. From there, you can visualize your traces using tools like Jaeger, Zipkin, or any backend that supports the OpenTelemetry protocol.
Summary
In this post, we built a professional Dreamland API integration in Elixir that:
- Uses a behavior to define a clear contract for our integration.
- Implements an adapter that encapsulates all HTTP details using a custom HTTP adapter.
- Delegates calls via a service module for clean separation of concerns.
- Centralizes configuration (e.g., the base URL as "https://dreamland.demo") for maintainability.
- Uses TDD with Mox to verify our integration works as expected without relying on the real API.
This modular, testable, and maintainable architecture is what makes your integration professional and future-proof.
Happy coding and testing!
Comments
Post a Comment