defmodule Axn do @moduledoc """ Axn - A clean, step-based DSL library for defining actions with parameter validation, authorization, telemetry, and custom business logic. Axn provides a unified interface that works seamlessly across Phoenix Controllers and LiveViews, solving the limitation that Plugs only work with `Plug.Conn`. ## Core Features * **Step-based Pipeline**: Define actions as a series of composable steps that execute in order * **Parameter Validation**: Built-in schema-based parameter casting and validation using `Params` * **Authorization**: Simple patterns for implementing custom authorization logic * **Telemetry**: Automatic telemetry events with configurable metadata for monitoring * **Phoenix Integration**: Works with both Controllers (Plug.Conn) and LiveViews (Phoenix.Socket) * **Error Handling**: Consistent, structured error formats across all operations * **Composability**: Steps can be reused across actions and even shared between modules ## Key Concepts ### Actions Actions are named units of work that execute a series of steps in order. Each action automatically gets telemetry wrapping and error handling. ### Steps Steps are individual functions that take a context and either continue the pipeline or halt it. Steps follow a simple contract: `(ctx, opts) -> {:cont, new_ctx} | {:halt, result}`. ### Context An `Axn.Context` struct flows through the step pipeline, carrying request data, user information, and any step-added fields. Provides helper functions similar to `Plug.Conn` and `Phoenix.Component`. ## Quick Example defmodule MyApp.UserActions do use Axn action :create_user do step :cast_validate_params, schema: %{email!: :string, name!: :string}, validate: &__MODULE__.validate_params/2 step :require_admin step :handle_create end def validate_params(changeset, %{action: :create_user} = ctx) do changeset |> validate_format(:email, ~r/@/) |> validate_admin_permissions(ctx.assigns.current_user) end def require_admin(ctx) do if admin?(ctx.assigns.current_user) do {:cont, ctx} else {:halt, {:error, :unauthorized}} end end def handle_create(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 ## Advanced Validation Example Use pattern matching with context to handle multiple actions in a single validation function: defmodule MyApp.AuthActions do use Axn action :request_otp do step :cast_validate_params, schema: %{phone!: :string, region: [field: :string, default: "US"]}, validate: &__MODULE__.validate_params/2 step :handle_request end action :verify_otp do step :cast_validate_params, schema: %{phone!: :string, code!: :string}, validate: &__MODULE__.validate_params/2 step :handle_verify end # Single validation function handling multiple actions def validate_params(changeset, %{action: :request_otp} = ctx) do params = Ecto.Changeset.apply_changes(changeset) changeset |> validate_phone_number(:phone, region: params.region) |> validate_user_permissions(ctx.assigns.current_user) end def validate_params(changeset, %{action: :verify_otp} = ctx) do changeset |> validate_phone_number(:phone, region: "US") |> validate_otp_format(:code) |> validate_rate_limit(ctx.assigns.current_user) end end ## Usage in Phoenix # Phoenix Controller def create(conn, params) do case MyApp.UserActions.run(:create_user, params, conn) do {:ok, user} -> json(conn, %{success: true, user: user}) {:error, %{reason: :invalid_params, changeset: changeset}} -> json(conn, %{errors: format_changeset_errors(changeset)}) {:error, reason} -> json(conn, %{error: reason}) end end # Phoenix LiveView def handle_event("submit", 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, reason)} end end ## Design Principles * **Explicit over implicit**: Each action clearly shows its execution flow * **Composable**: Steps should be reusable across actions and modules * **Safe by default**: Telemetry and error handling do not leak sensitive data * **Simple to implement**: Minimal macro magic, straightforward execution model * **Easy to test**: Steps are pure functions that are easy to unit test * **Familiar patterns**: Feels natural to experienced Elixir developers See the module documentation for `Axn.Context` for details on the context struct and helper functions available in steps. """ alias Axn.Steps.CastValidateParams alias Axn.Telemetry @doc """ Sets up a module to use the Axn DSL for defining actions. ## Options * `:metadata` - Function that takes a context and returns a map of custom metadata for telemetry events. Optional. ## Examples defmodule MyApp.UserActions do use Axn action :create_user do # Action definition end end 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 """ defmacro __using__(opts) do metadata_fn = Keyword.get(opts, :metadata) quote do import Axn, only: [action: 2, action: 3, step: 1, step: 2] import Axn.Context alias Axn.Context @module_opts unquote(opts) @telemetry_metadata_fn unquote(metadata_fn) @actions [] @current_action nil @steps [] @before_compile Axn end end @doc """ Defines an action with its steps. ## Examples action :create_user do step :cast_validate_params, schema: %{name!: :string} step :authorize, &can_create_users?/1 step :handle_create end action :create_user, metadata: &action_metadata/1 do step :cast_validate_params, schema: %{name!: :string} step :handle_create end """ defmacro action(name, do: block) do quote do @current_action unquote(name) @steps [] unquote(block) @actions [{unquote(name), Enum.reverse(@steps), []} | @actions] @current_action nil @steps [] end end @doc """ Defines an action with options and its steps. ## Options * `:metadata` - Function that takes a context and returns a map of action-specific metadata for telemetry events. Optional. ## Examples action :create_user, metadata: &create_user_metadata/1 do step :cast_validate_params, schema: %{name!: :string} step :handle_create end """ defmacro action(name, opts, do: block) do quote do @current_action unquote(name) @steps [] unquote(block) @actions [{unquote(name), Enum.reverse(@steps), unquote(opts)} | @actions] @current_action nil @steps [] end end @doc """ Defines a step within an action. Steps must implement one of these function signatures: * `step_name(ctx)` - Receives context only * `step_name(ctx, opts)` - Receives context and step options ## Return Values Steps must return one of: * `{:cont, updated_context}` - Continue to next step with updated context * `{:halt, {:ok, result}}` - Stop pipeline with success result * `{:halt, {:error, reason}}` - Stop pipeline with error result ## Examples # Step with no options step :my_step # Step with options step :my_step, option: value # External step from another module step {ExternalModule, :external_step}, option: value # Built-in parameter validation step step :cast_validate_params, schema: %{name!: :string, age: :integer} ## Step Implementation # Simple step that just continues def my_step(ctx) do {:cont, Context.assign(ctx, :processed, true)} end # Step with options that modifies context def my_step_with_options(ctx, opts) do value = Keyword.get(opts, :option, "default") {:cont, Context.assign(ctx, :custom_value, value)} end # Step that can halt the pipeline def require_admin(ctx) do if admin?(ctx.assigns.current_user) do {:cont, ctx} else {:halt, {:error, :unauthorized}} end end # Step that completes the action def handle_create(ctx) do case create_user(ctx.params) do {:ok, user} -> {:halt, {:ok, user}} {:error, reason} -> {:halt, {:error, reason}} end end """ defmacro step(step_spec, opts \\ []) do quote do @steps [{unquote(step_spec), unquote(opts)} | @steps] end end @doc false defmacro __before_compile__(_env) do quote do @actions Enum.reverse(@actions) unquote(generate_run_function()) unquote(generate_result_helpers()) unquote(generate_pipeline_functions()) unquote(generate_step_execution_functions()) end end defp generate_run_function do quote do @doc """ Runs an action with the given parameters and source. This is the primary entry point for executing actions. It handles the full action pipeline including parameter processing, step execution, telemetry, and error handling. ## Parameters * `action` - Atom representing the action name to execute * `params` - Map of parameters to pass to the action (typically user input) * `source` - Context source containing assigns and other request data ## Source Types The source parameter provides context and can be: * **Plain map** - Treated as assigns directly * **Phoenix.LiveView.Socket** - Assigns extracted from `socket.assigns` * **Plug.Conn** - Assigns extracted from `conn.assigns` * **Any struct with `assigns` field** - Assigns extracted automatically The original source is preserved in `ctx.private.source` for steps that need to access framework-specific functionality. ## Return Values * `{:ok, result}` - Action completed successfully * `{:error, reason}` - Action failed, see error formats below ## Error Formats Different types of failures return structured error information: ### Action/Step Resolution Errors * `{:error, :action_not_found}` - The specified action doesn't exist * `{:error, :step_not_found}` - A step function couldn't be found ### Parameter Validation Errors * `{:error, %{reason: :invalid_params, changeset: changeset}}` - Parameter validation failed The changeset contains field-level validation errors and can be used to generate user-friendly error messages. ### Step Execution Errors * `{:error, %{reason: :step_exception, message: message, step: step_name}}` - Step raised an exception * `{:error, custom_reason}` - Custom error returned by a step via `{:halt, {:error, custom_reason}}` ### Authorization Errors * `{:error, :unauthorized}` - Standard format for authorization failures * `{:error, :forbidden}` - Alternative format for permission denied ## Framework Integration Examples ### Phoenix Controller Usage def create(conn, params) do case MyApp.UserActions.run(:create_user, params, conn) do {:ok, user} -> json(conn, %{success: true, user: user}) {:error, %{reason: :invalid_params, changeset: changeset}} -> conn |> put_status(400) |> json(%{errors: format_changeset_errors(changeset)}) {:error, :unauthorized} -> conn |> put_status(403) |> json(%{error: "Unauthorized"}) {:error, reason} -> conn |> put_status(500) |> json(%{error: "Internal error"}) end end ### Phoenix LiveView Usage def handle_event("submit", params, socket) do case MyApp.FormActions.run(:submit_form, params, socket) do {:ok, result} -> {:noreply, socket |> put_flash(:info, "Success!") |> assign(:result, result)} {:error, %{reason: :invalid_params, changeset: changeset}} -> {:noreply, assign(socket, :changeset, changeset)} {:error, reason} -> {:noreply, put_flash(socket, :error, "Error: \#{inspect(reason)}")} end end ### Direct Usage with Assigns Map assigns = %{current_user: user, tenant: tenant} case MyApp.BusinessActions.run(:process_order, order_params, assigns) do {:ok, order} -> {:ok, order} {:error, reason} -> handle_error(reason) end ## Telemetry Events All action executions automatically emit telemetry events: * `[:axn, :action, :start]` - When action begins * `[:axn, :action, :stop]` - When action completes successfully * `[:axn, :action, :exception]` - When action fails with exception Events include metadata such as module, action name, duration, and any custom metadata configured via `:metadata` options. """ def run(action, params, source) do case run_action_pipeline(action, params, source) do %Axn.Context{result: result} -> normalize_result(result) {:error, reason} -> {:error, reason} end end defp extract_assigns_and_source(source) do case source do %{assigns: assigns} -> {assigns, source} assigns when is_map(assigns) -> {assigns, source} _ -> {%{}, source} end end end end defp generate_result_helpers do quote do defp normalize_result({:ok, value}), do: {:ok, value} defp normalize_result({:error, reason}), do: {:error, reason} defp normalize_result(nil), do: {:ok, nil} defp normalize_result(other), do: {:ok, other} defp find_action(action_name) do case Enum.find(@actions, fn {name, _steps, _opts} -> name == action_name end) do {_name, steps, opts} -> {:ok, steps, opts} nil -> {:error, :action_not_found} end end end end defp generate_pipeline_functions do quote do defp run_action_pipeline(action, params, source) do case find_action(action) do {:ok, steps, action_opts} -> {assigns, original_source} = extract_assigns_and_source(source) ctx = %Axn.Context{ module: __MODULE__, action: action, assigns: assigns, params: params, private: %{source: original_source} } Telemetry.run_with_telemetry( ctx, action_opts, @module_opts, fn -> run_step_pipeline(steps, ctx) end ) {:error, reason} -> {:error, reason} end end defp run_step_pipeline(steps, %Axn.Context{} = ctx) do Enum.reduce_while(steps, ctx, fn step, acc_ctx -> case apply_step(step, acc_ctx) do {:cont, new_ctx} -> {:cont, new_ctx} {:halt, result} -> {:halt, %{acc_ctx | result: result}} end end) end end end defp generate_step_execution_functions do quote do defp apply_step({step_name, opts}, %Axn.Context{} = ctx) when is_atom(step_name) do case step_name do :cast_validate_params -> CastValidateParams.cast_validate_params(ctx, opts) _ -> apply_step_function(__MODULE__, step_name, ctx, opts) end end defp apply_step({{module, function}, opts}, %Axn.Context{} = ctx) do apply_step_function(module, function, ctx, opts) end defp apply_step_function(module, function, ctx, opts) do cond do function_exported?(module, function, 2) -> apply(module, function, [ctx, opts]) function_exported?(module, function, 1) -> apply(module, function, [ctx]) true -> {:halt, {:error, :step_not_found}} end end end end end