Packages

A clean, step-based DSL library for defining actions with parameter validation, authorization, telemetry, and custom business logic. Prioritizes simplicity, explicitness, and ease of following execution flow.

Current section

Files

Jump to
axn README.md
Raw

README.md

# Axn - Unified Action DSL for Phoenix

A clean, step-based DSL library for defining actions that work seamlessly across Phoenix Controllers and LiveViews. Axn provides a unified interface for parameter validation, authorization, telemetry, and business logic where Plugs cannot be used.

**Why Axn?** Plugs only work with `Plug.Conn` but not `Phoenix.LiveView.Socket`. Axn bridges this gap, letting you write action logic once and use it in both contexts.

[![Hex.pm](https://img.shields.io/hexpm/v/axn.svg)](https://hex.pm/packages/axn)
[![Documentation](https://img.shields.io/badge/docs-hexdocs-blue.svg)](https://hexdocs.pm/axn)

## Installation

```elixir
def deps do
  [
    {:axn, "~> 0.1.0"}
  ]
end
```

## Quick Start

```elixir
defmodule MyApp.UserActions do
  use Axn

  action :create_user do
    step :validate_params
    step :require_admin
    step :create_user

    def validate_params(ctx) do
      # Simple validation - details in docs
      {:cont, ctx}
    end

    def require_admin(ctx) do
      if admin?(ctx.assigns.current_user) do
        {:cont, ctx}
      else
        {:halt, {:error, :unauthorized}}
      end
    end

    def create_user(ctx) do
      case Users.create(ctx.params) do
        {:ok, user} -> {:halt, {:ok, user}}
        {:error, reason} -> {:halt, {:error, reason}}
      end
    end

    defp admin?(user), do: user && user.role == "admin"
  end
end

# Use in Phoenix Controller
def create(conn, params) do
  case MyApp.UserActions.run(:create_user, params, conn) do
    {:ok, user} -> json(conn, %{user: user})
    {:error, reason} -> json(conn, %{error: reason})
  end
end

# Use in Phoenix LiveView
def handle_event("create_user", params, socket) do
  case MyApp.UserActions.run(:create_user, params, socket) do
    {:ok, user} -> {:noreply, assign(socket, :user, user)}
    {:error, reason} -> {:noreply, put_flash(socket, :error, "Error: #{reason}")}
  end
end
```

## Core Concepts

### Actions

Actions are named units of work that execute steps in order:

```elixir
action :action_name do
  step :step_name
  step :step_name, option: value
  step {ExternalModule, :external_step}
end
```

### Steps

Steps take a context and either continue or halt the pipeline:

```elixir
def my_step(ctx) do
  {:cont, updated_ctx}           # Continue to next step
  # OR
  {:halt, {:ok, result}}         # Stop with success
  # OR
  {:halt, {:error, reason}}      # Stop with error
end
```

### Context

The `Axn.Context` struct flows through steps, carrying data:

```elixir
%Axn.Context{
  action: :create_user,
  assigns: %{current_user: user},    # Phoenix-style assigns
  params: %{email: "...", name: "..."},  # Request parameters
  private: %{},                      # Internal state
  result: nil                        # Final result
}
```

## Built-in Steps

### Parameter Validation

```elixir
step :cast_validate_params, schema: %{
  email!: :string,                        # Required
  name: :string,                          # Optional
  age: [field: :integer, default: 18]     # With default
}

# With custom validation
step :cast_validate_params,
     schema: %{phone!: :string},
     validate: &validate_phone/1
```

## Authorization

Create simple authorization steps:

```elixir
step :require_admin

def require_admin(ctx) do
  if admin?(ctx.assigns.current_user) do
    {:cont, ctx}
  else
    {:halt, {:error, :unauthorized}}
  end
end
```


## Telemetry

Axn automatically emits telemetry events:

- `[:axn, :action, :start]` - Action starts
- `[:axn, :action, :stop]` - Action completes
- `[:axn, :action, :exception]` - Action fails

### Custom Metadata

```elixir
defmodule MyApp.UserActions do
  use Axn, metadata: &__MODULE__.telemetry_metadata/1

  def telemetry_metadata(ctx) do
    %{
      user_id: ctx.assigns.current_user && ctx.assigns.current_user.id,
      tenant: ctx.assigns.tenant && ctx.assigns.tenant.slug
    }
  end
end
```

## Unified Phoenix Integration

**The Problem:** Plugs work with Controllers but not LiveViews, creating code duplication.

**The Solution:** Axn works with both contexts seamlessly.

```elixir
# Same action works in both:
MyApp.UserActions.run(:create_user, params, conn)    # Controller
MyApp.UserActions.run(:create_user, params, socket)  # LiveView
```

The action automatically extracts assigns from either `conn` or `socket`, eliminating the need to duplicate authorization, validation, and business logic.

## Testing

```elixir
test "create_user succeeds with valid input" do
  assigns = %{current_user: %User{role: "admin"}}
  params = %{"email" => "test@example.com", "name" => "John"}

  assert {:ok, user} = MyApp.UserActions.run(:create_user, params, assigns)
  assert user.email == "test@example.com"
end
```

## Error Handling

Axn provides consistent error handling:

```elixir
# Parameter errors
{:error, %{reason: :invalid_params, changeset: changeset}}

# Authorization errors
{:error, :unauthorized}

# Custom errors
{:error, :custom_reason}
```

## External Steps

Use steps from other modules:

```elixir
action :complex_operation do
  step :validate_params
  step {MySteps, :enrich_context}, fields: [:preferences]
  step :handle_operation
end
```

## Performance

- Minimal overhead when telemetry is disabled
- Efficient pipeline using `Enum.reduce_while/3`
- Steps are pure functions, easy to optimize

## Comparison

### vs. Phoenix Plugs
- **Plugs**: Work only with Controllers (`Plug.Conn`)
- **Axn**: Works with both Controllers and LiveViews

### vs. Phoenix Contexts
- **Contexts**: Business logic modules, manual integration
- **Axn**: Built-in Phoenix integration with parameter validation and telemetry

## Contributing

1. Fork the repository
2. Create your feature branch (`git checkout -b my-new-feature`)
3. Add tests for your changes
4. Ensure all tests pass (`mix test`)
5. Run static analysis (`mix credo`)
6. Commit your changes (`git commit -am 'Add some feature'`)
7. Push to the branch (`git push origin my-new-feature`)
8. Create a new Pull Request

## License

MIT