Enact.Action behaviour (Enact v0.1.0)

Copy Markdown View Source

The behaviour every action module implements.

use Enact.Action sets @behaviour Enact.Action and overridable defaults for the optional callbacks — that is its entire body; there is no DSL and no code generation.

Required: input/0, authorize/1, execute/2. An open write is a written authorize/1 returning true, never an omitted callback.

Every callback is either pure data (config/0, input/0, resolvers/0) or a single-purpose function over (changeset | params, ctx). The runner (Enact.run/3) orchestrates them as:

load  authorize  cast  validate  resolve  execute  after_commit

Summary

Callbacks

Post-commit side effects (job insertion, analytics). Runs strictly after a successful commit, outside the transaction; its return value is ignored and it never rolls back. A raise propagates to the caller, but the write is already committed and the success telemetry event has already been emitted — the audit trail records the write either way.

Authorizes the actor against the loaded context. Required — a forgotten policy is indistinguishable from an open write. Runs before cast — unauthorized callers trigger no input work.

Static action configuration.

Persists the write. Runs inside repo.transaction/1; {:error, reason} rolls back. A returned %Ecto.Changeset{} error (declared constraints) is promoted to :invalid; any other error becomes :internal.

The input-schema module (Enact.InputSchema), or nil for input-less actions (archive, cancel, resend) — the runner then skips cast and validate, and Enact.updates/2 returns %{}.

Fetches the subject — the URL-anchored record the action operates on or within (the updated record in patch mode; the parent in create-under-parent). Always invoked. The default is a no-op that returns :no_subject (leave ctx.subject nil). Returning nil produces :not_found. Must scope by the trust anchor (§9 of the design spec). Params are string-keyed — the runner stringifies atom keys at the run/dry_run/subject/authorized boundary — and values are uncast. Match %{"id" => id}, not params[:id].

Declarative reference-resolution spec: [name: {field_or_path, fetcher}]. See Enact.Resolve.

Ordinary changeset pipeline for operation-specific rules (payload- intrinsic rules belong in the input module's own validations). Not invoked for input: nil actions.

Callbacks

after_commit(result, ctx)

(optional)
@callback after_commit(result :: term(), ctx :: Enact.Context.t()) :: term()

Post-commit side effects (job insertion, analytics). Runs strictly after a successful commit, outside the transaction; its return value is ignored and it never rolls back. A raise propagates to the caller, but the write is already committed and the success telemetry event has already been emitted — the audit trail records the write either way.

authorize(ctx)

@callback authorize(ctx :: Enact.Context.t()) :: boolean() | {:error, term()}

Authorizes the actor against the loaded context. Required — a forgotten policy is indistinguishable from an open write. Runs before cast — unauthorized callers trigger no input work.

Return values:

  • true — proceed
  • false{:error, %Enact.Error{type: :forbidden}}
  • {:error, reason}{:error, %Enact.Error{type: :forbidden, reason: reason}} ({:error, :foo}{:error, %Enact.Error{type: :forbidden, reason: :foo}})
  • {:error, %Enact.Error{}} — passed through

reason is for logs, telemetry, and the host renderer. Do not echo it wholesale. Match host-owned atoms in the renderer when the 403 copy should vary; leave the default opaque.

An open write (any non-anonymous actor; or anyone, on anonymous?: true actions) is a written true, never an omitted callback.

config()

(optional)
@callback config() :: keyword()

Static action configuration.

Recognized keys (all optional): mode: :create | :patch (default :create), anonymous?: boolean (default false).

execute(changeset, ctx)

@callback execute(changeset :: Ecto.Changeset.t(), ctx :: Enact.Context.t()) ::
  {:ok, term()} | {:error, term()}

Persists the write. Runs inside repo.transaction/1; {:error, reason} rolls back. A returned %Ecto.Changeset{} error (declared constraints) is promoted to :invalid; any other error becomes :internal.

input()

@callback input() :: module() | nil

The input-schema module (Enact.InputSchema), or nil for input-less actions (archive, cancel, resend) — the runner then skips cast and validate, and Enact.updates/2 returns %{}.

load_subject(params, ctx)

(optional)
@callback load_subject(params :: map(), ctx :: Enact.Context.t()) ::
  struct() | nil | :no_subject | {:error, term()}

Fetches the subject — the URL-anchored record the action operates on or within (the updated record in patch mode; the parent in create-under-parent). Always invoked. The default is a no-op that returns :no_subject (leave ctx.subject nil). Returning nil produces :not_found. Must scope by the trust anchor (§9 of the design spec). Params are string-keyed — the runner stringifies atom keys at the run/dry_run/subject/authorized boundary — and values are uncast. Match %{"id" => id}, not params[:id].

resolvers()

(optional)
@callback resolvers() :: keyword()

Declarative reference-resolution spec: [name: {field_or_path, fetcher}]. See Enact.Resolve.

validate(changeset, ctx)

(optional)
@callback validate(changeset :: Ecto.Changeset.t(), ctx :: Enact.Context.t()) ::
  Ecto.Changeset.t()

Ordinary changeset pipeline for operation-specific rules (payload- intrinsic rules belong in the input module's own validations). Not invoked for input: nil actions.