Refactor application and runtime boundaries

This commit is contained in:
Hermes Agent
2026-07-29 10:40:39 +00:00
parent 902037f947
commit 0f0a1a5409
10 changed files with 1676 additions and 1678 deletions

659
src/a2ui/validation.rs Normal file
View File

@@ -0,0 +1,659 @@
use super::*;
pub(super) fn validate_component(
component: &Map<String, Value>,
catalog_id: &str,
) -> Result<(), String> {
let id = required_string(component, "id")?;
let kind = required_string(component, "component")?;
let allowed = if catalog_id == CATALOG_ID {
ds4_catalog()
.pointer("/components")
.and_then(Value::as_object)
.is_some_and(|components| components.contains_key(kind))
} else {
BASIC_COMPONENTS.contains(&kind)
};
if !allowed {
return Err(format!(
"component `{id}` uses `{kind}`, which is not in catalog `{catalog_id}`"
));
}
let required: &[&str] = match kind {
"Text" => &["text"],
"Image" => &["url"],
"Icon" => &["name"],
"Video" | "AudioPlayer" => &["url"],
"Row" | "Column" | "List" => &["children"],
"Card" => &["child"],
"Modal" => &["trigger", "content"],
"Tabs" => &["tabs"],
"Button" => &["child", "action"],
"TextField" => &["label"],
"CheckBox" => &["label", "value"],
"Slider" => &["value", "max"],
"DateTimeInput" => &["value"],
"ChoicePicker" => &["options", "value"],
"Chart" => &["chartType", "series"],
"Table" => &["columns", "rows"],
"Metric" => &["label", "value"],
"Timeline" => &["events"],
"Map" => &["locations"],
"MindMap" => &["nodes"],
"Form" => &["children"],
_ => &[],
};
if let Some(field) = required
.iter()
.find(|field| !component.contains_key(**field))
{
return Err(format!("component `{id}` ({kind}) requires `{field}`"));
}
let common = ["id", "component", "accessibility", "weight", "checks"];
let specific: &[&str] = match kind {
"Text" => &["text", "variant"],
"Image" => &["url", "description", "fit", "variant"],
"Icon" => &["name"],
"Video" => &["url", "posterUrl"],
"AudioPlayer" => &["url", "description"],
"Divider" => &["axis"],
"Row" | "Column" => &["children", "justify", "align"],
"List" => &["children", "direction", "align"],
"Card" => &["child"],
"Modal" => &["trigger", "content"],
"Tabs" => &["tabs"],
"Button" => &["child", "variant", "action"],
"TextField" => &["label", "value", "placeholder", "variant"],
"CheckBox" => &["label", "value"],
"Slider" => &["label", "min", "max", "value", "steps"],
"DateTimeInput" => &["label", "value", "enableDate", "enableTime", "min", "max"],
"ChoicePicker" => &[
"label",
"variant",
"options",
"value",
"displayStyle",
"filterable",
],
"Chart" => &["title", "chartType", "series"],
"Table" => &["title", "columns", "rows"],
"Metric" => &["label", "value", "detail", "trend"],
"Timeline" => &["title", "events"],
"Map" => &["title", "locations"],
"MindMap" => &["title", "nodes"],
"Form" => &["title", "children", "submitLabel", "action"],
_ => &[],
};
if let Some(field) = component
.keys()
.find(|field| !common.contains(&field.as_str()) && !specific.contains(&field.as_str()))
{
return Err(format!(
"component `{id}` ({kind}) contains unknown property `{field}`"
));
}
if let Some(accessibility) = component.get("accessibility") {
let accessibility = object(Some(accessibility), "accessibility")?;
for field in ["label", "description"] {
validate_optional_dynamic(accessibility, field, Value::is_string, id)?;
}
}
if component
.get("weight")
.is_some_and(|value| !value.is_number())
{
return Err(format!("component `{id}` weight must be a number"));
}
if component
.get("title")
.is_some_and(|value| !value.is_string())
{
return Err(format!("component `{id}` title must be a string"));
}
if catalog_id != CATALOG_ID
&& component.contains_key("checks")
&& !matches!(
kind,
"Button" | "TextField" | "CheckBox" | "ChoicePicker" | "Slider" | "DateTimeInput"
)
{
return Err(format!("component `{id}` ({kind}) does not support checks"));
}
match kind {
"Row" | "Column" | "List" | "Form" => {
validate_children(component.get("children").unwrap(), id)?
}
"Card" | "Button" => expect_string(component, "child", id)?,
"Modal" => {
expect_string(component, "trigger", id)?;
expect_string(component, "content", id)?;
}
"Tabs" => validate_tabs(component.get("tabs").unwrap(), id)?,
"ChoicePicker" => validate_options(component.get("options").unwrap(), id)?,
"Slider" => {
expect_number(component, "max", id)?;
if let Some(min) = component.get("min")
&& !min.is_number()
{
return Err(format!("component `{id}` min must be a number"));
}
if let Some(steps) = component.get("steps")
&& !steps.as_u64().is_some_and(|steps| steps > 0)
{
return Err(format!("component `{id}` steps must be a positive integer"));
}
}
_ => {}
}
match kind {
"Text" => validate_dynamic_field(component, "text", Value::is_string, id)?,
"Image" | "Video" | "AudioPlayer" => {
validate_dynamic_field(component, "url", Value::is_string, id)?;
validate_optional_dynamic(component, "description", Value::is_string, id)?;
validate_optional_dynamic(component, "posterUrl", Value::is_string, id)?;
}
"Icon" => validate_dynamic_field(component, "name", Value::is_string, id)?,
"TextField" | "DateTimeInput" => {
validate_optional_dynamic(component, "label", Value::is_string, id)?;
validate_optional_dynamic(component, "value", Value::is_string, id)?;
}
"CheckBox" => {
validate_dynamic_field(component, "label", Value::is_string, id)?;
validate_dynamic_field(component, "value", Value::is_boolean, id)?;
}
"Slider" => validate_dynamic_field(component, "value", Value::is_number, id)?,
"ChoicePicker" => {
validate_optional_dynamic(component, "label", Value::is_string, id)?;
validate_dynamic_field(
component,
"value",
|value| {
value
.as_array()
.is_some_and(|values| values.iter().all(Value::is_string))
},
id,
)?;
}
"Chart" | "Table" | "Timeline" | "Map" | "MindMap" => {
for field in required {
if *field != "chartType" {
validate_dynamic_field(component, field, Value::is_array, id)?;
}
}
}
_ => {}
}
match kind {
"Text" => validate_enum(component, "variant", &["caption", "body"], id)?,
"Image" => {
validate_enum(
component,
"fit",
&["contain", "cover", "fill", "none", "scaleDown"],
id,
)?;
validate_enum(
component,
"variant",
&[
"icon",
"avatar",
"smallFeature",
"mediumFeature",
"largeFeature",
"header",
],
id,
)?;
}
"Icon" if component.get("name").is_some_and(Value::is_string) => validate_enum(
component,
"name",
&[
"accountCircle",
"add",
"arrowBack",
"arrowForward",
"attachFile",
"calendarToday",
"call",
"camera",
"check",
"close",
"delete",
"download",
"edit",
"event",
"error",
"fastForward",
"favorite",
"favoriteOff",
"folder",
"help",
"home",
"info",
"locationOn",
"lock",
"lockOpen",
"mail",
"menu",
"moreVert",
"moreHoriz",
"notificationsOff",
"notifications",
"pause",
"payment",
"person",
"phone",
"photo",
"play",
"print",
"refresh",
"rewind",
"search",
"send",
"settings",
"share",
"shoppingCart",
"skipNext",
"skipPrevious",
"star",
"starHalf",
"starOff",
"stop",
"upload",
"visibility",
"visibilityOff",
"volumeDown",
"volumeMute",
"volumeOff",
"volumeUp",
"warning",
],
id,
)?,
"Divider" => validate_enum(component, "axis", &["horizontal", "vertical"], id)?,
"Row" | "Column" => {
validate_enum(
component,
"justify",
&[
"start",
"center",
"end",
"spaceBetween",
"spaceAround",
"spaceEvenly",
"stretch",
],
id,
)?;
validate_enum(
component,
"align",
&["start", "center", "end", "stretch"],
id,
)?;
}
"List" => {
validate_enum(component, "direction", &["vertical", "horizontal"], id)?;
validate_enum(
component,
"align",
&["start", "center", "end", "stretch"],
id,
)?;
}
"Button" => validate_enum(
component,
"variant",
&["default", "primary", "borderless"],
id,
)?,
"TextField" => validate_enum(
component,
"variant",
&["longText", "number", "shortText", "obscured"],
id,
)?,
"ChoicePicker" => {
validate_enum(
component,
"variant",
&["multipleSelection", "mutuallyExclusive"],
id,
)?;
validate_enum(component, "displayStyle", &["checkbox", "chips"], id)?;
optional_bool(component, "filterable")?;
}
"DateTimeInput" => {
optional_bool(component, "enableDate")?;
optional_bool(component, "enableTime")?;
validate_optional_dynamic(component, "min", Value::is_string, id)?;
validate_optional_dynamic(component, "max", Value::is_string, id)?;
}
"Chart" => validate_enum(
component,
"chartType",
&[
"bar",
"line",
"area",
"stackedBar",
"pie",
"donut",
"heatmap",
],
id,
)?,
_ => {}
}
if let Some(action) = component.get("action") {
validate_action(action, id)?;
}
if let Some(checks) = component.get("checks") {
let checks = checks
.as_array()
.ok_or_else(|| format!("component `{id}` checks must be an array"))?;
for check in checks {
let check = object(Some(check), "check")?;
reject_unknown(check, &["condition", "message"], "check")?;
let condition = check
.get("condition")
.ok_or_else(|| format!("component `{id}` check requires condition"))?;
required_string(check, "message")?;
validate_function(condition)?;
}
}
validate_dynamic_values(component)?;
Ok(())
}
fn validate_dynamic_field(
object: &Map<String, Value>,
field: &str,
literal: impl Fn(&Value) -> bool,
id: &str,
) -> Result<(), String> {
let value = object
.get(field)
.ok_or_else(|| format!("component `{id}` requires `{field}`"))?;
if literal(value) {
return Ok(());
}
let dynamic = value
.as_object()
.ok_or_else(|| format!("component `{id}` {field} has the wrong literal or dynamic type"))?;
if let Some(path) = dynamic.get("path") {
if dynamic.len() == 1 && path.is_string() {
return Ok(());
}
return Err(format!(
"component `{id}` {field} has an invalid data binding"
));
}
validate_function(value).map_err(|error| format!("component `{id}` {field}: {error}"))
}
fn validate_optional_dynamic(
object: &Map<String, Value>,
field: &str,
literal: impl Fn(&Value) -> bool,
id: &str,
) -> Result<(), String> {
if object.contains_key(field) {
validate_dynamic_field(object, field, literal, id)
} else {
Ok(())
}
}
pub(super) fn ds4_catalog() -> &'static Value {
static CATALOG: OnceLock<Value> = OnceLock::new();
CATALOG.get_or_init(|| {
serde_json::from_str(CATALOG_JSON).expect("embedded DS4Server A2UI catalog must be valid")
})
}
fn expect_string(object: &Map<String, Value>, field: &str, id: &str) -> Result<(), String> {
if !object.get(field).is_some_and(Value::is_string) {
return Err(format!("component `{id}` {field} must be a string"));
}
Ok(())
}
fn expect_number(object: &Map<String, Value>, field: &str, id: &str) -> Result<(), String> {
if !object.get(field).is_some_and(Value::is_number) {
return Err(format!("component `{id}` {field} must be a number"));
}
Ok(())
}
fn validate_enum(
object: &Map<String, Value>,
field: &str,
allowed: &[&str],
id: &str,
) -> Result<(), String> {
if let Some(value) = object.get(field) {
let value = value
.as_str()
.ok_or_else(|| format!("component `{id}` {field} must be a string"))?;
if !allowed.contains(&value) {
return Err(format!(
"component `{id}` has invalid {field} `{value}`; expected one of: {}",
allowed.join(", ")
));
}
}
Ok(())
}
fn validate_children(value: &Value, id: &str) -> Result<(), String> {
if value
.as_array()
.is_some_and(|children| children.iter().all(Value::is_string))
{
return Ok(());
}
let template = object(Some(value), "children")?;
reject_unknown(template, &["componentId", "path"], "children template")?;
required_string(template, "componentId")?;
required_string(template, "path")?;
if !required_string(template, "path")?.starts_with('/') {
return Err(format!(
"component `{id}` child template path must be a JSON Pointer"
));
}
Ok(())
}
fn validate_tabs(value: &Value, id: &str) -> Result<(), String> {
let tabs = value
.as_array()
.filter(|tabs| !tabs.is_empty())
.ok_or_else(|| format!("component `{id}` tabs must be a non-empty array"))?;
for tab in tabs {
let tab = object(Some(tab), "tab")?;
reject_unknown(tab, &["title", "child"], "tab")?;
validate_dynamic_field(tab, "title", Value::is_string, id)?;
required_string(tab, "child")?;
}
Ok(())
}
fn validate_options(value: &Value, id: &str) -> Result<(), String> {
let options = value
.as_array()
.ok_or_else(|| format!("component `{id}` options must be an array"))?;
for option in options {
let option = object(Some(option), "choice option")?;
reject_unknown(option, &["label", "value"], "choice option")?;
validate_dynamic_field(option, "label", Value::is_string, id)?;
required_string(option, "value")?;
}
Ok(())
}
fn validate_action(value: &Value, id: &str) -> Result<(), String> {
let action = object(Some(value), "action")?;
if let Some(event) = action.get("event") {
reject_unknown(action, &["event"], "action")?;
let event = object(Some(event), "action.event")?;
reject_unknown(
event,
&["name", "context", "wantResponse", "responsePath"],
"action.event",
)?;
required_string(event, "name")?;
if let Some(context) = event.get("context")
&& !context.is_object()
{
return Err(format!("component `{id}` action context must be an object"));
}
optional_bool(event, "wantResponse")?;
if let Some(path) = event.get("responsePath")
&& !path.as_str().is_some_and(|path| path.starts_with('/'))
{
return Err(format!(
"component `{id}` responsePath must be a JSON Pointer"
));
}
return Ok(());
}
reject_unknown(action, &["functionCall"], "action")?;
validate_function(
action
.get("functionCall")
.ok_or_else(|| format!("component `{id}` action requires event or functionCall"))?,
)
}
fn validate_dynamic_values(value: &Map<String, Value>) -> Result<(), String> {
for value in value.values() {
if value.get("call").is_some() {
validate_function(value)?;
}
match value {
Value::Object(object) => validate_dynamic_values(object)?,
Value::Array(values) => {
for value in values {
if let Value::Object(object) = value {
validate_dynamic_values(object)?;
}
}
}
_ => {}
}
}
Ok(())
}
pub(super) fn validate_function(value: &Value) -> Result<(), String> {
let function = object(Some(value), "function call")?;
reject_unknown(function, &["call", "args"], "function call")?;
let call = value
.get("call")
.and_then(Value::as_str)
.ok_or_else(|| "function call requires `call`".to_owned())?;
if !FUNCTIONS.contains(&call) {
return Err(format!("function `{call}` is not declared by the catalog"));
}
if value.get("args").is_some_and(|args| !args.is_object()) {
return Err(format!("function `{call}` requires object `args`"));
}
let args = value
.get("args")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let allowed: &[&str] = match call {
"required" | "email" | "formatString" | "not" => &["value"],
"regex" => &["value", "pattern"],
"length" | "numeric" => &["value", "min", "max"],
"formatNumber" => &["value", "decimals", "grouping"],
"formatCurrency" => &["value", "currency", "decimals", "grouping"],
"formatDate" => &["value", "format"],
"pluralize" => &["value", "zero", "one", "two", "few", "many", "other"],
"openUrl" => &["url"],
"and" | "or" => &["values"],
"@index" => &["offset"],
_ => &[],
};
reject_unknown(&args, allowed, &format!("function `{call}` args"))?;
let required: &[&str] = match call {
"required" | "regex" | "length" | "numeric" | "email" | "formatString" | "formatNumber"
| "not" | "pluralize" => &["value"],
"formatCurrency" => &["value", "currency"],
"formatDate" => &["value", "format"],
"openUrl" => &["url"],
"and" | "or" => &["values"],
"@index" => &[],
_ => &[],
};
if let Some(field) = required.iter().find(|field| !args.contains_key(**field)) {
return Err(format!("function `{call}` requires argument `{field}`"));
}
if call == "regex" && !args.get("pattern").is_some_and(Value::is_string) {
return Err("function `regex` requires string argument `pattern`".into());
}
if matches!(call, "length" | "numeric")
&& !args.contains_key("min")
&& !args.contains_key("max")
{
return Err(format!("function `{call}` requires `min` or `max`"));
}
if call == "pluralize" && !args.contains_key("other") {
return Err("function `pluralize` requires argument `other`".into());
}
if matches!(call, "and" | "or")
&& !args
.get("values")
.and_then(Value::as_array)
.is_some_and(|values| values.len() >= 2)
{
return Err(format!("function `{call}` requires at least two values"));
}
match call {
"regex" | "length" | "email" | "formatString" => {
validate_dynamic_field(&args, "value", Value::is_string, call)?;
}
"numeric" | "formatNumber" | "formatCurrency" | "pluralize" => {
validate_dynamic_field(&args, "value", Value::is_number, call)?;
}
"not" => validate_dynamic_field(&args, "value", Value::is_boolean, call)?,
"formatDate" => validate_dynamic_field(&args, "format", Value::is_string, call)?,
"openUrl" => {
let url = required_string(&args, "url")?;
url::Url::parse(url).map_err(|error| format!("invalid openUrl URL: {error}"))?;
}
"and" | "or" => {
for item in args["values"].as_array().unwrap() {
if !item.is_boolean() {
let item = item.as_object().ok_or_else(|| {
format!("function `{call}` values must be dynamic booleans")
})?;
if !item.contains_key("path") && !item.contains_key("call") {
return Err(format!("function `{call}` values must be dynamic booleans"));
}
}
}
}
_ => {}
}
for field in ["min", "max", "decimals", "offset"] {
if args.contains_key(field) {
validate_dynamic_field(&args, field, Value::is_number, call)?;
}
}
if args.contains_key("grouping") {
validate_dynamic_field(&args, "grouping", Value::is_boolean, call)?;
}
for field in ["currency", "zero", "one", "two", "few", "many", "other"] {
if args.contains_key(field) {
validate_dynamic_field(&args, field, Value::is_string, call)?;
}
}
Ok(())
}