use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use rhai::{Array, Dynamic, FLOAT, FnPtr, INT, ImmutableString, Map};
use serde::{Deserialize, Serialize};
use crate::{AssetId, ElementRef, Length, NativeSignal, OpaqueHandle, Style, UiNode, UiValue};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ValueSchema {
Null,
Bool,
Integer {
#[serde(default, skip_serializing_if = "Option::is_none")]
min: Option<INT>,
#[serde(default, skip_serializing_if = "Option::is_none")]
max: Option<INT>,
},
Float {
#[serde(default, skip_serializing_if = "Option::is_none")]
min: Option<FLOAT>,
#[serde(default, skip_serializing_if = "Option::is_none")]
max: Option<FLOAT>,
#[serde(default, skip_serializing_if = "Option::is_none")]
exclusive_min: Option<FLOAT>,
#[serde(default, skip_serializing_if = "Option::is_none")]
exclusive_max: Option<FLOAT>,
},
Number {
#[serde(default, skip_serializing_if = "Option::is_none")]
min: Option<FLOAT>,
#[serde(default, skip_serializing_if = "Option::is_none")]
max: Option<FLOAT>,
#[serde(default, skip_serializing_if = "Option::is_none")]
exclusive_min: Option<FLOAT>,
#[serde(default, skip_serializing_if = "Option::is_none")]
exclusive_max: Option<FLOAT>,
},
String {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
allowed: Vec<String>,
},
Array {
items: Box<Self>,
#[serde(default, skip_serializing_if = "Option::is_none")]
max_items: Option<usize>,
},
Map {
values: Box<Self>,
},
Object {
fields: BTreeMap<String, ObjectField>,
#[serde(default)]
allow_unknown: bool,
},
Optional {
value: Box<Self>,
},
OneOf {
variants: Vec<Self>,
},
Node,
Callback,
Style,
Length,
UiValue,
Asset,
Signal,
Collection,
Document,
#[cfg(feature = "charts")]
ChartData,
Ref,
Handle {
kind: String,
},
}
impl ValueSchema {
#[must_use]
pub fn string() -> Self {
Self::String {
allowed: Vec::new(),
}
}
#[must_use]
pub const fn integer() -> Self {
Self::Integer {
min: None,
max: None,
}
}
#[must_use]
pub const fn bounded_integer(min: Option<INT>, max: Option<INT>) -> Self {
Self::Integer { min, max }
}
#[must_use]
pub const fn float() -> Self {
Self::Float {
min: None,
max: None,
exclusive_min: None,
exclusive_max: None,
}
}
#[must_use]
pub const fn bounded_float(min: Option<FLOAT>, max: Option<FLOAT>) -> Self {
Self::Float {
min,
max,
exclusive_min: None,
exclusive_max: None,
}
}
#[must_use]
pub const fn number() -> Self {
Self::Number {
min: None,
max: None,
exclusive_min: None,
exclusive_max: None,
}
}
#[must_use]
pub const fn bounded_number(min: Option<FLOAT>, max: Option<FLOAT>) -> Self {
Self::Number {
min,
max,
exclusive_min: None,
exclusive_max: None,
}
}
#[must_use]
pub const fn positive_number() -> Self {
Self::Number {
min: None,
max: None,
exclusive_min: Some(0.0),
exclusive_max: None,
}
}
#[must_use]
pub fn enumeration(values: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self::String {
allowed: values.into_iter().map(Into::into).collect(),
}
}
#[must_use]
pub fn optional(value: Self) -> Self {
Self::Optional {
value: Box::new(value),
}
}
#[must_use]
pub fn one_of(variants: impl IntoIterator<Item = Self>) -> Self {
Self::OneOf {
variants: variants.into_iter().collect(),
}
}
#[must_use]
pub fn object(fields: BTreeMap<String, ObjectField>) -> Self {
Self::Object {
fields,
allow_unknown: false,
}
}
pub fn validate_definition(&self) -> Result<(), SchemaDefinitionError> {
self.validate_definition_at("$")
}
fn validate_definition_at(&self, path: &str) -> Result<(), SchemaDefinitionError> {
match self {
Self::Integer { min, max } => validate_integer_bounds(*min, *max, path),
Self::Float {
min,
max,
exclusive_min,
exclusive_max,
}
| Self::Number {
min,
max,
exclusive_min,
exclusive_max,
} => validate_float_bounds(*min, *max, *exclusive_min, *exclusive_max, path),
Self::String { allowed } => {
let mut unique = BTreeSet::new();
if let Some(duplicate) = allowed.iter().find(|value| !unique.insert(*value)) {
return Err(SchemaDefinitionError::new(
path,
format!("allowed string value `{duplicate}` is duplicated"),
));
}
Ok(())
}
Self::Array { items, .. } => items.validate_definition_at(&format!("{path}.items")),
Self::Map { values } => values.validate_definition_at(&format!("{path}.values")),
Self::Object { fields, .. } => {
for (name, field) in fields {
field
.schema
.validate_definition_at(&format!("{path}.fields.{name}"))?;
}
Ok(())
}
Self::Optional { value } => value.validate_definition_at(&format!("{path}.value")),
Self::OneOf { variants } => {
if variants.is_empty() {
return Err(SchemaDefinitionError::new(
path,
"one_of must contain at least one variant",
));
}
for (index, variant) in variants.iter().enumerate() {
variant.validate_definition_at(&format!("{path}.variants[{index}]"))?;
}
Ok(())
}
Self::Handle { kind } if kind.trim().is_empty() => Err(SchemaDefinitionError::new(
path,
"handle kind cannot be empty",
)),
Self::Null
| Self::Bool
| Self::Node
| Self::Callback
| Self::Style
| Self::Length
| Self::UiValue
| Self::Asset
| Self::Signal
| Self::Collection
| Self::Document
| Self::Ref
| Self::Handle { .. } => Ok(()),
#[cfg(feature = "charts")]
Self::ChartData => Ok(()),
}
}
pub fn validate(&self, value: &Dynamic) -> Result<(), SchemaValidationError> {
let mut issues = Vec::new();
self.validate_at(value, "$", &mut issues);
if issues.is_empty() {
Ok(())
} else {
Err(SchemaValidationError { issues })
}
}
pub fn validate_ui_value(&self, value: &UiValue) -> Result<(), SchemaValidationError> {
value.validate().map_err(|error| SchemaValidationError {
issues: vec![SchemaIssue::new("$", error.to_string())],
})?;
self.validate(&value.clone().into_dynamic())
}
pub(crate) fn validate_ui_value_first(
&self,
value: &UiValue,
) -> Result<(), SchemaValidationError> {
value.validate().map_err(|error| SchemaValidationError {
issues: vec![SchemaIssue::new("$", error.to_string())],
})?;
self.validate_ui_value_at_first(value, "$")
.map_or(Ok(()), |issue| {
Err(SchemaValidationError {
issues: vec![issue],
})
})
}
#[allow(clippy::too_many_lines)]
fn validate_ui_value_at_first(&self, value: &UiValue, path: &str) -> Option<SchemaIssue> {
let expected = |name: &str| {
Some(SchemaIssue::new(
path,
format!("expected {name}, got {}", ui_value_type_name(value)),
))
};
match self {
Self::Null => (!matches!(value, UiValue::Null))
.then(|| expected("null"))
.flatten(),
Self::Bool => (!matches!(value, UiValue::Bool(_)))
.then(|| expected("bool"))
.flatten(),
Self::Integer { min, max } => match value {
UiValue::Integer(value) => {
if min.is_some_and(|min| *value < min) {
Some(SchemaIssue::new(
path,
format!("expected integer >= {}, got {value}", min.unwrap()),
))
} else if max.is_some_and(|max| *value > max) {
Some(SchemaIssue::new(
path,
format!("expected integer <= {}, got {value}", max.unwrap()),
))
} else {
None
}
}
_ => expected("integer"),
},
Self::Float {
min,
max,
exclusive_min,
exclusive_max,
} => match value {
UiValue::Float(value) => {
first_float_issue(*value, *min, *max, *exclusive_min, *exclusive_max, path)
}
_ => expected("float"),
},
Self::Number {
min,
max,
exclusive_min,
exclusive_max,
} => match value {
UiValue::Float(value) => {
first_float_issue(*value, *min, *max, *exclusive_min, *exclusive_max, path)
}
UiValue::Integer(value) => first_float_issue(
integer_as_float(*value),
*min,
*max,
*exclusive_min,
*exclusive_max,
path,
),
_ => expected("number"),
},
Self::String { allowed } => match value {
UiValue::String(value)
if !allowed.is_empty() && !allowed.iter().any(|item| item == value) =>
{
Some(SchemaIssue::new(
path,
format!("expected one of [{}], got `{value}`", allowed.join(", ")),
))
}
UiValue::String(_) => None,
_ => expected("string"),
},
Self::Array { items, max_items } => match value {
UiValue::Array(values) => {
if max_items.is_some_and(|max| values.len() > max) {
Some(SchemaIssue::new(
path,
format!(
"expected at most {} items, got {}",
max_items.unwrap(),
values.len()
),
))
} else {
values.iter().enumerate().find_map(|(index, value)| {
items.validate_ui_value_at_first(value, &format!("{path}[{index}]"))
})
}
}
_ => expected("array"),
},
Self::Map { values } => match value {
UiValue::Map(items) => items.iter().find_map(|(key, value)| {
values.validate_ui_value_at_first(value, &format!("{path}.{key}"))
}),
_ => expected("map"),
},
Self::Object {
fields,
allow_unknown,
} => match value {
UiValue::Map(values) => {
let field_issue = fields.iter().find_map(|(name, field)| {
values.get(name).map_or_else(
|| {
field.required.then(|| {
SchemaIssue::new(
format!("{path}.{name}"),
"required field is missing",
)
})
},
|value| {
field
.schema
.validate_ui_value_at_first(value, &format!("{path}.{name}"))
},
)
});
field_issue.or_else(|| {
(!*allow_unknown).then(|| {
values
.keys()
.find(|key| !fields.contains_key(*key))
.map(|key| {
SchemaIssue::new(format!("{path}.{key}"), "unknown field")
})
})?
})
}
_ => expected("object"),
},
Self::Optional { value: schema } => {
if matches!(value, UiValue::Null) {
None
} else {
schema.validate_ui_value_at_first(value, path)
}
}
Self::OneOf { variants } => {
let failures = variants
.iter()
.map(|variant| variant.validate_ui_value_at_first(value, path))
.collect::<Vec<_>>();
if failures.iter().any(Option::is_none) {
None
} else {
Some(SchemaIssue::new(
path,
format!(
"value did not match any one_of variant ({})",
failures
.into_iter()
.enumerate()
.filter_map(|(index, issue)| issue
.map(|issue| format!("variant {index}: {}", issue.message)))
.collect::<Vec<_>>()
.join("; ")
),
))
}
}
Self::UiValue => None,
Self::Handle { kind } => match value {
UiValue::Handle(handle) if handle.kind() == kind => None,
UiValue::Handle(handle) => Some(SchemaIssue::new(
path,
format!("expected `{kind}` handle, got `{}` handle", handle.kind()),
)),
_ => expected(&format!("{kind} handle")),
},
Self::Node => expected("UiNode"),
Self::Callback => expected("callback or NativeHandlerRef"),
Self::Style => expected("Style"),
Self::Length => expected("Length"),
Self::Asset => expected("AssetId"),
Self::Signal => expected("NativeSignal"),
Self::Collection => expected("NativeCollection"),
Self::Document => expected("NativeTextDocument"),
#[cfg(feature = "charts")]
Self::ChartData => expected("NativeChartData"),
Self::Ref => expected("ElementRef"),
}
}
fn validate_at(&self, value: &Dynamic, path: &str, issues: &mut Vec<SchemaIssue>) {
match self {
Self::Null => expect_type(value.is_unit(), value, path, "null", issues),
Self::Bool => expect_type(value.is::<bool>(), value, path, "bool", issues),
Self::Integer { min, max } => validate_integer(value, *min, *max, path, issues),
Self::Float {
min,
max,
exclusive_min,
exclusive_max,
} => validate_float(
value,
*min,
*max,
*exclusive_min,
*exclusive_max,
path,
issues,
),
Self::Number {
min,
max,
exclusive_min,
exclusive_max,
} => validate_number(
value,
*min,
*max,
*exclusive_min,
*exclusive_max,
path,
issues,
),
Self::String { allowed } => validate_string(value, allowed, path, issues),
Self::Array { items, max_items } => {
validate_array(value, items, *max_items, path, issues);
}
Self::Map { values } => validate_map(value, values, path, issues),
Self::Object {
fields,
allow_unknown,
} => validate_object_value(value, fields, *allow_unknown, path, issues),
Self::Optional { value: schema } => {
if !value.is_unit() {
schema.validate_at(value, path, issues);
}
}
Self::OneOf { variants } => validate_one_of(value, variants, path, issues),
Self::Node => expect_type(value.is::<UiNode>(), value, path, "UiNode", issues),
Self::Callback => expect_type(
value.is::<FnPtr>() || value.is::<crate::NativeHandlerRef>(),
value,
path,
"callback or NativeHandlerRef",
issues,
),
Self::Style => expect_type(value.is::<Style>(), value, path, "Style", issues),
Self::Length => validate_length(value, path, issues),
Self::UiValue => {
if let Err(error) = UiValue::from_dynamic(value.clone()) {
issues.push(SchemaIssue::new(path, error.to_string()));
}
}
Self::Asset => expect_type(value.is::<AssetId>(), value, path, "AssetId", issues),
Self::Signal => expect_type(
value.is::<NativeSignal>(),
value,
path,
"NativeSignal",
issues,
),
Self::Collection => expect_type(
value.is::<crate::NativeCollection>(),
value,
path,
"NativeCollection",
issues,
),
Self::Document => expect_type(
value.is::<crate::NativeTextDocument>(),
value,
path,
"NativeTextDocument",
issues,
),
#[cfg(feature = "charts")]
Self::ChartData => expect_type(
value.is::<crate::NativeChartData>(),
value,
path,
"NativeChartData",
issues,
),
Self::Ref => expect_type(value.is::<ElementRef>(), value, path, "ElementRef", issues),
Self::Handle { kind } => validate_handle(value, kind, path, issues),
}
}
}
fn ui_value_type_name(value: &UiValue) -> &'static str {
match value {
UiValue::Null => "null",
UiValue::Bool(_) => "bool",
UiValue::Integer(_) => "integer",
UiValue::Float(_) => "float",
UiValue::String(_) => "string",
UiValue::Array(_) => "array",
UiValue::Map(_) => "map",
UiValue::Handle(_) => "handle",
}
}
fn first_float_issue(
value: FLOAT,
min: Option<FLOAT>,
max: Option<FLOAT>,
exclusive_min: Option<FLOAT>,
exclusive_max: Option<FLOAT>,
path: &str,
) -> Option<SchemaIssue> {
if !value.is_finite() {
Some(SchemaIssue::new(path, "number must be finite"))
} else if min.is_some_and(|min| value < min) {
Some(SchemaIssue::new(
path,
format!("expected number >= {}, got {value}", min.unwrap()),
))
} else if max.is_some_and(|max| value > max) {
Some(SchemaIssue::new(
path,
format!("expected number <= {}, got {value}", max.unwrap()),
))
} else if exclusive_min.is_some_and(|min| value <= min) {
Some(SchemaIssue::new(
path,
format!("expected number > {}, got {value}", exclusive_min.unwrap()),
))
} else if exclusive_max.is_some_and(|max| value >= max) {
Some(SchemaIssue::new(
path,
format!("expected number < {}, got {value}", exclusive_max.unwrap()),
))
} else {
None
}
}
fn validate_integer(
value: &Dynamic,
min: Option<INT>,
max: Option<INT>,
path: &str,
issues: &mut Vec<SchemaIssue>,
) {
if value.is::<INT>() {
validate_integer_value(value.clone_cast::<INT>(), min, max, path, issues);
} else {
expect_type(false, value, path, "integer", issues);
}
}
fn validate_float(
value: &Dynamic,
min: Option<FLOAT>,
max: Option<FLOAT>,
exclusive_min: Option<FLOAT>,
exclusive_max: Option<FLOAT>,
path: &str,
issues: &mut Vec<SchemaIssue>,
) {
if value.is::<FLOAT>() {
validate_float_value(
value.clone_cast::<FLOAT>(),
min,
max,
exclusive_min,
exclusive_max,
path,
issues,
);
} else {
expect_type(false, value, path, "float", issues);
}
}
fn validate_number(
value: &Dynamic,
min: Option<FLOAT>,
max: Option<FLOAT>,
exclusive_min: Option<FLOAT>,
exclusive_max: Option<FLOAT>,
path: &str,
issues: &mut Vec<SchemaIssue>,
) {
let number = if value.is::<INT>() {
Some(integer_as_float(value.clone_cast::<INT>()))
} else if value.is::<FLOAT>() {
Some(value.clone_cast::<FLOAT>())
} else {
None
};
if let Some(number) = number {
validate_float_value(number, min, max, exclusive_min, exclusive_max, path, issues);
} else {
expect_type(false, value, path, "number", issues);
}
}
fn validate_string(value: &Dynamic, allowed: &[String], path: &str, issues: &mut Vec<SchemaIssue>) {
if value.is::<ImmutableString>() {
let actual = value.clone_cast::<ImmutableString>();
if !allowed.is_empty() && !allowed.iter().any(|allowed| allowed == actual.as_str()) {
issues.push(SchemaIssue::new(
path,
format!("expected one of [{}], got `{actual}`", allowed.join(", ")),
));
}
} else {
expect_type(false, value, path, "string", issues);
}
}
fn validate_array(
value: &Dynamic,
items: &ValueSchema,
max_items: Option<usize>,
path: &str,
issues: &mut Vec<SchemaIssue>,
) {
if !value.is::<Array>() {
expect_type(false, value, path, "array", issues);
return;
}
let values = value.clone_cast::<Array>();
if let Some(max_items) = max_items
&& values.len() > max_items
{
issues.push(SchemaIssue::new(
path,
format!("expected at most {max_items} items, got {}", values.len()),
));
}
for (index, item) in values.iter().enumerate() {
items.validate_at(item, &format!("{path}[{index}]"), issues);
}
}
fn validate_map(value: &Dynamic, values: &ValueSchema, path: &str, issues: &mut Vec<SchemaIssue>) {
if !value.is::<Map>() {
expect_type(false, value, path, "map", issues);
return;
}
for (key, item) in value.clone_cast::<Map>() {
values.validate_at(&item, &format!("{path}.{key}"), issues);
}
}
fn validate_object_value(
value: &Dynamic,
fields: &BTreeMap<String, ObjectField>,
allow_unknown: bool,
path: &str,
issues: &mut Vec<SchemaIssue>,
) {
if value.is::<Map>() {
validate_object(
&value.clone_cast::<Map>(),
fields,
allow_unknown,
path,
issues,
);
} else {
expect_type(false, value, path, "object", issues);
}
}
fn validate_one_of(
value: &Dynamic,
variants: &[ValueSchema],
path: &str,
issues: &mut Vec<SchemaIssue>,
) {
let mut branch_issues = Vec::with_capacity(variants.len());
for variant in variants {
let mut candidate = Vec::new();
variant.validate_at(value, path, &mut candidate);
if candidate.is_empty() {
return;
}
branch_issues.push(candidate);
}
let summary = branch_issues
.iter()
.enumerate()
.map(|(index, candidate)| {
let messages = candidate
.iter()
.map(|issue| issue.message.as_str())
.collect::<Vec<_>>()
.join(", ");
format!("variant {index}: {messages}")
})
.collect::<Vec<_>>()
.join("; ");
issues.push(SchemaIssue::new(
path,
format!("value did not match any one_of variant ({summary})"),
));
}
fn validate_length(value: &Dynamic, path: &str, issues: &mut Vec<SchemaIssue>) {
if value.is::<Length>() {
let length = value.clone_cast::<Length>();
if let Err(error) = length.validate() {
issues.push(SchemaIssue::new(path, error.to_string()));
}
} else {
expect_type(false, value, path, "Length", issues);
}
}
fn validate_handle(value: &Dynamic, kind: &str, path: &str, issues: &mut Vec<SchemaIssue>) {
if value.is::<OpaqueHandle>() {
let handle = value.clone_cast::<OpaqueHandle>();
if handle.kind() != kind {
issues.push(SchemaIssue::new(
path,
format!("expected `{kind}` handle, got `{}` handle", handle.kind()),
));
}
} else {
expect_type(false, value, path, &format!("{kind} handle"), issues);
}
}
fn validate_integer_bounds(
min: Option<INT>,
max: Option<INT>,
path: &str,
) -> Result<(), SchemaDefinitionError> {
if min.zip(max).is_some_and(|(min, max)| min > max) {
Err(SchemaDefinitionError::new(
path,
format!("integer minimum {min:?} exceeds maximum {max:?}"),
))
} else {
Ok(())
}
}
fn validate_float_bounds(
min: Option<FLOAT>,
max: Option<FLOAT>,
exclusive_min: Option<FLOAT>,
exclusive_max: Option<FLOAT>,
path: &str,
) -> Result<(), SchemaDefinitionError> {
if min.is_some_and(|value| !value.is_finite())
|| max.is_some_and(|value| !value.is_finite())
|| exclusive_min.is_some_and(|value| !value.is_finite())
|| exclusive_max.is_some_and(|value| !value.is_finite())
{
return Err(SchemaDefinitionError::new(
path,
"numeric bounds must be finite",
));
}
if min.is_some() && exclusive_min.is_some() {
return Err(SchemaDefinitionError::new(
path,
"numeric schema cannot define both min and exclusive_min",
));
}
if max.is_some() && exclusive_max.is_some() {
return Err(SchemaDefinitionError::new(
path,
"numeric schema cannot define both max and exclusive_max",
));
}
let lower = min.or(exclusive_min);
let upper = max.or(exclusive_max);
let empty = lower.zip(upper).is_some_and(|(lower, upper)| {
matches!(lower.total_cmp(&upper), Ordering::Greater)
|| (matches!(lower.total_cmp(&upper), Ordering::Equal)
&& (exclusive_min.is_some() || exclusive_max.is_some()))
});
if empty {
Err(SchemaDefinitionError::new(
path,
format!("numeric lower bound {lower:?} does not precede upper bound {upper:?}"),
))
} else {
Ok(())
}
}
fn validate_integer_value(
value: INT,
min: Option<INT>,
max: Option<INT>,
path: &str,
issues: &mut Vec<SchemaIssue>,
) {
if let Some(min) = min
&& value < min
{
issues.push(SchemaIssue::new(
path,
format!("expected integer >= {min}, got {value}"),
));
}
if let Some(max) = max
&& value > max
{
issues.push(SchemaIssue::new(
path,
format!("expected integer <= {max}, got {value}"),
));
}
}
fn validate_float_value(
value: FLOAT,
min: Option<FLOAT>,
max: Option<FLOAT>,
exclusive_min: Option<FLOAT>,
exclusive_max: Option<FLOAT>,
path: &str,
issues: &mut Vec<SchemaIssue>,
) {
if !value.is_finite() {
issues.push(SchemaIssue::new(path, "number must be finite"));
return;
}
if let Some(min) = min
&& value < min
{
issues.push(SchemaIssue::new(
path,
format!("expected number >= {min}, got {value}"),
));
}
if let Some(max) = max
&& value > max
{
issues.push(SchemaIssue::new(
path,
format!("expected number <= {max}, got {value}"),
));
}
if let Some(min) = exclusive_min
&& value <= min
{
issues.push(SchemaIssue::new(
path,
format!("expected number > {min}, got {value}"),
));
}
if let Some(max) = exclusive_max
&& value >= max
{
issues.push(SchemaIssue::new(
path,
format!("expected number < {max}, got {value}"),
));
}
}
fn integer_as_float(value: INT) -> FLOAT {
value
.to_string()
.parse()
.expect("an integer always has a finite float representation")
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ObjectField {
pub schema: ValueSchema,
#[serde(default)]
pub required: bool,
#[serde(default)]
pub sensitive: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<crate::UiValue>,
}
impl ObjectField {
#[must_use]
pub fn required(schema: ValueSchema) -> Self {
Self {
schema,
required: true,
sensitive: false,
default: None,
}
}
#[must_use]
pub fn optional(schema: ValueSchema) -> Self {
Self {
schema,
required: false,
sensitive: false,
default: None,
}
}
#[must_use]
pub fn sensitive(mut self) -> Self {
self.sensitive = true;
self
}
#[must_use]
pub fn with_default(mut self, default: crate::UiValue) -> Self {
self.default = Some(default);
self
}
}
fn validate_object(
value: &Map,
fields: &BTreeMap<String, ObjectField>,
allow_unknown: bool,
path: &str,
issues: &mut Vec<SchemaIssue>,
) {
let actual_keys = value
.keys()
.map(ToString::to_string)
.collect::<BTreeSet<_>>();
for (name, field) in fields {
match value.get(name.as_str()) {
Some(value) => field
.schema
.validate_at(value, &format!("{path}.{name}"), issues),
None if field.required => issues.push(SchemaIssue::new(
format!("{path}.{name}"),
"required field is missing",
)),
None => {}
}
}
if !allow_unknown {
for unknown in actual_keys.difference(&fields.keys().cloned().collect()) {
issues.push(SchemaIssue::new(
format!("{path}.{unknown}"),
"unknown field",
));
}
}
}
fn expect_type(
valid: bool,
value: &Dynamic,
path: &str,
expected: &str,
issues: &mut Vec<SchemaIssue>,
) {
if !valid {
issues.push(SchemaIssue::new(
path,
format!("expected {expected}, got {}", value.type_name()),
));
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct SchemaIssue {
pub path: String,
pub message: String,
}
impl SchemaIssue {
#[must_use]
pub fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
Self {
path: path.into(),
message: message.into(),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct SchemaValidationError {
pub issues: Vec<SchemaIssue>,
}
impl fmt::Display for SchemaValidationError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
for (index, issue) in self.issues.iter().enumerate() {
if index > 0 {
formatter.write_str("; ")?;
}
write!(formatter, "{}: {}", issue.path, issue.message)?;
}
Ok(())
}
}
impl std::error::Error for SchemaValidationError {}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct SchemaDefinitionError {
pub path: String,
pub message: String,
}
impl SchemaDefinitionError {
#[must_use]
pub fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
Self {
path: path.into(),
message: message.into(),
}
}
}
impl fmt::Display for SchemaDefinitionError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(formatter, "{}: {}", self.path, self.message)
}
}
impl std::error::Error for SchemaDefinitionError {}
#[cfg(test)]
mod tests {
use super::*;
fn button_schema() -> ValueSchema {
ValueSchema::object(BTreeMap::from([
(
"text".to_owned(),
ObjectField::required(ValueSchema::string()),
),
(
"variant".to_owned(),
ObjectField::optional(ValueSchema::enumeration(["primary", "secondary"])),
),
(
"on_click".to_owned(),
ObjectField::optional(ValueSchema::Callback),
),
]))
}
#[test]
fn objects_report_all_precise_paths() {
let value = Dynamic::from_map(Map::from_iter([
("variant".into(), Dynamic::from("danger")),
("lable".into(), Dynamic::from("Save")),
]));
let error = button_schema().validate(&value).unwrap_err();
assert_eq!(
error.issues,
vec![
SchemaIssue::new("$.text", "required field is missing"),
SchemaIssue::new(
"$.variant",
"expected one of [primary, secondary], got `danger`",
),
SchemaIssue::new("$.lable", "unknown field"),
]
);
}
#[test]
fn node_callback_and_handle_types_are_distinct() {
ValueSchema::Node
.validate(&Dynamic::from(UiNode::text("content")))
.unwrap();
ValueSchema::Callback
.validate(&Dynamic::from(FnPtr::new("clicked").unwrap()))
.unwrap();
ValueSchema::Handle {
kind: "image".to_owned(),
}
.validate(&Dynamic::from(OpaqueHandle::new("image", 42)))
.unwrap();
let error = ValueSchema::Handle {
kind: "image".to_owned(),
}
.validate(&Dynamic::from(OpaqueHandle::new("task", 42)))
.unwrap_err();
assert_eq!(error.issues[0].path, "$".to_owned());
}
#[test]
fn bounds_and_unions_report_precise_failures() {
let schema = ValueSchema::one_of([
ValueSchema::bounded_integer(Some(1), Some(3)),
ValueSchema::enumeration(["auto"]),
]);
schema.validate_definition().unwrap();
schema.validate(&Dynamic::from(2_i64)).unwrap();
schema.validate(&Dynamic::from("auto")).unwrap();
let error = schema.validate(&Dynamic::from(9_i64)).unwrap_err();
assert_eq!(error.issues.len(), 1);
assert!(error.issues[0].message.contains("integer <= 3"));
assert!(error.issues[0].message.contains("expected string"));
}
#[test]
fn invalid_definitions_are_rejected() {
assert!(matches!(
ValueSchema::bounded_integer(Some(4), Some(2)).validate_definition(),
Err(SchemaDefinitionError { ref path, .. }) if path == "$"
));
assert!(ValueSchema::one_of([]).validate_definition().is_err());
assert!(
ValueSchema::bounded_number(Some(f64::NAN), None)
.validate_definition()
.is_err()
);
ValueSchema::positive_number()
.validate(&Dynamic::from(0.5_f64))
.unwrap();
assert!(
ValueSchema::positive_number()
.validate(&Dynamic::from(0_i64))
.is_err()
);
}
#[test]
fn length_ui_value_and_asset_are_distinct() {
ValueSchema::Length
.validate(&Dynamic::from(Length::pixels(12.0).unwrap()))
.unwrap();
ValueSchema::UiValue
.validate(&Dynamic::from_map(Map::from_iter([(
"nested".into(),
Dynamic::from_array(vec![Dynamic::from(1_i64)]),
)])))
.unwrap();
ValueSchema::Asset
.validate(&Dynamic::from(AssetId::parse("app/check").unwrap()))
.unwrap();
assert!(
ValueSchema::UiValue
.validate(&Dynamic::from(UiNode::text("not data")))
.is_err()
);
}
#[test]
fn online_ui_value_validation_stops_after_the_first_issue() {
let value = UiValue::Map(
(0..100_000)
.map(|index| (format!("field-{index}"), UiValue::Null))
.collect(),
);
let error = ValueSchema::Map {
values: Box::new(ValueSchema::integer()),
}
.validate_ui_value_first(&value)
.unwrap_err();
assert_eq!(error.issues.len(), 1);
assert_eq!(error.issues[0].path, "$.field-0");
}
#[test]
fn online_and_complete_durable_validation_agree_on_acceptance() {
let cases = [
(ValueSchema::integer(), UiValue::Integer(2)),
(ValueSchema::integer(), UiValue::Null),
(
ValueSchema::Array {
items: Box::new(ValueSchema::string()),
max_items: Some(2),
},
UiValue::Array(vec![UiValue::String("ok".to_owned())]),
),
(
ValueSchema::Array {
items: Box::new(ValueSchema::string()),
max_items: Some(2),
},
UiValue::Array(vec![UiValue::Integer(1)]),
),
(
button_schema(),
UiValue::Map(BTreeMap::from([(
"text".to_owned(),
UiValue::String("Save".to_owned()),
)])),
),
(
button_schema(),
UiValue::Map(BTreeMap::from([(
"variant".to_owned(),
UiValue::String("danger".to_owned()),
)])),
),
];
for (schema, value) in cases {
assert_eq!(
schema.validate_ui_value(&value).is_ok(),
schema.validate_ui_value_first(&value).is_ok(),
"schema={schema:?}, value={value:?}"
);
}
}
}