Skip to main content

Building a Professional API Integration in Elixir with TDD

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.ex
defmodule 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.ex
defmodule 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.exs
import 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.ex
defmodule 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.exs
ExUnit.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.exs
defmodule 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

Popular posts from this blog

Building a Real-Time Collaborative Editing Application with Phoenix LiveView and Presence

In this blog post, we will walk through the process of building a real-time collaborative document editing application using Phoenix LiveView. We will cover everything from setting up the project, creating the user interface, implementing real-time updates, and handling user presence. By the end of this tutorial, you'll have a fully functional collaborative editor that allows multiple users to edit a document simultaneously, with real-time updates and presence tracking. User Flow  Before diving into the code, let's outline the user flow and wireframes for our application. This will help us understand the overall structure and functionality we aim to achieve. Landing Page: The user is greeted with a landing page that prompts them to enter their name. Upon entering their name and clicking "Submit", they are redirected to the document list page. Document List Page: The user sees a list of available documents. Each document title is a clickable link that takes the user to...

Handling Massive Concurrency with Elixir and OTP: Advanced Practical Example

For advanced Elixir developers, handling massive concurrency involves not just understanding the basics of GenServers, Tasks, and Supervisors, but also effectively utilizing more complex patterns and strategies to optimize performance and ensure fault tolerance. In this blog post, we'll dive deeper into advanced techniques and provide a practical example involving a distributed, fault-tolerant system. Practical Example: Distributed Web Crawler We'll build a distributed web crawler that can handle massive concurrency by distributing tasks across multiple nodes, dynamically supervising crawling processes, and implementing rate limiting to control the crawling rate. In this example, we will build a distributed web crawler that simulates handling massive concurrency and backpressure. To achieve this, we will: Generate 100 unique API URLs that will be processed by our system. Create an API within the application that simulates slow responses using :timer.sleep to introduce artificia...

Integrating Elixir with Rust for Advanced WebSocket Message Decoding

As developers, we often face scenarios where we need to push the boundaries of performance and efficiency. One such case is decoding complex WebSocket messages in real-time financial applications. In this blog post, we'll explore how to leverage Rust's performance within an Elixir application to decode WebSocket messages from Zerodha's Kite Connect API. Why Integrate Elixir with Rust? Elixir is known for its concurrency and fault-tolerance, making it an excellent choice for building scalable applications. However, Rust offers unmatched performance and memory safety, making it ideal for CPU-intensive tasks like decoding binary WebSocket messages. By integrating Rust with Elixir, we can achieve the best of both worlds. The Challenge: Decoding Kite Connect WebSocket Messages Zerodha's Kite Connect API provides market data via WebSocket in binary format. These messages need to be decoded efficiently to be useful. While Elixir is powerful, decoding binary data is an area whe...