Design and implement a clean, step-based DSL library for defining actions with parameter validation, authorization, telemetry, and custom business logic in Elixir/Phoenix applications.
You are tasked with designing and implementing **Axn**, a clean, step-based DSL library for defining actions in Elixir/Phoenix applications. This DSL unifies logic between Phoenix LiveView and Controllers, providing parameter validation, authorization, telemetry, and custom business logic.
Follow these core principles throughout implementation:
1. **Explicit over implicit**: Each action should clearly show its execution flow
2. **Composable**: Steps should be reusable across actions and modules
3. **Safe by default**: Telemetry and error handling should not leak sensitive data
4. **Simple to implement**: Minimal macro magic, straightforward execution model
5. **Easy to test**: Steps should be pure functions that are easy to unit test
6. **Familiar patterns**: Should feel natural to Elixir developers
Implement `Axn.Context` struct with helper functions:
```elixir
defmodule Axn.Context do
defstruct [
:action, # atom() - Current action name
assigns: %{}, # map() - Phoenix-style assigns (includes current_user, etc.)
params: %{}, # map() - Cast and validated parameters
private: %{}, # map() - Internal DSL state (raw_params, changeset, etc.)
result: nil # any() - Action result
]
# Implement helper functions:
# - assign/2, assign/3 (Phoenix.Component style)
# - get_private/2, get_private/3
# - put_private/3
# - put_params/2
# - put_result/2
end
```
**Helper Function Requirements:**
Create the main DSL module with macros:
```elixir
defmodule Axn do
defmacro __using__(opts) do
# Set up module attributes
# - @actions []
# - @telemetry_metadata_fn (from opts)
# - @current_action nil
# - @steps []
# Auto-alias Axn.Context as Context
# Register @before_compile hook
end
defmacro action(name, opts \\ [], do: block) do
# Set @current_action
# Reset @steps
# Execute block (which contains step declarations)
# Append {name, steps, opts} to @actions
# Reset @current_action
end
defmacro step(step_ref, opts \\ []) do
# Append {step_ref, opts} to @steps
# step_ref can be:
# - atom (local function)
# - {Module, :function} (external step)
end
defmacro __before_compile__(_env) do
# Generate run/3 function
# Generate run_step_pipeline/2
# Generate apply_step/3
# Generate run_action_with_telemetry/2
end
end
```
The `@before_compile` hook must generate these functions:
**run/3 - Primary entry point:**
```elixir
def run(action_name, assigns, raw_params) do
# Find action steps from @actions
# Build initial context
# Call run_action_with_telemetry
# Extract and return result
end
```
**run_action_with_telemetry/2:**
```elixir
defp run_action_with_telemetry(ctx, steps) do
# Emit [:axn, :action, :start] telemetry event
# Run step pipeline
# Emit [:axn, :action, :stop] telemetry event
# Handle exceptions with [:axn, :action, :exception] event
# Return final context
end
```
**run_step_pipeline/2:**
```elixir
defp run_step_pipeline(steps, ctx) do
# Reduce over steps
# Apply each step with apply_step/3
# Stop on {:halt, result}
# Return final context with result set
end
```
**apply_step/3:**
```elixir
defp apply_step({step_ref, opts}, ctx, _acc_opts) do
# Resolve step function (local or external)
# Call step function with context and opts
# Handle return value: {:cont, ctx} | {:halt, result}
end
```
Create `Axn.Steps` module with built-in steps:
**cast_validate_params/2:**
```elixir
def cast_validate_params(ctx, opts) do
schema = Keyword.fetch!(opts, :schema)
validate_fn = Keyword.get(opts, :validate)
raw_params = ctx.params # Initially holds raw params
# Cast params according to schema
# Apply optional custom validation function
# On success: {:cont, updated_ctx}
# On failure: {:halt, {:error, %{reason: :invalid_params, changeset: changeset}}}
end
```
**Schema Format:**
Ensure telemetry events follow this structure:
**Event Names (fixed):**
**Metadata Precedence (merged in order):**
1. Default metadata: `%{module: ..., action: ..., duration: ...}`
2. Module-level metadata (from `use Axn, metadata: &func/1`)
3. Action-level metadata (from `action :name, metadata: &func/1`)
**Implementation:**
```elixir
defp build_telemetry_metadata(ctx, action_opts) do
default = %{module: __MODULE__, action: ctx.action}
module_meta = if @telemetry_metadata_fn do
@telemetry_metadata_fn.(ctx)
else
%{}
end
action_meta = if action_opts[:metadata] do
action_opts[:metadata].(ctx)
else
%{}
end
Map.merge(default, Map.merge(module_meta, action_meta))
end
```
Ensure proper error handling throughout:
Create test suites for:
**Unit Tests (Individual Steps):**
**Integration Tests (Actions):**
**Example Test Pattern:**
```elixir
defmodule MyActionsTest do
use ExUnit.Case
test "action succeeds with valid input" do
assigns = %{current_user: %User{id: 123}}
params = %{"phone" => "+1234567890"}
assert {:ok, result} = MyActions.run(:request_otp, assigns, params)
assert result.message == "OTP sent"
end
test "action fails authorization" do
assigns = %{current_user: nil}
params = %{"phone" => "+1234567890"}
assert {:error, :unauthorized} = MyActions.run(:request_otp, assigns, params)
end
end
```
Authorization is application-specific. Implement as custom steps following these patterns:
**Pattern 1: Simple role check**
```elixir
step :require_admin
def require_admin(ctx) do
if admin?(ctx.assigns.current_user) do
{:cont, ctx}
else
{:halt, {:error, :unauthorized}}
end
end
```
**Pattern 2: Resource-based authorization**
```elixir
step :authorize_user_access
def authorize_user_access(ctx) do
if can_access?(ctx.assigns.current_user, ctx.params.user_id) do
{:cont, ctx}
else
{:halt, {:error, :unauthorized}}
end
end
```
**Pattern 3: Action-based authorization**
```elixir
step :authorize_action
def authorize_action(ctx) do
if allowed?(ctx.assigns.current_user, ctx.action) do
{:cont, ctx}
else
{:halt, {:error, :unauthorized}}
end
end
```
Once implemented, the DSL should be used like this:
```elixir
defmodule MyApp.UserActions do
use Axn, metadata: &__MODULE__.module_metadata/1
def module_metadata(ctx) do
%{user_id: ctx.assigns.current_user && ctx.assigns.current_user.id}
end
action :create_user do
step :cast_validate_params, schema: %{
email!: :string,
name!: :string,
age: [field: :integer, default: 18]
}
step :require_admin
step :create_user_record
end
def require_admin(ctx) do
if ctx.assigns.current_user.admin? do
{:cont, ctx}
else
{:halt, {:error, :unauthorized}}
end
end
def create_user_record(ctx) do
case Repo.insert(User.changeset(%User{}, ctx.params)) do
{:ok, user} -> {:halt, {:ok, user}}
{:error, changeset} -> {:halt, {:error, changeset}}
end
end
end
```
1. `lib/axn/context.ex` - Context struct and helper functions
2. `lib/axn.ex` - Main DSL macros and generated functions
3. `lib/axn/steps.ex` - Built-in steps (cast_validate_params)
4. `test/axn/context_test.exs` - Context helper tests
5. `test/axn/steps_test.exs` - Built-in step tests
6. `test/axn_test.exs` - Integration tests
7. Documentation with usage examples
Leave a review
No reviews yet. Be the first to review this skill!
# Download SKILL.md from killerskills.ai/api/skills/axn-action-dsl-implementation/raw