use super::*; pub(super) fn validate_component( component: &Map, 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, 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, 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 = 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, 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, 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, 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) -> 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(()) }