1use std::cmp::Ordering;
2use std::collections::{BTreeMap, BTreeSet};
3use std::fmt;
4
5use rhai::{Array, Dynamic, FLOAT, FnPtr, INT, ImmutableString, Map};
6use serde::{Deserialize, Serialize};
7
8use crate::{AssetId, ElementRef, Length, NativeSignal, OpaqueHandle, Style, UiNode, UiValue};
9
10#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
11#[serde(tag = "type", rename_all = "snake_case")]
12pub enum ValueSchema {
13 Null,
14 Bool,
15 Integer {
16 #[serde(default, skip_serializing_if = "Option::is_none")]
17 min: Option<INT>,
18 #[serde(default, skip_serializing_if = "Option::is_none")]
19 max: Option<INT>,
20 },
21 Float {
22 #[serde(default, skip_serializing_if = "Option::is_none")]
23 min: Option<FLOAT>,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
25 max: Option<FLOAT>,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
27 exclusive_min: Option<FLOAT>,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 exclusive_max: Option<FLOAT>,
30 },
31 Number {
32 #[serde(default, skip_serializing_if = "Option::is_none")]
33 min: Option<FLOAT>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 max: Option<FLOAT>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
37 exclusive_min: Option<FLOAT>,
38 #[serde(default, skip_serializing_if = "Option::is_none")]
39 exclusive_max: Option<FLOAT>,
40 },
41 String {
42 #[serde(default, skip_serializing_if = "Vec::is_empty")]
43 allowed: Vec<String>,
44 },
45 Array {
46 items: Box<Self>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 max_items: Option<usize>,
49 },
50 Map {
51 values: Box<Self>,
52 },
53 Object {
54 fields: BTreeMap<String, ObjectField>,
55 #[serde(default)]
56 allow_unknown: bool,
57 },
58 Optional {
59 value: Box<Self>,
60 },
61 OneOf {
62 variants: Vec<Self>,
63 },
64 Node,
65 Callback,
66 Style,
67 Length,
68 UiValue,
69 Asset,
70 Signal,
71 Collection,
72 Document,
73 #[cfg(feature = "charts")]
74 ChartData,
75 Ref,
76 Handle {
77 kind: String,
78 },
79}
80
81impl ValueSchema {
82 #[must_use]
83 pub fn string() -> Self {
84 Self::String {
85 allowed: Vec::new(),
86 }
87 }
88
89 #[must_use]
90 pub const fn integer() -> Self {
91 Self::Integer {
92 min: None,
93 max: None,
94 }
95 }
96
97 #[must_use]
98 pub const fn bounded_integer(min: Option<INT>, max: Option<INT>) -> Self {
99 Self::Integer { min, max }
100 }
101
102 #[must_use]
103 pub const fn float() -> Self {
104 Self::Float {
105 min: None,
106 max: None,
107 exclusive_min: None,
108 exclusive_max: None,
109 }
110 }
111
112 #[must_use]
113 pub const fn bounded_float(min: Option<FLOAT>, max: Option<FLOAT>) -> Self {
114 Self::Float {
115 min,
116 max,
117 exclusive_min: None,
118 exclusive_max: None,
119 }
120 }
121
122 #[must_use]
123 pub const fn number() -> Self {
124 Self::Number {
125 min: None,
126 max: None,
127 exclusive_min: None,
128 exclusive_max: None,
129 }
130 }
131
132 #[must_use]
133 pub const fn bounded_number(min: Option<FLOAT>, max: Option<FLOAT>) -> Self {
134 Self::Number {
135 min,
136 max,
137 exclusive_min: None,
138 exclusive_max: None,
139 }
140 }
141
142 #[must_use]
143 pub const fn positive_number() -> Self {
144 Self::Number {
145 min: None,
146 max: None,
147 exclusive_min: Some(0.0),
148 exclusive_max: None,
149 }
150 }
151
152 #[must_use]
153 pub fn enumeration(values: impl IntoIterator<Item = impl Into<String>>) -> Self {
154 Self::String {
155 allowed: values.into_iter().map(Into::into).collect(),
156 }
157 }
158
159 #[must_use]
160 pub fn optional(value: Self) -> Self {
161 Self::Optional {
162 value: Box::new(value),
163 }
164 }
165
166 #[must_use]
167 pub fn one_of(variants: impl IntoIterator<Item = Self>) -> Self {
168 Self::OneOf {
169 variants: variants.into_iter().collect(),
170 }
171 }
172
173 #[must_use]
174 pub fn object(fields: BTreeMap<String, ObjectField>) -> Self {
175 Self::Object {
176 fields,
177 allow_unknown: false,
178 }
179 }
180
181 pub fn validate_definition(&self) -> Result<(), SchemaDefinitionError> {
188 self.validate_definition_at("$")
189 }
190
191 fn validate_definition_at(&self, path: &str) -> Result<(), SchemaDefinitionError> {
192 match self {
193 Self::Integer { min, max } => validate_integer_bounds(*min, *max, path),
194 Self::Float {
195 min,
196 max,
197 exclusive_min,
198 exclusive_max,
199 }
200 | Self::Number {
201 min,
202 max,
203 exclusive_min,
204 exclusive_max,
205 } => validate_float_bounds(*min, *max, *exclusive_min, *exclusive_max, path),
206 Self::String { allowed } => {
207 let mut unique = BTreeSet::new();
208 if let Some(duplicate) = allowed.iter().find(|value| !unique.insert(*value)) {
209 return Err(SchemaDefinitionError::new(
210 path,
211 format!("allowed string value `{duplicate}` is duplicated"),
212 ));
213 }
214 Ok(())
215 }
216 Self::Array { items, .. } => items.validate_definition_at(&format!("{path}.items")),
217 Self::Map { values } => values.validate_definition_at(&format!("{path}.values")),
218 Self::Object { fields, .. } => {
219 for (name, field) in fields {
220 field
221 .schema
222 .validate_definition_at(&format!("{path}.fields.{name}"))?;
223 }
224 Ok(())
225 }
226 Self::Optional { value } => value.validate_definition_at(&format!("{path}.value")),
227 Self::OneOf { variants } => {
228 if variants.is_empty() {
229 return Err(SchemaDefinitionError::new(
230 path,
231 "one_of must contain at least one variant",
232 ));
233 }
234 for (index, variant) in variants.iter().enumerate() {
235 variant.validate_definition_at(&format!("{path}.variants[{index}]"))?;
236 }
237 Ok(())
238 }
239 Self::Handle { kind } if kind.trim().is_empty() => Err(SchemaDefinitionError::new(
240 path,
241 "handle kind cannot be empty",
242 )),
243 Self::Null
244 | Self::Bool
245 | Self::Node
246 | Self::Callback
247 | Self::Style
248 | Self::Length
249 | Self::UiValue
250 | Self::Asset
251 | Self::Signal
252 | Self::Collection
253 | Self::Document
254 | Self::Ref
255 | Self::Handle { .. } => Ok(()),
256 #[cfg(feature = "charts")]
257 Self::ChartData => Ok(()),
258 }
259 }
260
261 pub fn validate(&self, value: &Dynamic) -> Result<(), SchemaValidationError> {
268 let mut issues = Vec::new();
269 self.validate_at(value, "$", &mut issues);
270 if issues.is_empty() {
271 Ok(())
272 } else {
273 Err(SchemaValidationError { issues })
274 }
275 }
276
277 pub fn validate_ui_value(&self, value: &UiValue) -> Result<(), SchemaValidationError> {
283 value.validate().map_err(|error| SchemaValidationError {
284 issues: vec![SchemaIssue::new("$", error.to_string())],
285 })?;
286 self.validate(&value.clone().into_dynamic())
287 }
288
289 pub(crate) fn validate_ui_value_first(
292 &self,
293 value: &UiValue,
294 ) -> Result<(), SchemaValidationError> {
295 value.validate().map_err(|error| SchemaValidationError {
296 issues: vec![SchemaIssue::new("$", error.to_string())],
297 })?;
298 self.validate_ui_value_at_first(value, "$")
299 .map_or(Ok(()), |issue| {
300 Err(SchemaValidationError {
301 issues: vec![issue],
302 })
303 })
304 }
305
306 #[allow(clippy::too_many_lines)]
307 fn validate_ui_value_at_first(&self, value: &UiValue, path: &str) -> Option<SchemaIssue> {
308 let expected = |name: &str| {
309 Some(SchemaIssue::new(
310 path,
311 format!("expected {name}, got {}", ui_value_type_name(value)),
312 ))
313 };
314 match self {
315 Self::Null => (!matches!(value, UiValue::Null))
316 .then(|| expected("null"))
317 .flatten(),
318 Self::Bool => (!matches!(value, UiValue::Bool(_)))
319 .then(|| expected("bool"))
320 .flatten(),
321 Self::Integer { min, max } => match value {
322 UiValue::Integer(value) => {
323 if min.is_some_and(|min| *value < min) {
324 Some(SchemaIssue::new(
325 path,
326 format!("expected integer >= {}, got {value}", min.unwrap()),
327 ))
328 } else if max.is_some_and(|max| *value > max) {
329 Some(SchemaIssue::new(
330 path,
331 format!("expected integer <= {}, got {value}", max.unwrap()),
332 ))
333 } else {
334 None
335 }
336 }
337 _ => expected("integer"),
338 },
339 Self::Float {
340 min,
341 max,
342 exclusive_min,
343 exclusive_max,
344 } => match value {
345 UiValue::Float(value) => {
346 first_float_issue(*value, *min, *max, *exclusive_min, *exclusive_max, path)
347 }
348 _ => expected("float"),
349 },
350 Self::Number {
351 min,
352 max,
353 exclusive_min,
354 exclusive_max,
355 } => match value {
356 UiValue::Float(value) => {
357 first_float_issue(*value, *min, *max, *exclusive_min, *exclusive_max, path)
358 }
359 UiValue::Integer(value) => first_float_issue(
360 integer_as_float(*value),
361 *min,
362 *max,
363 *exclusive_min,
364 *exclusive_max,
365 path,
366 ),
367 _ => expected("number"),
368 },
369 Self::String { allowed } => match value {
370 UiValue::String(value)
371 if !allowed.is_empty() && !allowed.iter().any(|item| item == value) =>
372 {
373 Some(SchemaIssue::new(
374 path,
375 format!("expected one of [{}], got `{value}`", allowed.join(", ")),
376 ))
377 }
378 UiValue::String(_) => None,
379 _ => expected("string"),
380 },
381 Self::Array { items, max_items } => match value {
382 UiValue::Array(values) => {
383 if max_items.is_some_and(|max| values.len() > max) {
384 Some(SchemaIssue::new(
385 path,
386 format!(
387 "expected at most {} items, got {}",
388 max_items.unwrap(),
389 values.len()
390 ),
391 ))
392 } else {
393 values.iter().enumerate().find_map(|(index, value)| {
394 items.validate_ui_value_at_first(value, &format!("{path}[{index}]"))
395 })
396 }
397 }
398 _ => expected("array"),
399 },
400 Self::Map { values } => match value {
401 UiValue::Map(items) => items.iter().find_map(|(key, value)| {
402 values.validate_ui_value_at_first(value, &format!("{path}.{key}"))
403 }),
404 _ => expected("map"),
405 },
406 Self::Object {
407 fields,
408 allow_unknown,
409 } => match value {
410 UiValue::Map(values) => {
411 let field_issue = fields.iter().find_map(|(name, field)| {
412 values.get(name).map_or_else(
413 || {
414 field.required.then(|| {
415 SchemaIssue::new(
416 format!("{path}.{name}"),
417 "required field is missing",
418 )
419 })
420 },
421 |value| {
422 field
423 .schema
424 .validate_ui_value_at_first(value, &format!("{path}.{name}"))
425 },
426 )
427 });
428 field_issue.or_else(|| {
429 (!*allow_unknown).then(|| {
430 values
431 .keys()
432 .find(|key| !fields.contains_key(*key))
433 .map(|key| {
434 SchemaIssue::new(format!("{path}.{key}"), "unknown field")
435 })
436 })?
437 })
438 }
439 _ => expected("object"),
440 },
441 Self::Optional { value: schema } => {
442 if matches!(value, UiValue::Null) {
443 None
444 } else {
445 schema.validate_ui_value_at_first(value, path)
446 }
447 }
448 Self::OneOf { variants } => {
449 let failures = variants
450 .iter()
451 .map(|variant| variant.validate_ui_value_at_first(value, path))
452 .collect::<Vec<_>>();
453 if failures.iter().any(Option::is_none) {
454 None
455 } else {
456 Some(SchemaIssue::new(
457 path,
458 format!(
459 "value did not match any one_of variant ({})",
460 failures
461 .into_iter()
462 .enumerate()
463 .filter_map(|(index, issue)| issue
464 .map(|issue| format!("variant {index}: {}", issue.message)))
465 .collect::<Vec<_>>()
466 .join("; ")
467 ),
468 ))
469 }
470 }
471 Self::UiValue => None,
472 Self::Handle { kind } => match value {
473 UiValue::Handle(handle) if handle.kind() == kind => None,
474 UiValue::Handle(handle) => Some(SchemaIssue::new(
475 path,
476 format!("expected `{kind}` handle, got `{}` handle", handle.kind()),
477 )),
478 _ => expected(&format!("{kind} handle")),
479 },
480 Self::Node => expected("UiNode"),
481 Self::Callback => expected("callback or NativeHandlerRef"),
482 Self::Style => expected("Style"),
483 Self::Length => expected("Length"),
484 Self::Asset => expected("AssetId"),
485 Self::Signal => expected("NativeSignal"),
486 Self::Collection => expected("NativeCollection"),
487 Self::Document => expected("NativeTextDocument"),
488 #[cfg(feature = "charts")]
489 Self::ChartData => expected("NativeChartData"),
490 Self::Ref => expected("ElementRef"),
491 }
492 }
493
494 fn validate_at(&self, value: &Dynamic, path: &str, issues: &mut Vec<SchemaIssue>) {
495 match self {
496 Self::Null => expect_type(value.is_unit(), value, path, "null", issues),
497 Self::Bool => expect_type(value.is::<bool>(), value, path, "bool", issues),
498 Self::Integer { min, max } => validate_integer(value, *min, *max, path, issues),
499 Self::Float {
500 min,
501 max,
502 exclusive_min,
503 exclusive_max,
504 } => validate_float(
505 value,
506 *min,
507 *max,
508 *exclusive_min,
509 *exclusive_max,
510 path,
511 issues,
512 ),
513 Self::Number {
514 min,
515 max,
516 exclusive_min,
517 exclusive_max,
518 } => validate_number(
519 value,
520 *min,
521 *max,
522 *exclusive_min,
523 *exclusive_max,
524 path,
525 issues,
526 ),
527 Self::String { allowed } => validate_string(value, allowed, path, issues),
528 Self::Array { items, max_items } => {
529 validate_array(value, items, *max_items, path, issues);
530 }
531 Self::Map { values } => validate_map(value, values, path, issues),
532 Self::Object {
533 fields,
534 allow_unknown,
535 } => validate_object_value(value, fields, *allow_unknown, path, issues),
536 Self::Optional { value: schema } => {
537 if !value.is_unit() {
538 schema.validate_at(value, path, issues);
539 }
540 }
541 Self::OneOf { variants } => validate_one_of(value, variants, path, issues),
542 Self::Node => expect_type(value.is::<UiNode>(), value, path, "UiNode", issues),
543 Self::Callback => expect_type(
544 value.is::<FnPtr>() || value.is::<crate::NativeHandlerRef>(),
545 value,
546 path,
547 "callback or NativeHandlerRef",
548 issues,
549 ),
550 Self::Style => expect_type(value.is::<Style>(), value, path, "Style", issues),
551 Self::Length => validate_length(value, path, issues),
552 Self::UiValue => {
553 if let Err(error) = UiValue::from_dynamic(value.clone()) {
554 issues.push(SchemaIssue::new(path, error.to_string()));
555 }
556 }
557 Self::Asset => expect_type(value.is::<AssetId>(), value, path, "AssetId", issues),
558 Self::Signal => expect_type(
559 value.is::<NativeSignal>(),
560 value,
561 path,
562 "NativeSignal",
563 issues,
564 ),
565 Self::Collection => expect_type(
566 value.is::<crate::NativeCollection>(),
567 value,
568 path,
569 "NativeCollection",
570 issues,
571 ),
572 Self::Document => expect_type(
573 value.is::<crate::NativeTextDocument>(),
574 value,
575 path,
576 "NativeTextDocument",
577 issues,
578 ),
579 #[cfg(feature = "charts")]
580 Self::ChartData => expect_type(
581 value.is::<crate::NativeChartData>(),
582 value,
583 path,
584 "NativeChartData",
585 issues,
586 ),
587 Self::Ref => expect_type(value.is::<ElementRef>(), value, path, "ElementRef", issues),
588 Self::Handle { kind } => validate_handle(value, kind, path, issues),
589 }
590 }
591}
592
593fn ui_value_type_name(value: &UiValue) -> &'static str {
594 match value {
595 UiValue::Null => "null",
596 UiValue::Bool(_) => "bool",
597 UiValue::Integer(_) => "integer",
598 UiValue::Float(_) => "float",
599 UiValue::String(_) => "string",
600 UiValue::Array(_) => "array",
601 UiValue::Map(_) => "map",
602 UiValue::Handle(_) => "handle",
603 }
604}
605
606fn first_float_issue(
607 value: FLOAT,
608 min: Option<FLOAT>,
609 max: Option<FLOAT>,
610 exclusive_min: Option<FLOAT>,
611 exclusive_max: Option<FLOAT>,
612 path: &str,
613) -> Option<SchemaIssue> {
614 if !value.is_finite() {
615 Some(SchemaIssue::new(path, "number must be finite"))
616 } else if min.is_some_and(|min| value < min) {
617 Some(SchemaIssue::new(
618 path,
619 format!("expected number >= {}, got {value}", min.unwrap()),
620 ))
621 } else if max.is_some_and(|max| value > max) {
622 Some(SchemaIssue::new(
623 path,
624 format!("expected number <= {}, got {value}", max.unwrap()),
625 ))
626 } else if exclusive_min.is_some_and(|min| value <= min) {
627 Some(SchemaIssue::new(
628 path,
629 format!("expected number > {}, got {value}", exclusive_min.unwrap()),
630 ))
631 } else if exclusive_max.is_some_and(|max| value >= max) {
632 Some(SchemaIssue::new(
633 path,
634 format!("expected number < {}, got {value}", exclusive_max.unwrap()),
635 ))
636 } else {
637 None
638 }
639}
640
641fn validate_integer(
642 value: &Dynamic,
643 min: Option<INT>,
644 max: Option<INT>,
645 path: &str,
646 issues: &mut Vec<SchemaIssue>,
647) {
648 if value.is::<INT>() {
649 validate_integer_value(value.clone_cast::<INT>(), min, max, path, issues);
650 } else {
651 expect_type(false, value, path, "integer", issues);
652 }
653}
654
655fn validate_float(
656 value: &Dynamic,
657 min: Option<FLOAT>,
658 max: Option<FLOAT>,
659 exclusive_min: Option<FLOAT>,
660 exclusive_max: Option<FLOAT>,
661 path: &str,
662 issues: &mut Vec<SchemaIssue>,
663) {
664 if value.is::<FLOAT>() {
665 validate_float_value(
666 value.clone_cast::<FLOAT>(),
667 min,
668 max,
669 exclusive_min,
670 exclusive_max,
671 path,
672 issues,
673 );
674 } else {
675 expect_type(false, value, path, "float", issues);
676 }
677}
678
679fn validate_number(
680 value: &Dynamic,
681 min: Option<FLOAT>,
682 max: Option<FLOAT>,
683 exclusive_min: Option<FLOAT>,
684 exclusive_max: Option<FLOAT>,
685 path: &str,
686 issues: &mut Vec<SchemaIssue>,
687) {
688 let number = if value.is::<INT>() {
689 Some(integer_as_float(value.clone_cast::<INT>()))
690 } else if value.is::<FLOAT>() {
691 Some(value.clone_cast::<FLOAT>())
692 } else {
693 None
694 };
695 if let Some(number) = number {
696 validate_float_value(number, min, max, exclusive_min, exclusive_max, path, issues);
697 } else {
698 expect_type(false, value, path, "number", issues);
699 }
700}
701
702fn validate_string(value: &Dynamic, allowed: &[String], path: &str, issues: &mut Vec<SchemaIssue>) {
703 if value.is::<ImmutableString>() {
704 let actual = value.clone_cast::<ImmutableString>();
705 if !allowed.is_empty() && !allowed.iter().any(|allowed| allowed == actual.as_str()) {
706 issues.push(SchemaIssue::new(
707 path,
708 format!("expected one of [{}], got `{actual}`", allowed.join(", ")),
709 ));
710 }
711 } else {
712 expect_type(false, value, path, "string", issues);
713 }
714}
715
716fn validate_array(
717 value: &Dynamic,
718 items: &ValueSchema,
719 max_items: Option<usize>,
720 path: &str,
721 issues: &mut Vec<SchemaIssue>,
722) {
723 if !value.is::<Array>() {
724 expect_type(false, value, path, "array", issues);
725 return;
726 }
727 let values = value.clone_cast::<Array>();
728 if let Some(max_items) = max_items
729 && values.len() > max_items
730 {
731 issues.push(SchemaIssue::new(
732 path,
733 format!("expected at most {max_items} items, got {}", values.len()),
734 ));
735 }
736 for (index, item) in values.iter().enumerate() {
737 items.validate_at(item, &format!("{path}[{index}]"), issues);
738 }
739}
740
741fn validate_map(value: &Dynamic, values: &ValueSchema, path: &str, issues: &mut Vec<SchemaIssue>) {
742 if !value.is::<Map>() {
743 expect_type(false, value, path, "map", issues);
744 return;
745 }
746 for (key, item) in value.clone_cast::<Map>() {
747 values.validate_at(&item, &format!("{path}.{key}"), issues);
748 }
749}
750
751fn validate_object_value(
752 value: &Dynamic,
753 fields: &BTreeMap<String, ObjectField>,
754 allow_unknown: bool,
755 path: &str,
756 issues: &mut Vec<SchemaIssue>,
757) {
758 if value.is::<Map>() {
759 validate_object(
760 &value.clone_cast::<Map>(),
761 fields,
762 allow_unknown,
763 path,
764 issues,
765 );
766 } else {
767 expect_type(false, value, path, "object", issues);
768 }
769}
770
771fn validate_one_of(
772 value: &Dynamic,
773 variants: &[ValueSchema],
774 path: &str,
775 issues: &mut Vec<SchemaIssue>,
776) {
777 let mut branch_issues = Vec::with_capacity(variants.len());
778 for variant in variants {
779 let mut candidate = Vec::new();
780 variant.validate_at(value, path, &mut candidate);
781 if candidate.is_empty() {
782 return;
783 }
784 branch_issues.push(candidate);
785 }
786 let summary = branch_issues
787 .iter()
788 .enumerate()
789 .map(|(index, candidate)| {
790 let messages = candidate
791 .iter()
792 .map(|issue| issue.message.as_str())
793 .collect::<Vec<_>>()
794 .join(", ");
795 format!("variant {index}: {messages}")
796 })
797 .collect::<Vec<_>>()
798 .join("; ");
799 issues.push(SchemaIssue::new(
800 path,
801 format!("value did not match any one_of variant ({summary})"),
802 ));
803}
804
805fn validate_length(value: &Dynamic, path: &str, issues: &mut Vec<SchemaIssue>) {
806 if value.is::<Length>() {
807 let length = value.clone_cast::<Length>();
808 if let Err(error) = length.validate() {
809 issues.push(SchemaIssue::new(path, error.to_string()));
810 }
811 } else {
812 expect_type(false, value, path, "Length", issues);
813 }
814}
815
816fn validate_handle(value: &Dynamic, kind: &str, path: &str, issues: &mut Vec<SchemaIssue>) {
817 if value.is::<OpaqueHandle>() {
818 let handle = value.clone_cast::<OpaqueHandle>();
819 if handle.kind() != kind {
820 issues.push(SchemaIssue::new(
821 path,
822 format!("expected `{kind}` handle, got `{}` handle", handle.kind()),
823 ));
824 }
825 } else {
826 expect_type(false, value, path, &format!("{kind} handle"), issues);
827 }
828}
829
830fn validate_integer_bounds(
831 min: Option<INT>,
832 max: Option<INT>,
833 path: &str,
834) -> Result<(), SchemaDefinitionError> {
835 if min.zip(max).is_some_and(|(min, max)| min > max) {
836 Err(SchemaDefinitionError::new(
837 path,
838 format!("integer minimum {min:?} exceeds maximum {max:?}"),
839 ))
840 } else {
841 Ok(())
842 }
843}
844
845fn validate_float_bounds(
846 min: Option<FLOAT>,
847 max: Option<FLOAT>,
848 exclusive_min: Option<FLOAT>,
849 exclusive_max: Option<FLOAT>,
850 path: &str,
851) -> Result<(), SchemaDefinitionError> {
852 if min.is_some_and(|value| !value.is_finite())
853 || max.is_some_and(|value| !value.is_finite())
854 || exclusive_min.is_some_and(|value| !value.is_finite())
855 || exclusive_max.is_some_and(|value| !value.is_finite())
856 {
857 return Err(SchemaDefinitionError::new(
858 path,
859 "numeric bounds must be finite",
860 ));
861 }
862 if min.is_some() && exclusive_min.is_some() {
863 return Err(SchemaDefinitionError::new(
864 path,
865 "numeric schema cannot define both min and exclusive_min",
866 ));
867 }
868 if max.is_some() && exclusive_max.is_some() {
869 return Err(SchemaDefinitionError::new(
870 path,
871 "numeric schema cannot define both max and exclusive_max",
872 ));
873 }
874 let lower = min.or(exclusive_min);
875 let upper = max.or(exclusive_max);
876 let empty = lower.zip(upper).is_some_and(|(lower, upper)| {
877 matches!(lower.total_cmp(&upper), Ordering::Greater)
878 || (matches!(lower.total_cmp(&upper), Ordering::Equal)
879 && (exclusive_min.is_some() || exclusive_max.is_some()))
880 });
881 if empty {
882 Err(SchemaDefinitionError::new(
883 path,
884 format!("numeric lower bound {lower:?} does not precede upper bound {upper:?}"),
885 ))
886 } else {
887 Ok(())
888 }
889}
890
891fn validate_integer_value(
892 value: INT,
893 min: Option<INT>,
894 max: Option<INT>,
895 path: &str,
896 issues: &mut Vec<SchemaIssue>,
897) {
898 if let Some(min) = min
899 && value < min
900 {
901 issues.push(SchemaIssue::new(
902 path,
903 format!("expected integer >= {min}, got {value}"),
904 ));
905 }
906 if let Some(max) = max
907 && value > max
908 {
909 issues.push(SchemaIssue::new(
910 path,
911 format!("expected integer <= {max}, got {value}"),
912 ));
913 }
914}
915
916fn validate_float_value(
917 value: FLOAT,
918 min: Option<FLOAT>,
919 max: Option<FLOAT>,
920 exclusive_min: Option<FLOAT>,
921 exclusive_max: Option<FLOAT>,
922 path: &str,
923 issues: &mut Vec<SchemaIssue>,
924) {
925 if !value.is_finite() {
926 issues.push(SchemaIssue::new(path, "number must be finite"));
927 return;
928 }
929 if let Some(min) = min
930 && value < min
931 {
932 issues.push(SchemaIssue::new(
933 path,
934 format!("expected number >= {min}, got {value}"),
935 ));
936 }
937 if let Some(max) = max
938 && value > max
939 {
940 issues.push(SchemaIssue::new(
941 path,
942 format!("expected number <= {max}, got {value}"),
943 ));
944 }
945 if let Some(min) = exclusive_min
946 && value <= min
947 {
948 issues.push(SchemaIssue::new(
949 path,
950 format!("expected number > {min}, got {value}"),
951 ));
952 }
953 if let Some(max) = exclusive_max
954 && value >= max
955 {
956 issues.push(SchemaIssue::new(
957 path,
958 format!("expected number < {max}, got {value}"),
959 ));
960 }
961}
962
963fn integer_as_float(value: INT) -> FLOAT {
964 value
965 .to_string()
966 .parse()
967 .expect("an integer always has a finite float representation")
968}
969
970#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
971pub struct ObjectField {
972 pub schema: ValueSchema,
973 #[serde(default)]
974 pub required: bool,
975 #[serde(default)]
976 pub sensitive: bool,
977 #[serde(default, skip_serializing_if = "Option::is_none")]
978 pub default: Option<crate::UiValue>,
979}
980
981impl ObjectField {
982 #[must_use]
983 pub fn required(schema: ValueSchema) -> Self {
984 Self {
985 schema,
986 required: true,
987 sensitive: false,
988 default: None,
989 }
990 }
991
992 #[must_use]
993 pub fn optional(schema: ValueSchema) -> Self {
994 Self {
995 schema,
996 required: false,
997 sensitive: false,
998 default: None,
999 }
1000 }
1001
1002 #[must_use]
1003 pub fn sensitive(mut self) -> Self {
1004 self.sensitive = true;
1005 self
1006 }
1007
1008 #[must_use]
1009 pub fn with_default(mut self, default: crate::UiValue) -> Self {
1010 self.default = Some(default);
1011 self
1012 }
1013}
1014
1015fn validate_object(
1016 value: &Map,
1017 fields: &BTreeMap<String, ObjectField>,
1018 allow_unknown: bool,
1019 path: &str,
1020 issues: &mut Vec<SchemaIssue>,
1021) {
1022 let actual_keys = value
1023 .keys()
1024 .map(ToString::to_string)
1025 .collect::<BTreeSet<_>>();
1026
1027 for (name, field) in fields {
1028 match value.get(name.as_str()) {
1029 Some(value) => field
1030 .schema
1031 .validate_at(value, &format!("{path}.{name}"), issues),
1032 None if field.required => issues.push(SchemaIssue::new(
1033 format!("{path}.{name}"),
1034 "required field is missing",
1035 )),
1036 None => {}
1037 }
1038 }
1039
1040 if !allow_unknown {
1041 for unknown in actual_keys.difference(&fields.keys().cloned().collect()) {
1042 issues.push(SchemaIssue::new(
1043 format!("{path}.{unknown}"),
1044 "unknown field",
1045 ));
1046 }
1047 }
1048}
1049
1050fn expect_type(
1051 valid: bool,
1052 value: &Dynamic,
1053 path: &str,
1054 expected: &str,
1055 issues: &mut Vec<SchemaIssue>,
1056) {
1057 if !valid {
1058 issues.push(SchemaIssue::new(
1059 path,
1060 format!("expected {expected}, got {}", value.type_name()),
1061 ));
1062 }
1063}
1064
1065#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1066pub struct SchemaIssue {
1067 pub path: String,
1068 pub message: String,
1069}
1070
1071impl SchemaIssue {
1072 #[must_use]
1073 pub fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
1074 Self {
1075 path: path.into(),
1076 message: message.into(),
1077 }
1078 }
1079}
1080
1081#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1082pub struct SchemaValidationError {
1083 pub issues: Vec<SchemaIssue>,
1084}
1085
1086impl fmt::Display for SchemaValidationError {
1087 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1088 for (index, issue) in self.issues.iter().enumerate() {
1089 if index > 0 {
1090 formatter.write_str("; ")?;
1091 }
1092 write!(formatter, "{}: {}", issue.path, issue.message)?;
1093 }
1094 Ok(())
1095 }
1096}
1097
1098impl std::error::Error for SchemaValidationError {}
1099
1100#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
1101pub struct SchemaDefinitionError {
1102 pub path: String,
1103 pub message: String,
1104}
1105
1106impl SchemaDefinitionError {
1107 #[must_use]
1108 pub fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
1109 Self {
1110 path: path.into(),
1111 message: message.into(),
1112 }
1113 }
1114}
1115
1116impl fmt::Display for SchemaDefinitionError {
1117 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1118 write!(formatter, "{}: {}", self.path, self.message)
1119 }
1120}
1121
1122impl std::error::Error for SchemaDefinitionError {}
1123
1124#[cfg(test)]
1125mod tests {
1126 use super::*;
1127
1128 fn button_schema() -> ValueSchema {
1129 ValueSchema::object(BTreeMap::from([
1130 (
1131 "text".to_owned(),
1132 ObjectField::required(ValueSchema::string()),
1133 ),
1134 (
1135 "variant".to_owned(),
1136 ObjectField::optional(ValueSchema::enumeration(["primary", "secondary"])),
1137 ),
1138 (
1139 "on_click".to_owned(),
1140 ObjectField::optional(ValueSchema::Callback),
1141 ),
1142 ]))
1143 }
1144
1145 #[test]
1146 fn objects_report_all_precise_paths() {
1147 let value = Dynamic::from_map(Map::from_iter([
1148 ("variant".into(), Dynamic::from("danger")),
1149 ("lable".into(), Dynamic::from("Save")),
1150 ]));
1151 let error = button_schema().validate(&value).unwrap_err();
1152
1153 assert_eq!(
1154 error.issues,
1155 vec![
1156 SchemaIssue::new("$.text", "required field is missing"),
1157 SchemaIssue::new(
1158 "$.variant",
1159 "expected one of [primary, secondary], got `danger`",
1160 ),
1161 SchemaIssue::new("$.lable", "unknown field"),
1162 ]
1163 );
1164 }
1165
1166 #[test]
1167 fn node_callback_and_handle_types_are_distinct() {
1168 ValueSchema::Node
1169 .validate(&Dynamic::from(UiNode::text("content")))
1170 .unwrap();
1171 ValueSchema::Callback
1172 .validate(&Dynamic::from(FnPtr::new("clicked").unwrap()))
1173 .unwrap();
1174 ValueSchema::Handle {
1175 kind: "image".to_owned(),
1176 }
1177 .validate(&Dynamic::from(OpaqueHandle::new("image", 42)))
1178 .unwrap();
1179
1180 let error = ValueSchema::Handle {
1181 kind: "image".to_owned(),
1182 }
1183 .validate(&Dynamic::from(OpaqueHandle::new("task", 42)))
1184 .unwrap_err();
1185 assert_eq!(error.issues[0].path, "$".to_owned());
1186 }
1187
1188 #[test]
1189 fn bounds_and_unions_report_precise_failures() {
1190 let schema = ValueSchema::one_of([
1191 ValueSchema::bounded_integer(Some(1), Some(3)),
1192 ValueSchema::enumeration(["auto"]),
1193 ]);
1194 schema.validate_definition().unwrap();
1195 schema.validate(&Dynamic::from(2_i64)).unwrap();
1196 schema.validate(&Dynamic::from("auto")).unwrap();
1197
1198 let error = schema.validate(&Dynamic::from(9_i64)).unwrap_err();
1199 assert_eq!(error.issues.len(), 1);
1200 assert!(error.issues[0].message.contains("integer <= 3"));
1201 assert!(error.issues[0].message.contains("expected string"));
1202 }
1203
1204 #[test]
1205 fn invalid_definitions_are_rejected() {
1206 assert!(matches!(
1207 ValueSchema::bounded_integer(Some(4), Some(2)).validate_definition(),
1208 Err(SchemaDefinitionError { ref path, .. }) if path == "$"
1209 ));
1210 assert!(ValueSchema::one_of([]).validate_definition().is_err());
1211 assert!(
1212 ValueSchema::bounded_number(Some(f64::NAN), None)
1213 .validate_definition()
1214 .is_err()
1215 );
1216 ValueSchema::positive_number()
1217 .validate(&Dynamic::from(0.5_f64))
1218 .unwrap();
1219 assert!(
1220 ValueSchema::positive_number()
1221 .validate(&Dynamic::from(0_i64))
1222 .is_err()
1223 );
1224 }
1225
1226 #[test]
1227 fn length_ui_value_and_asset_are_distinct() {
1228 ValueSchema::Length
1229 .validate(&Dynamic::from(Length::pixels(12.0).unwrap()))
1230 .unwrap();
1231 ValueSchema::UiValue
1232 .validate(&Dynamic::from_map(Map::from_iter([(
1233 "nested".into(),
1234 Dynamic::from_array(vec![Dynamic::from(1_i64)]),
1235 )])))
1236 .unwrap();
1237 ValueSchema::Asset
1238 .validate(&Dynamic::from(AssetId::parse("app/check").unwrap()))
1239 .unwrap();
1240 assert!(
1241 ValueSchema::UiValue
1242 .validate(&Dynamic::from(UiNode::text("not data")))
1243 .is_err()
1244 );
1245 }
1246
1247 #[test]
1248 fn online_ui_value_validation_stops_after_the_first_issue() {
1249 let value = UiValue::Map(
1250 (0..100_000)
1251 .map(|index| (format!("field-{index}"), UiValue::Null))
1252 .collect(),
1253 );
1254 let error = ValueSchema::Map {
1255 values: Box::new(ValueSchema::integer()),
1256 }
1257 .validate_ui_value_first(&value)
1258 .unwrap_err();
1259 assert_eq!(error.issues.len(), 1);
1260 assert_eq!(error.issues[0].path, "$.field-0");
1261 }
1262
1263 #[test]
1264 fn online_and_complete_durable_validation_agree_on_acceptance() {
1265 let cases = [
1266 (ValueSchema::integer(), UiValue::Integer(2)),
1267 (ValueSchema::integer(), UiValue::Null),
1268 (
1269 ValueSchema::Array {
1270 items: Box::new(ValueSchema::string()),
1271 max_items: Some(2),
1272 },
1273 UiValue::Array(vec![UiValue::String("ok".to_owned())]),
1274 ),
1275 (
1276 ValueSchema::Array {
1277 items: Box::new(ValueSchema::string()),
1278 max_items: Some(2),
1279 },
1280 UiValue::Array(vec![UiValue::Integer(1)]),
1281 ),
1282 (
1283 button_schema(),
1284 UiValue::Map(BTreeMap::from([(
1285 "text".to_owned(),
1286 UiValue::String("Save".to_owned()),
1287 )])),
1288 ),
1289 (
1290 button_schema(),
1291 UiValue::Map(BTreeMap::from([(
1292 "variant".to_owned(),
1293 UiValue::String("danger".to_owned()),
1294 )])),
1295 ),
1296 ];
1297 for (schema, value) in cases {
1298 assert_eq!(
1299 schema.validate_ui_value(&value).is_ok(),
1300 schema.validate_ui_value_first(&value).is_ok(),
1301 "schema={schema:?}, value={value:?}"
1302 );
1303 }
1304 }
1305}