Sink
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]}
]
endUsage
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()}
end2. 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.Local4. View the dashboard
Start your app and visit http://localhost:4041 to inspect captured calls in real-time.
Features
- One-line adapters -
use Sink, behaviour: MyBehaviourgenerates complete implementations - Automatic call capture - Every call is stored with function, args, return value, and timestamp
- LiveView dashboard - Real-time web UI to inspect captured calls
- Customizable returns - Override
default_return/2to control what each function returns - Metadata support - Attach static or dynamic metadata to calls
- Test assertions - Query captured calls in your tests
- Call forwarding - Forward captured calls to real adapters for debugging
Configuration
Options
| Option | Default | Description |
|---|---|---|
:behaviour | required | The behaviour module to implement |
:capture | true | Whether to capture calls to storage |
:log | true | 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
endAdding Metadata
Attach static metadata via options:
defmodule MyApp.SmsGateway.Local do
use Sink,
behaviour: MyApp.SmsGateway,
metadata: %{provider: :local, region: "dev"}
endOr 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: falseGlobal 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_clearedForwarding 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
endHow It Works
Sink uses Elixir's macro system to introspect behaviours at compile time. When you use Sink, behaviour: SomeBehaviour:
-
Calls
SomeBehaviour.behaviour_info(:callbacks)to get all callback definitions -
For each callback, generates a function that:
-
Calls your
default_return/2to get the return value - Optionally logs the call
- Captures the call to ETS storage
- Returns the value
-
Calls your
The dashboard uses Phoenix LiveView with PubSub for real-time updates.
Links
License
MIT License. See LICENSE for details.