Packages
Event-driven notification system for Ash Framework with multiple transport types
Current section
Files
Jump to
Current section
Files
lib/event/interpolation.ex
defmodule AshDispatch.Event.Interpolation do
@moduledoc """
Handles variable interpolation in DSL content strings.
Supports {{variable}} syntax for interpolating values from:
1. Context data (ctx.data.variable)
2. Template assigns from prepare_template_assigns/2
## Examples
# In DSL:
content do
subject "Order #\{\{order_number\}\} created"
notification_message "Your order for \{\{total_items\}\} items is ready"
end
# In event module:
def prepare_template_assigns(context, channel) do
%{
order_number: format_order_id(context.data.order),
total_items: length(context.data.order.items)
}
end
## Interpolation Rules
1. Variables are looked up in template assigns first
2. Fallback to context.data with same key
3. Missing variables render as empty string (no errors)
4. Nested access is supported with dot notation: {{project.name}}
"""
alias AshDispatch.{Channel, Context}
@doc """
Interpolates {{variable}} placeholders in a string.
## Examples
iex> context = %Context{data: %{user: %{name: "John"}}}
iex> Interpolation.interpolate("Hello {{user_name}}", context, channel, event_module)
"Hello John"
iex> Interpolation.interpolate("No variables here", context, channel, event_module)
"No variables here"
"""
@spec interpolate(String.t() | nil, Context.t(), Channel.t(), module()) :: String.t()
def interpolate(nil, _context, _channel, _event_module), do: ""
def interpolate("", _context, _channel, _event_module), do: ""
def interpolate(string, context, channel, event_module) when is_binary(string) do
# Get template assigns from event module
assigns = event_module.prepare_template_assigns(context, channel)
# Find all {{variable}} or {{variable.path}} patterns and replace them
# Supports both simple {{name}} and nested {{user.name}} or {{project.name}}
Regex.replace(~r/\{\{([a-zA-Z_][a-zA-Z0-9_.]*)\}\}/, string, fn _, var_path ->
resolve_variable_path(var_path, assigns, context)
end)
end
# Private helpers
# Resolve a variable path like "project.name" or simple "name"
defp resolve_variable_path(var_path, assigns, context) do
path_parts = String.split(var_path, ".")
case path_parts do
[simple_key] ->
# Simple variable like {{name}}
var_atom = String.to_atom(simple_key)
case Map.get(assigns, var_atom) do
nil ->
# Try context.data directly
case Map.get(context.data, var_atom) do
nil ->
# Try via the main resource (context.resource_key)
# e.g., for {{name}} in project events, tries context.data.project.name
get_simple_from_resource(context, var_atom)
value ->
to_string_safe(value)
end
value ->
to_string_safe(value)
end
[first | rest] ->
# Nested path like {{project.name}} or {{user.email}}
first_atom = String.to_atom(first)
rest_atoms = Enum.map(rest, &String.to_atom/1)
# Try assigns first
case Map.get(assigns, first_atom) do
nil ->
# Try context.data directly
case Map.get(context.data, first_atom) do
nil ->
# Try via the main resource (context.resource_key)
# e.g., for phase events, look in context.data.phase.project
get_from_resource_path(context, first_atom, rest_atoms)
root_value ->
get_nested_value(root_value, rest_atoms)
end
root_value ->
get_nested_value(root_value, rest_atoms)
end
end
end
# Navigate nested struct/map path
defp get_nested_value(value, []), do: to_string_safe(value)
defp get_nested_value(value, [key | rest]) when is_map(value) do
case Map.get(value, key) do
nil -> ""
nested -> get_nested_value(nested, rest)
end
end
defp get_nested_value(_value, _path), do: ""
# Try to resolve a path via the main resource
# e.g., for {{project.name}} in phase events, tries context.data.phase.project.name
defp get_from_resource_path(
%Context{data: data, resource_key: resource_key},
first_key,
rest_keys
)
when not is_nil(resource_key) do
case Map.get(data, resource_key) do
nil ->
""
resource ->
# Try to get first_key from the resource, then continue with rest_keys
case Map.get(resource, first_key) do
nil -> ""
nested_value -> get_nested_value(nested_value, rest_keys)
end
end
end
defp get_from_resource_path(_context, _first_key, _rest_keys), do: ""
# Try to resolve a simple variable via the main resource
# e.g., for {{name}} in project events, tries context.data.project.name
defp get_simple_from_resource(%Context{data: data, resource_key: resource_key}, key)
when not is_nil(resource_key) do
case Map.get(data, resource_key) do
nil -> ""
resource -> to_string_safe(Map.get(resource, key))
end
end
defp get_simple_from_resource(_context, _key), do: ""
defp to_string_safe(value) when is_binary(value), do: value
defp to_string_safe(value) when is_integer(value), do: Integer.to_string(value)
defp to_string_safe(value) when is_float(value), do: Float.to_string(value)
defp to_string_safe(value) when is_atom(value), do: Atom.to_string(value)
defp to_string_safe(_value), do: ""
@doc """
Validates interpolation string for valid variable names.
Returns {:ok, variables} or {:error, reason}.
## Examples
iex> Interpolation.validate("Order #\{\{order_number\}\}")
{:ok, ["order_number"]}
iex> Interpolation.validate("Invalid \{\{123invalid\}\}")
{:error, "Invalid variable name: 123invalid"}
"""
@spec validate(String.t()) :: {:ok, [String.t()]} | {:error, String.t()}
def validate(string) when is_binary(string) do
# Extract all {{variable}} or {{variable.path}} patterns
variables =
Regex.scan(~r/\{\{([a-zA-Z_][a-zA-Z0-9_.]*)\}\}/, string)
|> Enum.map(fn [_, var] -> var end)
# Check for invalid patterns (must be valid identifier or dot-separated identifiers)
invalid =
Regex.scan(~r/\{\{([^}]*)\}\}/, string)
|> Enum.map(fn [_, var] -> var end)
|> Enum.reject(fn var -> var =~ ~r/^[a-zA-Z_][a-zA-Z0-9_.]*$/ end)
case invalid do
[] -> {:ok, variables}
[first | _] -> {:error, "Invalid variable name: #{first}"}
end
end
end