Current section
Files
Jump to
Current section
Files
lib/a2ui/catalog.ex
defmodule A2UI.Catalog do
@moduledoc """
Registry for custom A2UI component types.
A catalog defines which custom component types are available, along with
their descriptions, properties, and required properties. This enables
server-side validation of custom components before they are sent to clients.
Standard component types (Text, Button, Card, etc.) are always valid and
do not need to be registered.
## Example
catalog = A2UI.Catalog.new("my-app-v1")
|> A2UI.Catalog.register("Graph", description: "Network graph", properties: [:nodes, :edges], required: [:nodes])
|> A2UI.Catalog.register("Sparkline", description: "Inline chart", properties: [:data, :color])
A2UI.Catalog.types(catalog)
# => ["Graph", "Sparkline"]
A2UI.Catalog.validate_component(catalog, component)
# => :ok | {:error, reason}
"""
@type type_spec :: %{
description: String.t() | nil,
properties: [atom()] | :any,
required: [atom()]
}
@type t :: %__MODULE__{
id: String.t(),
types: %{String.t() => type_spec()}
}
@enforce_keys [:id]
defstruct [:id, types: %{}]
@standard_types ~w(Text Button TextField CheckBox DateTimeInput Slider ChoicePicker
Image Icon Video AudioPlayer Divider Row Column List Card Tabs Modal)
@doc "Creates a new empty catalog with the given ID."
@spec new(String.t()) :: t()
def new(id) when is_binary(id), do: %__MODULE__{id: id}
@doc """
Registers a custom component type in the catalog.
## Options
- `:description` — human-readable description
- `:properties` — list of allowed property atoms, or `:any` (default: `:any`)
- `:required` — list of required property atoms (default: `[]`)
"""
@spec register(t(), String.t(), keyword()) :: t()
def register(%__MODULE__{} = catalog, type_name, opts \\ []) when is_binary(type_name) do
spec = %{
description: Keyword.get(opts, :description),
properties: Keyword.get(opts, :properties, :any),
required: Keyword.get(opts, :required, [])
}
%{catalog | types: Map.put(catalog.types, type_name, spec)}
end
@doc "Returns the list of registered custom type names."
@spec types(t()) :: [String.t()]
def types(%__MODULE__{types: types}), do: Map.keys(types)
@doc "Returns true if the type is registered in the catalog."
@spec has_type?(t(), String.t()) :: boolean()
def has_type?(%__MODULE__{types: types}, type_name), do: Map.has_key?(types, type_name)
@doc "Returns the spec for a registered type, or nil."
@spec get_spec(t(), String.t()) :: type_spec() | nil
def get_spec(%__MODULE__{types: types}, type_name), do: Map.get(types, type_name)
@doc """
Validates a component against the catalog.
Standard component types always pass. Custom components must be registered
in the catalog and satisfy property requirements.
Returns `:ok` or `{:error, reason}`.
"""
@spec validate_component(t(), A2UI.Component.t()) :: :ok | {:error, term()}
def validate_component(%__MODULE__{} = catalog, %A2UI.Component{} = component) do
type_name = resolve_type_name(component.type)
cond do
type_name in @standard_types ->
:ok
not has_type?(catalog, type_name) ->
{:error, {:unknown_type, type_name}}
true ->
validate_properties(catalog, type_name, component.properties)
end
end
defp resolve_type_name({:custom, name}), do: Atom.to_string(name)
defp resolve_type_name(atom) when is_atom(atom), do: encode_type_key(atom)
defp validate_properties(%__MODULE__{} = catalog, type_name, properties) do
spec = get_spec(catalog, type_name)
prop_keys = Map.keys(properties)
# Check required properties
missing = Enum.filter(spec.required, fn req -> req not in prop_keys end)
if missing != [] do
{:error, {:missing_required, missing}}
else
validate_allowed_properties(spec.properties, prop_keys)
end
end
defp validate_allowed_properties(:any, _prop_keys), do: :ok
defp validate_allowed_properties(allowed, prop_keys) when is_list(allowed) do
disallowed = Enum.filter(prop_keys, fn k -> k not in allowed end)
if disallowed != [] do
{:error, {:disallowed_properties, disallowed}}
else
:ok
end
end
# Mirror of Encoder type key mapping for standard type lookup
defp encode_type_key(:text), do: "Text"
defp encode_type_key(:button), do: "Button"
defp encode_type_key(:text_field), do: "TextField"
defp encode_type_key(:checkbox), do: "CheckBox"
defp encode_type_key(:date_time_input), do: "DateTimeInput"
defp encode_type_key(:slider), do: "Slider"
defp encode_type_key(:choice_picker), do: "ChoicePicker"
defp encode_type_key(:image), do: "Image"
defp encode_type_key(:icon), do: "Icon"
defp encode_type_key(:video), do: "Video"
defp encode_type_key(:audio_player), do: "AudioPlayer"
defp encode_type_key(:divider), do: "Divider"
defp encode_type_key(:row), do: "Row"
defp encode_type_key(:column), do: "Column"
defp encode_type_key(:list), do: "List"
defp encode_type_key(:card), do: "Card"
defp encode_type_key(:tabs), do: "Tabs"
defp encode_type_key(:modal), do: "Modal"
defp encode_type_key(other), do: Atom.to_string(other)
end