Sink

Build StatusHex.pmDocsLicense

Universal local adapter generator for Elixir behaviours with a real-time web dashboard.

Sink eliminates boilerplate when creating local/development adapters for external services. Generate a complete local adapter from any behaviour in one line, with automatic call capture and a LiveView dashboard for inspection.

Inspired by Swoosh's local mailbox preview.

Why Sink?

I use the adapter pattern extensively for external services—SMS gateways, push notifications, webhooks, payment providers, and more. Each one needs a local implementation for development and testing. Writing these by hand is tedious, and debugging what actually got called is a pain.

Sink is my toolkit for working with adapters. It lets me work offline without hitting real services, instantly see every call that was made, and forward captured calls to real implementations when I need to verify things work end-to-end.

Installation

Add sink to your list of dependencies in mix.exs:

def deps do
  [
    {:sink, "~> 0.1.0", only: [:dev, :test]}
  ]
end

Usage

1. Define your behaviour

defmodule MyApp.SmsGateway do
  @callback send_sms(to :: String.t(), message :: String.t()) :: :ok | {:error, term()}
  @callback check_status(message_id :: String.t()) :: {:ok, atom()} | {:error, term()}
end

2. Create a local adapter

defmodule MyApp.SmsGateway.Local do
  use Sink, behaviour: MyApp.SmsGateway
end

That's it! The adapter implements all callbacks, captures all calls, and returns :ok by default.

3. Configure for development

# config/dev.exs
config :my_app, :sms_gateway, MyApp.SmsGateway.Local

4. View the dashboard

Start your app and visit http://localhost:4041 to inspect captured calls in real-time.

Features

Configuration

Options

Option Default Description
:behaviour required The behaviour module to implement
:capturetrue Whether to capture calls to storage
:logtrue Whether to log calls
:log_level:debug Log level to use
:metadata%{} Static metadata to attach to all calls
:allow_forward_to[] List of adapter modules that captured calls can be forwarded to

Custom Return Values

Override default_return/2 to control what each function returns:

defmodule MyApp.Storage.Local do
  use Sink, behaviour: MyApp.Storage

  def default_return(:read, [path]) do
    {:ok, "Mock content for #{path}"}
  end

  def default_return(:exists?, _args), do: true
  def default_return(_, _), do: :ok
end

Adding Metadata

Attach static metadata via options:

defmodule MyApp.SmsGateway.Local do
  use Sink,
    behaviour: MyApp.SmsGateway,
    metadata: %{provider: :local, region: "dev"}
end

Or dynamic metadata per call:

def call_metadata(:send_sms, [to, _message]) do
  %{country_code: String.slice(to, 0, 3)}
end

def call_metadata(_, _), do: %{}

Dashboard Configuration

The dashboard runs on port 4041 by default. To change the port:

config :sink, Sink.Web.Endpoint,
  http: [port: 4042]

To disable the dashboard:

config :sink, start_dashboard: false

Global Settings

config :sink,
  max_calls: 1000  # Maximum calls to retain in memory (default: 1000)

Programmatic API

# List all calls
Sink.Storage.all()

# List with filters
Sink.Storage.list(
  behaviour: MyApp.SmsGateway,
  adapter: MyApp.SmsGateway.Local,
  function: :send_sms,
  since: ~U[2024-01-01 00:00:00Z],
  order: :asc,
  limit: 50
)

# Get a single call
Sink.Storage.get("call_id")

# Get statistics
Sink.Storage.stats()
# => %{total: 42, by_behaviour: %{...}, by_adapter: %{...}}

# Clear calls
Sink.Storage.delete_all()
Sink.Storage.delete(behaviour: MyApp.SmsGateway)

# Pop the most recent call
call = Sink.Storage.pop()

# Subscribe to real-time notifications
Sink.Storage.subscribe()
Sink.Storage.subscribe(MyApp.SmsGateway)
# Receive: {:call_captured, call} or :calls_cleared

Forwarding Calls

Captured calls can be forwarded to a real adapter for debugging or replay:

defmodule MyApp.SmsGateway.Local do
  use Sink,
    behaviour: MyApp.SmsGateway,
    allow_forward_to: [MyApp.SmsGateway.Twilio]
end

# Forward a captured call
{:ok, call} = Sink.Storage.get("call_id")
{:ok, result} = Sink.forward_call(call, MyApp.SmsGateway.Twilio)

Testing

Use Sink to assert on adapter calls in your tests:

defmodule MyApp.CheckoutTest do
  use ExUnit.Case

  setup do
    Sink.Storage.delete_all()
    :ok
  end

  test "checkout sends SMS confirmation" do
    MyApp.Checkout.complete(order)

    [call] = Sink.Storage.list(behaviour: MyApp.SmsGateway)
    assert call.function == :send_sms
    assert [phone, message] = call.args
    assert phone == "+1234567890"
    assert message =~ "Order confirmed"
  end
end

How It Works

Sink uses Elixir's macro system to introspect behaviours at compile time. When you use Sink, behaviour: SomeBehaviour:

  1. Calls SomeBehaviour.behaviour_info(:callbacks) to get all callback definitions
  2. For each callback, generates a function that:
    • Calls your default_return/2 to get the return value
    • Optionally logs the call
    • Captures the call to ETS storage
    • Returns the value

The dashboard uses Phoenix LiveView with PubSub for real-time updates.

Links

License

MIT License. See LICENSE for details.