README.md

# Sink

[![Build Status](https://github.com/chrisnordqvist/sink/actions/workflows/ci.yml/badge.svg)](https://github.com/chrisnordqvist/sink/actions)
[![Hex.pm](https://img.shields.io/hexpm/v/sink.svg)](https://hex.pm/packages/sink)
[![Docs](https://img.shields.io/badge/docs-hexpm-blue.svg)](https://hexdocs.pm/sink)
[![License](https://img.shields.io/hexpm/l/sink.svg)](https://github.com/chrisnordqvist/sink/blob/main/LICENSE)

> 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](https://github.com/swoosh/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`:

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

## Usage

### 1. Define your behaviour

```elixir
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

```elixir
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

```elixir
# 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

- **One-line adapters** - `use Sink, behaviour: MyBehaviour` generates 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/2` to 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:

```elixir
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:

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

Or dynamic metadata per call:

```elixir
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:

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

To disable the dashboard:

```elixir
config :sink, start_dashboard: false
```

### Global Settings

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

## Programmatic API

```elixir
# 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:

```elixir
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:

```elixir
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

- [Documentation](https://hexdocs.pm/sink)
- [Hex.pm](https://hex.pm/packages/sink)
- [GitHub](https://github.com/chrisnordqvist/sink)

## License

MIT License. See [LICENSE](LICENSE) for details.