1use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7use crate::error::{EngineError, ErrorCode};
8use crate::limits::Limits;
9use crate::number::Number;
10use crate::value::{Bound, Dimension, RESERVED_KIND_KEY, Value};
11
12#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum NumberKind {
16 Integer,
17 Rational,
18 Decimal,
19 Float64,
20 Exact,
22 Any,
24}
25
26impl NumberKind {
27 pub fn accepts(&self, number: &Number) -> bool {
28 match self {
29 NumberKind::Integer => matches!(number, Number::Integer(_)),
30 NumberKind::Rational => matches!(number, Number::Rational(_)),
31 NumberKind::Decimal => matches!(number, Number::Decimal(_)),
32 NumberKind::Float64 => matches!(number, Number::Float64(_)),
33 NumberKind::Exact => !matches!(number, Number::Float64(_)),
34 NumberKind::Any => true,
35 }
36 }
37
38 pub fn label(&self) -> &'static str {
39 match self {
40 NumberKind::Integer => "integer",
41 NumberKind::Rational => "rational",
42 NumberKind::Decimal => "decimal",
43 NumberKind::Float64 => "float64",
44 NumberKind::Exact => "exact number (integer, rational, or decimal)",
45 NumberKind::Any => "number",
46 }
47 }
48}
49
50#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
52pub struct FieldSchema {
53 pub name: String,
54 pub description: String,
55 pub schema: ValueSchema,
56 #[serde(default)]
57 pub required: bool,
58}
59
60impl FieldSchema {
61 pub fn required(name: impl Into<String>, schema: ValueSchema) -> FieldSchema {
62 FieldSchema {
63 name: name.into(),
64 description: String::new(),
65 schema,
66 required: true,
67 }
68 }
69
70 pub fn optional(name: impl Into<String>, schema: ValueSchema) -> FieldSchema {
71 FieldSchema {
72 name: name.into(),
73 description: String::new(),
74 schema,
75 required: false,
76 }
77 }
78
79 pub fn with_description(mut self, description: impl Into<String>) -> FieldSchema {
80 self.description = description.into();
81 self
82 }
83}
84
85#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
87#[serde(tag = "type", rename_all = "snake_case")]
88pub enum ValueSchema {
89 Number {
90 kind: NumberKind,
91 },
92 Bool,
93 Text {
94 #[serde(default, skip_serializing_if = "Option::is_none")]
95 max_len: Option<usize>,
96 },
97 Enum {
98 variants: Vec<String>,
99 },
100 Array {
101 items: Box<ValueSchema>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
103 min_len: Option<usize>,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 max_len: Option<usize>,
106 },
107 Record {
108 fields: Vec<FieldSchema>,
109 #[serde(default)]
110 allow_extra: bool,
111 },
112 Quantity {
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 dimension: Option<Dimension>,
115 #[serde(default)]
117 allow_delta: bool,
118 },
119 Money,
120 Matrix {
121 #[serde(default, skip_serializing_if = "Option::is_none")]
122 max_elems: Option<usize>,
123 #[serde(default)]
124 square: bool,
125 },
126 Expression {
128 variables: Vec<String>,
129 },
130 Bound,
131 Any,
132}
133
134impl ValueSchema {
135 pub fn number(kind: NumberKind) -> ValueSchema {
136 ValueSchema::Number { kind }
137 }
138
139 pub fn exact() -> ValueSchema {
140 ValueSchema::Number {
141 kind: NumberKind::Exact,
142 }
143 }
144
145 pub fn float64() -> ValueSchema {
146 ValueSchema::Number {
147 kind: NumberKind::Float64,
148 }
149 }
150
151 pub fn text() -> ValueSchema {
152 ValueSchema::Text { max_len: None }
153 }
154
155 pub fn array(items: ValueSchema) -> ValueSchema {
156 ValueSchema::Array {
157 items: Box::new(items),
158 min_len: None,
159 max_len: None,
160 }
161 }
162
163 pub fn array_with_len(
164 items: ValueSchema,
165 min_len: usize,
166 max_len: Option<usize>,
167 ) -> ValueSchema {
168 ValueSchema::Array {
169 items: Box::new(items),
170 min_len: Some(min_len),
171 max_len,
172 }
173 }
174
175 pub fn validate(&self, value: &Value, path: &str, limits: &Limits) -> Result<(), EngineError> {
177 value.check_limits(limits, 0)?;
178 self.validate_inner(value, path, limits)
179 }
180
181 fn validate_inner(
182 &self,
183 value: &Value,
184 path: &str,
185 limits: &Limits,
186 ) -> Result<(), EngineError> {
187 let at = |error: EngineError| {
188 if error.path.is_none() {
189 error.with_path(path.to_string())
190 } else {
191 error
192 }
193 };
194 match self {
195 ValueSchema::Number { kind } => match value {
196 Value::Number(number) if kind.accepts(number) => Ok(()),
197 Value::Number(number) => Err(at(EngineError::malformed(format!(
198 "expected {}, found {}",
199 kind.label(),
200 number.kind_name()
201 )))),
202 other => Err(at(EngineError::malformed(format!(
203 "expected {}, found {}",
204 kind.label(),
205 other.kind_name()
206 )))),
207 },
208 ValueSchema::Bool => match value {
209 Value::Bool(_) => Ok(()),
210 other => Err(at(EngineError::malformed(format!(
211 "expected a boolean, found {}",
212 other.kind_name()
213 )))),
214 },
215 ValueSchema::Text { max_len } => match value {
216 Value::Text(text) => {
217 if let Some(max_len) = max_len
218 && text.chars().count() > *max_len
219 {
220 return Err(at(EngineError::new(
221 ErrorCode::ResourceLimit,
222 format!(
223 "text length {} exceeds the limit of {max_len}",
224 text.chars().count()
225 ),
226 )));
227 }
228 Ok(())
229 }
230 other => Err(at(EngineError::malformed(format!(
231 "expected text, found {}",
232 other.kind_name()
233 )))),
234 },
235 ValueSchema::Enum { variants } => match value {
236 Value::Text(text) if variants.iter().any(|v| v == text) => Ok(()),
237 Value::Text(text) => Err(at(EngineError::domain(format!(
238 "unknown variant {text:?}; expected one of {variants:?}"
239 )))),
240 other => Err(at(EngineError::malformed(format!(
241 "expected one of {variants:?}, found {}",
242 other.kind_name()
243 )))),
244 },
245 ValueSchema::Array {
246 items,
247 min_len,
248 max_len,
249 } => match value {
250 Value::Array(values) => {
251 if let Some(min_len) = min_len
252 && values.len() < *min_len
253 {
254 return Err(at(EngineError::new(
255 ErrorCode::InsufficientObservations,
256 format!(
257 "array has {} elements; at least {min_len} are required",
258 values.len()
259 ),
260 )));
261 }
262 let max = max_len.unwrap_or(limits.max_array_len);
263 if values.len() > max {
264 return Err(at(EngineError::new(
265 ErrorCode::ResourceLimit,
266 format!("array length {} exceeds the limit of {max}", values.len()),
267 )));
268 }
269 for (index, item) in values.iter().enumerate() {
270 items.validate_inner(item, &format!("{path}[{index}]"), limits)?;
271 }
272 Ok(())
273 }
274 other => Err(at(EngineError::malformed(format!(
275 "expected an array, found {}",
276 other.kind_name()
277 )))),
278 },
279 ValueSchema::Record {
280 fields,
281 allow_extra,
282 } => match value {
283 Value::Record(record) => {
284 for field in fields {
285 match record.get(&field.name) {
286 Some(value) => {
287 field.schema.validate_inner(
288 value,
289 &format!("{path}.{}", field.name),
290 limits,
291 )?;
292 }
293 None if field.required => {
294 return Err(at(EngineError::malformed(format!(
295 "missing required field {:?}",
296 field.name
297 ))));
298 }
299 None => {}
300 }
301 }
302 if !allow_extra {
303 for key in record.keys() {
304 if !fields.iter().any(|f| &f.name == key) {
305 return Err(at(EngineError::malformed(format!(
306 "unknown field {key:?}"
307 ))));
308 }
309 }
310 }
311 Ok(())
312 }
313 other => Err(at(EngineError::malformed(format!(
314 "expected a record, found {}",
315 other.kind_name()
316 )))),
317 },
318 ValueSchema::Quantity {
319 dimension,
320 allow_delta: _,
321 } => match value {
322 Value::Quantity {
323 value,
324 dimension: actual,
325 } => {
326 if let Some(expected) = dimension
327 && expected != actual
328 {
329 return Err(at(EngineError::new(
330 ErrorCode::IncompatibleUnits,
331 format!("expected dimension {expected}, found {actual}"),
332 )));
333 }
334 value.check_limits(limits, 0)?;
335 Ok(())
336 }
337 other => Err(at(EngineError::malformed(format!(
338 "expected a quantity, found {}",
339 other.kind_name()
340 )))),
341 },
342 ValueSchema::Money => match value {
343 Value::Money { .. } => Ok(()),
344 other => Err(at(EngineError::malformed(format!(
345 "expected money, found {}",
346 other.kind_name()
347 )))),
348 },
349 ValueSchema::Matrix { max_elems, square } => match value {
350 Value::Matrix { rows, cols, .. } => {
351 if *square && rows != cols {
352 return Err(at(EngineError::malformed(format!(
353 "expected a square matrix, found {rows}x{cols}"
354 ))));
355 }
356 let max = max_elems.unwrap_or(limits.max_matrix_elements);
357 if (*rows as usize).saturating_mul(*cols as usize) > max {
358 return Err(at(EngineError::new(
359 ErrorCode::ResourceLimit,
360 format!("matrix {rows}x{cols} exceeds the element limit of {max}"),
361 )));
362 }
363 Ok(())
364 }
365 other => Err(at(EngineError::malformed(format!(
366 "expected a matrix, found {}",
367 other.kind_name()
368 )))),
369 },
370 ValueSchema::Expression { .. } => match value {
371 Value::Text(_) => Ok(()),
372 other => Err(at(EngineError::malformed(format!(
373 "expected an expression string, found {}",
374 other.kind_name()
375 )))),
376 },
377 ValueSchema::Bound => match value {
378 Value::Bound(_) => Ok(()),
379 other => Err(at(EngineError::malformed(format!(
380 "expected a bound, found {}",
381 other.kind_name()
382 )))),
383 },
384 ValueSchema::Any => Ok(()),
385 }
386 }
387
388 pub fn coerce(
392 &self,
393 raw: &serde_json::Value,
394 path: &str,
395 limits: &Limits,
396 numeric_shorthand: bool,
397 ) -> Result<Value, EngineError> {
398 match (self, raw) {
399 (ValueSchema::Number { kind }, serde_json::Value::String(text)) => {
400 if !numeric_shorthand {
401 return Err(EngineError::malformed(
402 "string shorthand is not allowed for this parameter",
403 )
404 .with_path(path.to_string()));
405 }
406 let number = Number::parse_literal(text, limits)
407 .map_err(|e| e.with_path(path.to_string()))?;
408 if !kind.accepts(&number) {
409 return Err(EngineError::malformed(format!(
410 "expected {}, parsed shorthand is {}",
411 kind.label(),
412 number.kind_name()
413 ))
414 .with_path(path.to_string()));
415 }
416 Ok(Value::Number(number))
417 }
418 (ValueSchema::Array { items, .. }, serde_json::Value::Array(raw_items)) => {
419 let mut out = Vec::with_capacity(raw_items.len());
420 for (index, item) in raw_items.iter().enumerate() {
421 out.push(items.coerce(
422 item,
423 &format!("{path}[{index}]"),
424 limits,
425 numeric_shorthand,
426 )?);
427 }
428 let value = Value::Array(out);
429 self.validate(&value, path, limits)?;
430 Ok(value)
431 }
432 (
433 ValueSchema::Record {
434 fields,
435 allow_extra,
436 },
437 serde_json::Value::Object(object),
438 ) => {
439 let mut out = BTreeMap::new();
440 for (key, raw_value) in object {
441 if key == RESERVED_KIND_KEY {
442 return Err(EngineError::malformed(
443 "record key \"kind\" is reserved for tagged values",
444 )
445 .with_path(path.to_string()));
446 }
447 let field_schema = fields
448 .iter()
449 .find(|f| &f.name == key)
450 .map(|f| &f.schema)
451 .or(if *allow_extra {
452 Some(&ValueSchema::Any)
453 } else {
454 None
455 });
456 let Some(field_schema) = field_schema else {
457 return Err(EngineError::malformed(format!("unknown field {key:?}"))
458 .with_path(path.to_string()));
459 };
460 out.insert(
461 key.clone(),
462 field_schema.coerce(
463 raw_value,
464 &format!("{path}.{key}"),
465 limits,
466 numeric_shorthand,
467 )?,
468 );
469 }
470 let value = Value::Record(out);
471 self.validate(&value, path, limits)?;
472 Ok(value)
473 }
474 _ => {
475 let value =
476 Value::from_json(raw, limits).map_err(|e| e.with_path(path.to_string()))?;
477 self.validate(&value, path, limits)?;
478 Ok(value)
479 }
480 }
481 }
482
483 pub fn is_numeric(&self) -> bool {
486 matches!(self, ValueSchema::Number { .. })
487 }
488
489 pub fn summary(&self) -> String {
491 match self {
492 ValueSchema::Number { kind } => kind.label().to_string(),
493 ValueSchema::Bool => "boolean".to_string(),
494 ValueSchema::Text { .. } => "text".to_string(),
495 ValueSchema::Enum { variants } => format!("one of {variants:?}"),
496 ValueSchema::Array { items, .. } => format!("array of {}", items.summary()),
497 ValueSchema::Record { fields, .. } => {
498 let names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
499 format!("record with fields {}", names.join(", "))
500 }
501 ValueSchema::Quantity { dimension, .. } => match dimension {
502 Some(dimension) => format!("quantity [{dimension}]"),
503 None => "quantity".to_string(),
504 },
505 ValueSchema::Money => "money".to_string(),
506 ValueSchema::Matrix { .. } => "matrix".to_string(),
507 ValueSchema::Expression { variables } => {
508 format!("restricted expression in {}", variables.join(", "))
509 }
510 ValueSchema::Bound => "bound".to_string(),
511 ValueSchema::Any => "any value".to_string(),
512 }
513 }
514
515 pub fn to_json_schema(&self) -> serde_json::Value {
517 use serde_json::json;
518 match self {
519 ValueSchema::Number { kind } => json!({
520 "description": kind.label(),
521 "oneOf": [
522 {"type": "object", "properties": {"kind": {"const": "integer"}, "value": {"type": "string"}}, "required": ["kind", "value"]},
523 {"type": "object", "properties": {"kind": {"const": "rational"}, "numerator": {"type": "string"}, "denominator": {"type": "string"}}, "required": ["kind", "numerator", "denominator"]},
524 {"type": "object", "properties": {"kind": {"const": "decimal"}, "value": {"type": "string"}}, "required": ["kind", "value"]},
525 {"type": "object", "properties": {"kind": {"const": "float64"}, "value": {"type": "string"}}, "required": ["kind", "value"]},
526 {"type": "string", "description": "numeric shorthand string (for declared numeric parameters)"}
527 ]
528 }),
529 ValueSchema::Bool => json!({"type": "boolean"}),
530 ValueSchema::Text { .. } => json!({"type": "string"}),
531 ValueSchema::Enum { variants } => json!({"type": "string", "enum": variants}),
532 ValueSchema::Array {
533 items,
534 min_len,
535 max_len,
536 } => {
537 let mut schema = json!({"type": "array", "items": items.to_json_schema()});
538 if let Some(min_len) = min_len {
539 schema["minItems"] = json!(min_len);
540 }
541 if let Some(max_len) = max_len {
542 schema["maxItems"] = json!(max_len);
543 }
544 schema
545 }
546 ValueSchema::Record {
547 fields,
548 allow_extra,
549 } => {
550 let mut properties = serde_json::Map::new();
551 let mut required = Vec::new();
552 for field in fields {
553 let mut schema = field.schema.to_json_schema();
554 if !field.description.is_empty() {
555 schema["description"] = json!(field.description);
556 }
557 properties.insert(field.name.clone(), schema);
558 if field.required {
559 required.push(serde_json::Value::String(field.name.clone()));
560 }
561 }
562 json!({
563 "type": "object",
564 "properties": properties,
565 "required": required,
566 "additionalProperties": *allow_extra,
567 })
568 }
569 ValueSchema::Quantity { .. } => json!({
570 "type": "object",
571 "description": "quantity: {kind: quantity, value: number, dimension: {...}}",
572 }),
573 ValueSchema::Money => json!({
574 "type": "object",
575 "description": "money: {kind: money, amount: number, currency: \"USD\"}",
576 }),
577 ValueSchema::Matrix { .. } => json!({
578 "type": "object",
579 "description": "matrix: {kind: matrix, rows: n, cols: m, data: [numbers]}",
580 }),
581 ValueSchema::Expression { variables } => json!({
582 "type": "string",
583 "description": format!("restricted expression using variables {}", variables.join(", ")),
584 }),
585 ValueSchema::Bound => json!({
586 "type": "object",
587 "description": "bound: {kind: bound, unbounded: true} or {kind: bound, value: number}",
588 }),
589 ValueSchema::Any => json!({}),
590 }
591 }
592}
593
594pub fn exact_number_array() -> ValueSchema {
596 ValueSchema::array(ValueSchema::exact())
597}
598
599pub fn float64_array() -> ValueSchema {
601 ValueSchema::array(ValueSchema::float64())
602}
603
604pub fn integer() -> ValueSchema {
606 ValueSchema::number(NumberKind::Integer)
607}
608
609pub fn finite_scalar() -> ValueSchema {
611 ValueSchema::number(NumberKind::Any)
612}
613
614pub fn bound() -> ValueSchema {
616 ValueSchema::Bound
617}
618
619pub fn expect_dimension(value: &Value, expected: Dimension) -> Result<(), EngineError> {
621 match value {
622 Value::Quantity { dimension, .. } if *dimension == expected => Ok(()),
623 Value::Quantity { dimension, .. } => Err(EngineError::new(
624 ErrorCode::IncompatibleUnits,
625 format!("expected dimension {expected}, found {dimension}"),
626 )),
627 other => Err(EngineError::malformed(format!(
628 "expected a quantity, found {}",
629 other.kind_name()
630 ))),
631 }
632}
633
634pub fn bound_endpoint(value: &Value) -> Result<Option<&Number>, EngineError> {
636 match value {
637 Value::Bound(Bound::Unbounded) => Ok(None),
638 Value::Bound(Bound::Finite(number)) => Ok(Some(number)),
639 other => Err(EngineError::malformed(format!(
640 "expected a bound, found {}",
641 other.kind_name()
642 ))),
643 }
644}
645
646#[cfg(test)]
647mod tests {
648 use super::*;
649
650 fn limits() -> Limits {
651 Limits::conservative()
652 }
653
654 #[test]
655 fn shorthand_is_only_allowed_when_declared() {
656 let schema = ValueSchema::exact();
657 let coerced = schema
658 .coerce(&serde_json::json!("0.10"), "x", &limits(), true)
659 .unwrap();
660 assert_eq!(
661 serde_json::to_value(&coerced).unwrap(),
662 serde_json::json!({"kind": "decimal", "value": "0.10"})
663 );
664 assert!(
665 schema
666 .coerce(&serde_json::json!("0.10"), "x", &limits(), false)
667 .is_err()
668 );
669 }
670
671 #[test]
672 fn float_is_rejected_for_exact_schema() {
673 let schema = ValueSchema::exact();
674 let raw = serde_json::json!({"kind": "float64", "value": "0.1"});
675 let err = schema.coerce(&raw, "x", &limits(), true).unwrap_err();
676 assert_eq!(err.code, ErrorCode::MalformedInput);
677 }
678
679 #[test]
680 fn unknown_record_fields_are_rejected() {
681 let schema = ValueSchema::Record {
682 fields: vec![FieldSchema::required("a", ValueSchema::exact())],
683 allow_extra: false,
684 };
685 let err = schema
686 .coerce(&serde_json::json!({"a": 1, "b": 2}), "r", &limits(), true)
687 .unwrap_err();
688 assert!(err.message.contains("unknown field"));
689 }
690}