Packages
Event-driven notification system for Ash Framework with multiple transport types
Current section
Files
Jump to
Current section
Files
lib/resource/transformers/validate_events.ex
defmodule AshDispatch.Resource.Transformers.ValidateEvents do
@moduledoc """
Transformer that validates event configurations.
Checks:
- Actions referenced in `trigger_on` actually exist
- Events have either inline configuration OR a module (not both empty)
- Channel configurations are valid
- Event names are unique within the resource
"""
use Spark.Dsl.Transformer
alias Spark.Dsl.Transformer
alias Spark.Error.DslError
# Run after Ash's transformers that register and validate actions
@impl true
def after?(Ash.Resource.Transformers.SetPrimaryActions), do: true
@impl true
def after?(_), do: false
@impl true
def transform(dsl_state) do
# Filter to only Event structs — the dispatch section may also contain
# EntityChanges, ResourceMeta, AudiencePrefix, etc.
events =
dsl_state
|> Transformer.get_entities([:dispatch])
|> Enum.filter(&match?(%AshDispatch.Resource.Dsl.Event{}, &1))
with :ok <- validate_unique_names(events, dsl_state),
:ok <- validate_trigger_actions(events, dsl_state),
:ok <- validate_event_configs(events, dsl_state),
:ok <- validate_action_label_requires_url(events, dsl_state),
:ok <- validate_required_event_metadata(events, dsl_state) do
{:ok, dsl_state}
end
end
# F4 — for each event, check that every channel's transport has its
# `required_event_metadata_keys/0` satisfied by the event's `metadata`
# map. Pre-F4 the `:oban` transport soft-failed on missing
# `:oban_worker` (runtime warning + receipt :skipped). Operators
# discovered the gap only by noticing their Oban queue stayed empty.
# With F4 the gap is impossible to ship — Spark.Error.DslError points
# at the registration site at compile time.
# Event DSL `metadata:` arrives as either a map (when set via the DSL's
# default-empty `metadata: %{}`) or a keyword list (when authored as
# `metadata: [k: v]` in the registration). Normalize both shapes.
defp event_metadata_keys(nil), do: []
defp event_metadata_keys(meta) when is_map(meta), do: Map.keys(meta)
defp event_metadata_keys(meta) when is_list(meta), do: Keyword.keys(meta)
# Channels in event entities arrive as keyword lists (`[transport: :x,
# audience: :y]`) — not `%AshDispatch.Dsl.Channel{}` structs. Read the
# transport field via Keyword.get for the keyword shape; preserve
# struct shape for any future change that normalizes to structs.
defp channel_transport(channel) when is_list(channel), do: Keyword.get(channel, :transport)
defp channel_transport(%{transport: transport}), do: transport
defp validate_required_event_metadata(events, dsl_state) do
bad_events =
Enum.flat_map(events, fn event ->
event_meta_keys = event_metadata_keys(event.metadata)
Enum.flat_map(event.channels, fn channel ->
transport = channel_transport(channel)
required = AshDispatch.Transport.Registry.required_event_metadata_keys(transport)
missing = required -- event_meta_keys
if missing == [], do: [], else: [{event.name, transport, missing}]
end)
end)
case bad_events do
[] ->
:ok
[{event_name, transport, missing} | _] ->
{:error,
DslError.exception(
module: Transformer.get_persisted(dsl_state, :module),
path: [:dispatch, event_name],
message: """
Event `#{inspect(event_name)}` declares a `transport: #{inspect(transport)}` channel \
but is missing required metadata key(s): #{inspect(missing)}
Add the keys to the event's `metadata:` block. Example for `:oban`:
event #{inspect(event_name)},
channels: [[transport: #{inspect(transport)}, audience: :system]],
metadata: [
oban_worker: MyApp.Workers.X,
oban_unique_keys: [:entry_id]
]
Required by `AshDispatch.Transports.#{transport |> Atom.to_string() |> Macro.camelize()}.required_event_metadata_keys/0`.
"""
)}
end
end
# Validate event names are unique
defp validate_unique_names(events, dsl_state) do
names = Enum.map(events, & &1.name)
duplicates = names -- Enum.uniq(names)
case duplicates do
[] ->
:ok
[first | _] ->
{:error,
DslError.exception(
module: Transformer.get_persisted(dsl_state, :module),
path: [:dispatch],
message: "Duplicate event name: #{inspect(first)}"
)}
end
end
# Validate that trigger_on actions exist (skip :manual events)
defp validate_trigger_actions(events, dsl_state) do
all_actions = get_all_action_names(dsl_state)
invalid_events =
Enum.filter(events, fn event ->
# Skip validation for manual-only events
if event.trigger_on == :manual do
false
else
trigger_actions =
case event.trigger_on do
name when is_atom(name) -> [name]
names when is_list(names) -> names
end
Enum.any?(trigger_actions, fn action_name ->
action_name not in all_actions
end)
end
end)
case invalid_events do
[] ->
:ok
[event | _] ->
missing =
case event.trigger_on do
name when is_atom(name) -> name
[name | _] -> name
end
{:error,
DslError.exception(
module: Transformer.get_persisted(dsl_state, :module),
path: [:dispatch],
message: """
Event #{inspect(event.name)} references non-existent action: #{inspect(missing)}
Available actions: #{inspect(all_actions)}
"""
)}
end
end
# Validate event has configuration
defp validate_event_configs(events, dsl_state) do
invalid_events =
Enum.filter(events, fn event ->
# Event must have either channels OR a module
has_channels = length(event.channels) > 0
has_module = not is_nil(event.module)
not (has_channels or has_module)
end)
case invalid_events do
[] ->
:ok
[event | _] ->
{:error,
DslError.exception(
module: Transformer.get_persisted(dsl_state, :module),
path: [:dispatch],
message: """
Event #{inspect(event.name)} has no configuration.
Events must have either:
- Inline channel/content configuration, OR
- A callback module via the `module:` option
"""
)}
end
end
# Validate that events with action_label also have action_url
defp validate_action_label_requires_url(events, dsl_state) do
invalid =
Enum.find(events, fn event ->
has_in_app =
Enum.any?(event.channels, fn
ch when is_map(ch) -> ch[:transport] == :in_app
ch when is_list(ch) -> Keyword.get(ch, :transport) == :in_app
end)
has_label = (event.content || [])[:action_label] not in [nil, ""]
has_url = (event.content || [])[:action_url] not in [nil, ""]
has_in_app and has_label and not has_url
end)
case invalid do
nil ->
:ok
event ->
{:error,
DslError.exception(
module: Transformer.get_persisted(dsl_state, :module),
path: [:dispatch],
message: """
Event #{inspect(event.name)} has action_label but no action_url.
In-app notifications with action_label must also set action_url
so the frontend knows where to navigate when the button is clicked.
"""
)}
end
end
# Helper to get all action names from the resource
defp get_all_action_names(dsl_state) do
dsl_state
|> Transformer.get_entities([:actions])
|> Enum.map(& &1.name)
end
end