Current section

Files

Jump to
musubi lib musubi hooks validate_render.ex
Raw

lib/musubi/hooks/validate_render.ex

defmodule Musubi.Hooks.ValidateRender do
@moduledoc """
Validates a store's wire-form render output against the compile-time
`__musubi_validate_state__/1` predicate generated by `Musubi.Plugin.StateField`.
Runtime-injected reserved keys like `__musubi_store_id__` are ignored.
Attached to the `:after_serialize` lifecycle stage. Two modes:
* `:raise` — raise `ArgumentError` on validation failure (dev/test default).
* `:telemetry` — emit `[:musubi, :validate, :exception]` and continue
(production default).
Successful validation emits `[:musubi, :validate, :stop]`.
"""
alias Musubi.Resolver
alias Musubi.Socket
@type validation_mode() :: :raise | :telemetry
@type validation_error() :: {String.t(), String.t()}
@doc """
Runs the per-store validator on the wire-form `render/1` output.
## Examples
socket = %Musubi.Socket{module: MyApp.RootStore}
Musubi.Hooks.ValidateRender.after_serialize(:raise, %{"title" => "Inbox"}, socket)
#=> {:cont, socket}
"""
@spec after_serialize(validation_mode(), term(), Socket.t()) :: Musubi.Lifecycle.hook_result()
def after_serialize(mode, wire_term, %Socket{module: store_module} = socket)
when mode in [:raise, :telemetry] and is_atom(store_module) do
case validate(wire_term, store_module) do
:ok ->
emit_stop(store_module)
{:cont, socket}
{:error, errors} ->
emit_exception(store_module, errors)
case mode do
:raise -> raise ArgumentError, format_errors(store_module, errors)
:telemetry -> {:cont, socket}
end
end
end
@doc """
Validates a wire-form term against `store_module.__musubi_validate_state__/1`.
## Examples
Musubi.Hooks.ValidateRender.validate(%{"title" => "Inbox"}, MyApp.RootStore)
#=> :ok
"""
@spec validate(term(), module()) :: :ok | {:error, [validation_error()]}
def validate(wire_term, store_module) when is_atom(store_module) do
if function_exported?(store_module, :__musubi_validate_state__, 1) do
wire_term
|> drop_runtime_keys()
|> store_module.__musubi_validate_state__()
else
:ok
end
end
defp drop_runtime_keys(%{} = map) do
map
|> Map.delete(Atom.to_string(Resolver.store_id_key()))
|> Enum.reject(fn {_key, value} -> Musubi.Upload.Marker.marker?(value) end)
|> Enum.into(%{}, fn {key, value} -> {key, drop_runtime_keys(value)} end)
end
defp drop_runtime_keys(list) when is_list(list), do: Enum.map(list, &drop_runtime_keys/1)
defp drop_runtime_keys(other), do: other
defp emit_stop(store_module) do
:telemetry.execute(
[:musubi, :validate, :stop],
%{count: 1},
%{store_module: store_module, errors: []}
)
end
defp emit_exception(store_module, errors) do
:telemetry.execute(
[:musubi, :validate, :exception],
%{count: 1},
%{store_module: store_module, errors: errors}
)
end
defp format_errors(store_module, errors) do
details =
Enum.map_join(errors, "; ", fn {path, message} -> "#{path}: #{message}" end)
"render output validation failed for #{inspect(store_module)}: #{details}" <>
mount_hint(errors)
end
# Adds an actionable hint when at least one error reports a `nil` value
# for a non-nullable field. The most common cause is forgetting to
# assign the field in `mount/init` or a `handle_*` callback that
# produces it — the spec allows hard-coding in `render/1`, so this is
# only a hint, not a directive.
@spec mount_hint([validation_error()]) :: String.t()
defp mount_hint(errors) do
if Enum.any?(errors, fn {_path, message} ->
is_binary(message) and String.contains?(message, "got: nil")
end) do
". Hint: a non-nullable field rendered as nil — if your `render/1` reads " <>
"from `socket.assigns`, ensure the field is assigned in `mount/init` or a " <>
"`handle_*` callback. To allow nil legitimately, declare the field nullable " <>
"(`field :name, T | nil`)."
else
""
end
end
end