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.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