Refactor application and runtime boundaries
This commit is contained in:
451
src/a2ui/evaluation.rs
Normal file
451
src/a2ui/evaluation.rs
Normal file
@@ -0,0 +1,451 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn resolve(value: &Value, data: &Value) -> Result<Value, String> {
|
||||
resolve_at(value, data, data)
|
||||
}
|
||||
|
||||
pub(super) fn resolve_at(value: &Value, data: &Value, context: &Value) -> Result<Value, String> {
|
||||
if let Some(path) = value
|
||||
.as_object()
|
||||
.and_then(|value| value.get("path"))
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
let source = if path.starts_with('/') { data } else { context };
|
||||
let pointer = if path.starts_with('/') {
|
||||
normalize_pointer(path).to_owned()
|
||||
} else {
|
||||
format!("/{path}")
|
||||
};
|
||||
return Ok(source.pointer(&pointer).cloned().unwrap_or(Value::Null));
|
||||
}
|
||||
if let Some(call) = value
|
||||
.as_object()
|
||||
.and_then(|value| value.get("call"))
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
return evaluate(
|
||||
call,
|
||||
value.get("args").unwrap_or(&Value::Null),
|
||||
data,
|
||||
context,
|
||||
);
|
||||
}
|
||||
match value {
|
||||
Value::Array(values) => values
|
||||
.iter()
|
||||
.map(|value| resolve_at(value, data, context))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map(Value::Array),
|
||||
Value::Object(values) => values
|
||||
.iter()
|
||||
.map(|(key, value)| Ok((key.clone(), resolve_at(value, data, context)?)))
|
||||
.collect::<Result<Map<_, _>, String>>()
|
||||
.map(Value::Object),
|
||||
_ => Ok(value.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn evaluate(
|
||||
call: &str,
|
||||
args: &Value,
|
||||
data: &Value,
|
||||
context: &Value,
|
||||
) -> Result<Value, String> {
|
||||
if !FUNCTIONS.contains(&call) {
|
||||
return Err(format!("function `{call}` is not declared by the catalog"));
|
||||
}
|
||||
let args = args
|
||||
.as_object()
|
||||
.ok_or_else(|| format!("function `{call}` requires object args"))?;
|
||||
let value = || resolve_at(args.get("value").unwrap_or(&Value::Null), data, context);
|
||||
match call {
|
||||
"required" => Ok(Value::Bool(match value()? {
|
||||
Value::Null => false,
|
||||
Value::String(value) => !value.is_empty(),
|
||||
Value::Array(value) => !value.is_empty(),
|
||||
Value::Object(value) => !value.is_empty(),
|
||||
_ => true,
|
||||
})),
|
||||
"regex" => {
|
||||
let pattern = args
|
||||
.get("pattern")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| "regex requires a pattern".to_owned())?;
|
||||
let regex = Regex::new(pattern).map_err(|error| format!("invalid regex: {error}"))?;
|
||||
Ok(Value::Bool(regex.is_match(&display_value(&value()?))))
|
||||
}
|
||||
"length" => {
|
||||
let length = display_value(&value()?).chars().count() as u64;
|
||||
let min = args.get("min").and_then(Value::as_u64).unwrap_or(0);
|
||||
let max = args.get("max").and_then(Value::as_u64).unwrap_or(u64::MAX);
|
||||
Ok(Value::Bool((min..=max).contains(&length)))
|
||||
}
|
||||
"numeric" => {
|
||||
let resolved = value()?;
|
||||
let number = resolved
|
||||
.as_f64()
|
||||
.or_else(|| resolved.as_str().and_then(|value| value.parse().ok()));
|
||||
let min = args
|
||||
.get("min")
|
||||
.and_then(Value::as_f64)
|
||||
.unwrap_or(f64::NEG_INFINITY);
|
||||
let max = args
|
||||
.get("max")
|
||||
.and_then(Value::as_f64)
|
||||
.unwrap_or(f64::INFINITY);
|
||||
Ok(Value::Bool(
|
||||
number.is_some_and(|number| (min..=max).contains(&number)),
|
||||
))
|
||||
}
|
||||
"email" => {
|
||||
let email = display_value(&value()?);
|
||||
let valid = Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
|
||||
.unwrap()
|
||||
.is_match(&email);
|
||||
Ok(Value::Bool(valid))
|
||||
}
|
||||
"formatString" => Ok(Value::String(interpolate(
|
||||
&display_value(&value()?),
|
||||
data,
|
||||
context,
|
||||
)?)),
|
||||
"formatNumber" => {
|
||||
let number = value()?.as_f64().unwrap_or(0.0);
|
||||
let digits = resolve_at(args.get("decimals").unwrap_or(&json!(2)), data, context)?
|
||||
.as_f64()
|
||||
.map(|value| value.max(0.0) as u64)
|
||||
.unwrap_or(2)
|
||||
.min(12) as usize;
|
||||
let formatted = format!("{number:.digits$}");
|
||||
Ok(Value::String(
|
||||
if resolve_at(
|
||||
args.get("grouping").unwrap_or(&Value::Bool(true)),
|
||||
data,
|
||||
context,
|
||||
)?
|
||||
.as_bool()
|
||||
.unwrap_or(true)
|
||||
{
|
||||
group_number(&formatted)
|
||||
} else {
|
||||
formatted
|
||||
},
|
||||
))
|
||||
}
|
||||
"formatCurrency" => {
|
||||
let number = value()?.as_f64().unwrap_or(0.0);
|
||||
let currency = display_value(&resolve_at(
|
||||
args.get("currency").unwrap_or(&Value::Null),
|
||||
data,
|
||||
context,
|
||||
)?);
|
||||
let digits = resolve_at(args.get("decimals").unwrap_or(&json!(2)), data, context)?
|
||||
.as_f64()
|
||||
.map(|value| value.max(0.0) as u64)
|
||||
.unwrap_or(2)
|
||||
.min(12) as usize;
|
||||
let formatted = format!("{number:.digits$}");
|
||||
let number = if resolve_at(
|
||||
args.get("grouping").unwrap_or(&Value::Bool(true)),
|
||||
data,
|
||||
context,
|
||||
)?
|
||||
.as_bool()
|
||||
.unwrap_or(true)
|
||||
{
|
||||
group_number(&formatted)
|
||||
} else {
|
||||
formatted
|
||||
};
|
||||
Ok(Value::String(format!("{currency} {number}")))
|
||||
}
|
||||
"formatDate" => {
|
||||
let input = display_value(&value()?);
|
||||
let date =
|
||||
time::OffsetDateTime::parse(&input, &time::format_description::well_known::Rfc3339)
|
||||
.map_err(|error| format!("formatDate requires an RFC 3339 value: {error}"))?;
|
||||
let pattern = display_value(&resolve_at(
|
||||
args.get("format").unwrap_or(&Value::Null),
|
||||
data,
|
||||
context,
|
||||
)?);
|
||||
Ok(Value::String(format_date(date, &pattern)))
|
||||
}
|
||||
"pluralize" => {
|
||||
let number = value()?.as_f64().unwrap_or(0.0);
|
||||
let key = if number == 0.0 && args.contains_key("zero") {
|
||||
"zero"
|
||||
} else if number == 1.0 && args.contains_key("one") {
|
||||
"one"
|
||||
} else if number == 2.0 && args.contains_key("two") {
|
||||
"two"
|
||||
} else {
|
||||
"other"
|
||||
};
|
||||
resolve_at(args.get(key).unwrap_or(&Value::Null), data, context)
|
||||
}
|
||||
"and" | "or" => {
|
||||
let values = args
|
||||
.get("values")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| format!("{call} requires array `values`"))?;
|
||||
let values = values
|
||||
.iter()
|
||||
.map(|value| {
|
||||
resolve_at(value, data, context)?
|
||||
.as_bool()
|
||||
.ok_or_else(|| format!("{call} requires boolean values"))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(Value::Bool(if call == "and" {
|
||||
values.into_iter().all(|value| value)
|
||||
} else {
|
||||
values.into_iter().any(|value| value)
|
||||
}))
|
||||
}
|
||||
"not" => {
|
||||
Ok(Value::Bool(!value()?.as_bool().ok_or_else(|| {
|
||||
"not requires a boolean value".to_owned()
|
||||
})?))
|
||||
}
|
||||
"openUrl" => Ok(Value::Null),
|
||||
"@index" => {
|
||||
let index = TEMPLATE_INDEX
|
||||
.with(Cell::get)
|
||||
.ok_or_else(|| "@index is only available in a list template".to_owned())?;
|
||||
let offset = args
|
||||
.get("offset")
|
||||
.map(|value| resolve_at(value, data, context))
|
||||
.transpose()?
|
||||
.and_then(|value| value.as_i64())
|
||||
.unwrap_or(0);
|
||||
Ok(json!(index as i64 + offset))
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn interpolate(template: &str, data: &Value, context: &Value) -> Result<String, String> {
|
||||
let mut output = String::new();
|
||||
let mut offset = 0;
|
||||
while let Some(relative) = template[offset..].find("${") {
|
||||
let start = offset + relative;
|
||||
if start > offset && template.as_bytes()[start - 1] == b'\\' {
|
||||
output.push_str(&template[offset..start - 1]);
|
||||
output.push_str("${");
|
||||
offset = start + 2;
|
||||
continue;
|
||||
}
|
||||
output.push_str(&template[offset..start]);
|
||||
let end = expression_end(template, start + 2)
|
||||
.ok_or_else(|| "formatString contains an unclosed expression".to_owned())?;
|
||||
output.push_str(&display_value(&evaluate_expression(
|
||||
&template[start + 2..end],
|
||||
data,
|
||||
context,
|
||||
)?));
|
||||
offset = end + 1;
|
||||
}
|
||||
output.push_str(&template[offset..]);
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn expression_end(text: &str, mut offset: usize) -> Option<usize> {
|
||||
let mut depth = 1;
|
||||
let mut quote = None;
|
||||
while offset < text.len() {
|
||||
let character = text[offset..].chars().next()?;
|
||||
if let Some(current) = quote {
|
||||
if character == current && text.as_bytes().get(offset.wrapping_sub(1)) != Some(&b'\\') {
|
||||
quote = None;
|
||||
}
|
||||
} else if matches!(character, '\'' | '"') {
|
||||
quote = Some(character);
|
||||
} else if text[offset..].starts_with("${") {
|
||||
depth += 1;
|
||||
offset += 2;
|
||||
continue;
|
||||
} else if character == '}' {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return Some(offset);
|
||||
}
|
||||
}
|
||||
offset += character.len_utf8();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn evaluate_expression(expression: &str, data: &Value, context: &Value) -> Result<Value, String> {
|
||||
let expression = expression.trim();
|
||||
if let Some(open) = expression.find('(')
|
||||
&& expression.ends_with(')')
|
||||
{
|
||||
let call = expression[..open].trim();
|
||||
let mut args = Map::new();
|
||||
for argument in split_expression_args(&expression[open + 1..expression.len() - 1]) {
|
||||
let colon = top_level_separator(argument, ':')
|
||||
.ok_or_else(|| format!("formatString argument `{argument}` must be named"))?;
|
||||
let name = argument[..colon].trim();
|
||||
if name.is_empty() {
|
||||
return Err("formatString contains an empty argument name".into());
|
||||
}
|
||||
args.insert(
|
||||
name.to_owned(),
|
||||
expression_value(argument[colon + 1..].trim(), data, context)?,
|
||||
);
|
||||
}
|
||||
return evaluate(call, &Value::Object(args), data, context);
|
||||
}
|
||||
let (source, pointer) = if expression.starts_with('/') {
|
||||
(data, normalize_pointer(expression).to_owned())
|
||||
} else {
|
||||
(context, format!("/{expression}"))
|
||||
};
|
||||
Ok(source.pointer(&pointer).cloned().unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
fn split_expression_args(input: &str) -> Vec<&str> {
|
||||
let mut arguments = Vec::new();
|
||||
let mut start = 0;
|
||||
while let Some(relative) = top_level_separator(&input[start..], ',') {
|
||||
arguments.push(input[start..start + relative].trim());
|
||||
start += relative + 1;
|
||||
}
|
||||
if !input[start..].trim().is_empty() {
|
||||
arguments.push(input[start..].trim());
|
||||
}
|
||||
arguments
|
||||
}
|
||||
|
||||
fn top_level_separator(input: &str, separator: char) -> Option<usize> {
|
||||
let mut round = 0;
|
||||
let mut braces = 0;
|
||||
let mut quote = None;
|
||||
for (index, character) in input.char_indices() {
|
||||
if let Some(current) = quote {
|
||||
if character == current && input.as_bytes().get(index.wrapping_sub(1)) != Some(&b'\\') {
|
||||
quote = None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match character {
|
||||
'\'' | '"' => quote = Some(character),
|
||||
'(' => round += 1,
|
||||
')' => round -= 1,
|
||||
'{' => braces += 1,
|
||||
'}' => braces -= 1,
|
||||
_ if character == separator && round == 0 && braces == 0 => return Some(index),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn expression_value(expression: &str, data: &Value, context: &Value) -> Result<Value, String> {
|
||||
if expression.starts_with("${") && expression.ends_with('}') {
|
||||
return evaluate_expression(&expression[2..expression.len() - 1], data, context);
|
||||
}
|
||||
if expression.starts_with('\'') && expression.ends_with('\'') && expression.len() >= 2 {
|
||||
return Ok(Value::String(
|
||||
expression[1..expression.len() - 1].to_owned(),
|
||||
));
|
||||
}
|
||||
if let Ok(value) = serde_json::from_str(expression) {
|
||||
return Ok(value);
|
||||
}
|
||||
if expression.starts_with('/') {
|
||||
return Ok(data
|
||||
.pointer(normalize_pointer(expression))
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null));
|
||||
}
|
||||
Ok(Value::String(expression.to_owned()))
|
||||
}
|
||||
|
||||
fn group_number(number: &str) -> String {
|
||||
let (whole, fraction) = number.split_once('.').unwrap_or((number, ""));
|
||||
let (sign, digits) = whole
|
||||
.strip_prefix('-')
|
||||
.map_or(("", whole), |digits| ("-", digits));
|
||||
let mut grouped = String::with_capacity(number.len() + number.len() / 3);
|
||||
grouped.push_str(sign);
|
||||
for (index, digit) in digits.chars().enumerate() {
|
||||
if index > 0 && (digits.len() - index).is_multiple_of(3) {
|
||||
grouped.push(',');
|
||||
}
|
||||
grouped.push(digit);
|
||||
}
|
||||
if !fraction.is_empty() {
|
||||
grouped.push('.');
|
||||
grouped.push_str(fraction);
|
||||
}
|
||||
grouped
|
||||
}
|
||||
|
||||
pub(super) fn format_date(date: time::OffsetDateTime, pattern: &str) -> String {
|
||||
const MONTHS: [&str; 12] = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
const DAYS: [&str; 7] = [
|
||||
"Monday",
|
||||
"Tuesday",
|
||||
"Wednesday",
|
||||
"Thursday",
|
||||
"Friday",
|
||||
"Saturday",
|
||||
"Sunday",
|
||||
];
|
||||
let month = MONTHS[date.month() as usize - 1];
|
||||
let day = DAYS[date.weekday().number_days_from_monday() as usize];
|
||||
let hour_12 = match date.hour() % 12 {
|
||||
0 => 12,
|
||||
hour => hour,
|
||||
};
|
||||
let replacements = [
|
||||
("EEEE", day.to_owned()),
|
||||
("MMMM", month.to_owned()),
|
||||
("yyyy", format!("{:04}", date.year())),
|
||||
("MMM", month[..3].to_owned()),
|
||||
("yy", format!("{:02}", date.year().rem_euclid(100))),
|
||||
("MM", format!("{:02}", date.month() as u8)),
|
||||
("dd", format!("{:02}", date.day())),
|
||||
("HH", format!("{:02}", date.hour())),
|
||||
("hh", format!("{hour_12:02}")),
|
||||
("mm", format!("{:02}", date.minute())),
|
||||
("ss", format!("{:02}", date.second())),
|
||||
("E", day[..3].to_owned()),
|
||||
("M", (date.month() as u8).to_string()),
|
||||
("d", date.day().to_string()),
|
||||
("H", date.hour().to_string()),
|
||||
("h", hour_12.to_string()),
|
||||
("a", if date.hour() < 12 { "AM" } else { "PM" }.to_owned()),
|
||||
];
|
||||
let mut output = String::new();
|
||||
let mut remaining = pattern;
|
||||
while !remaining.is_empty() {
|
||||
if let Some((token, replacement)) = replacements
|
||||
.iter()
|
||||
.find(|(token, _)| remaining.starts_with(token))
|
||||
{
|
||||
output.push_str(replacement);
|
||||
remaining = &remaining[token.len()..];
|
||||
} else {
|
||||
let character = remaining.chars().next().unwrap();
|
||||
output.push(character);
|
||||
remaining = &remaining[character.len_utf8()..];
|
||||
}
|
||||
}
|
||||
output
|
||||
}
|
||||
659
src/a2ui/validation.rs
Normal file
659
src/a2ui/validation.rs
Normal 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user