dmc-schema 0.3.0

Velite-style schema builders for dmc collection records
Documentation
use crate::{Ctx, Schema, ValidationError};
use serde_json::Value;

#[derive(Default, Clone)]
pub struct StringSchema {
  pub min: Option<usize>,
  pub max: Option<usize>,
  pub regex: Option<String>,
  pub length: Option<usize>,
}

impl StringSchema {
  pub fn min(mut self, n: usize) -> Self {
    self.min = Some(n);
    self
  }
  pub fn max(mut self, n: usize) -> Self {
    self.max = Some(n);
    self
  }
  pub fn length(mut self, n: usize) -> Self {
    self.length = Some(n);
    self
  }
  pub fn regex(mut self, pat: impl Into<String>) -> Self {
    self.regex = Some(pat.into());
    self
  }
}

impl Schema for StringSchema {
  fn parse(&self, value: &Value, _ctx: &Ctx) -> Result<Value, ValidationError> {
    let s =
      value.as_str().ok_or_else(|| ValidationError::root(format!("expected string, got {}", json_kind(value),)))?;
    let len = s.chars().count();
    if let Some(m) = self.min
      && len < m
    {
      return Err(ValidationError::root(format!("too short (min {m}, got {len})")));
    }
    if let Some(m) = self.max
      && len > m
    {
      return Err(ValidationError::root(format!("too long (max {m}, got {len})")));
    }
    if let Some(l) = self.length
      && len != l
    {
      return Err(ValidationError::root(format!("length {l} required (got {len})")));
    }
    if let Some(pat) = &self.regex {
      let re = fancy_regex::Regex::new(pat).map_err(|e| ValidationError::root(format!("invalid regex {pat}: {e}")))?;
      let matched = re.is_match(s).map_err(|e| ValidationError::root(format!("regex match error: {e}")))?;
      if !matched {
        return Err(ValidationError::root(format!("does not match /{pat}/")));
      }
    }
    Ok(Value::String(s.to_string()))
  }
}

pub struct RecordSchema {
  pub value: Box<dyn Schema>,
}

impl Schema for RecordSchema {
  fn parse(&self, value: &Value, ctx: &Ctx) -> Result<Value, ValidationError> {
    let obj =
      value.as_object().ok_or_else(|| ValidationError::root(format!("expected object, got {}", json_kind(value),)))?;
    let mut out = serde_json::Map::new();
    for (k, v) in obj {
      let parsed = self.value.parse(v, ctx).map_err(|e| e.at(k))?;
      out.insert(k.clone(), parsed);
    }
    Ok(Value::Object(out))
  }
}

pub struct TupleSchema {
  pub items: Vec<Box<dyn Schema>>,
}

impl Schema for TupleSchema {
  fn parse(&self, value: &Value, ctx: &Ctx) -> Result<Value, ValidationError> {
    let arr =
      value.as_array().ok_or_else(|| ValidationError::root(format!("expected tuple, got {}", json_kind(value),)))?;
    if arr.len() != self.items.len() {
      return Err(ValidationError::root(format!(
        "tuple length mismatch: expected {}, got {}",
        self.items.len(),
        arr.len(),
      )));
    }
    let mut out = Vec::with_capacity(arr.len());
    for (i, (schema, v)) in self.items.iter().zip(arr.iter()).enumerate() {
      out.push(schema.parse(v, ctx).map_err(|e| e.at_index(i))?);
    }
    Ok(Value::Array(out))
  }
}

pub struct IntersectionSchema {
  pub left: Box<dyn Schema>,
  pub right: Box<dyn Schema>,
}

impl Schema for IntersectionSchema {
  fn parse(&self, value: &Value, ctx: &Ctx) -> Result<Value, ValidationError> {
    let a = self.left.parse(value, ctx)?;
    let b = self.right.parse(value, ctx)?;
    match (a, b) {
      (Value::Object(mut ma), Value::Object(mb)) => {
        for (k, v) in mb {
          ma.insert(k, v);
        }
        Ok(Value::Object(ma))
      },
      (a, _) => Ok(a),
    }
  }
}

pub struct DiscriminatedUnionSchema {
  pub discriminator: String,
  pub variants: Vec<Box<dyn Schema>>,
}

impl Schema for DiscriminatedUnionSchema {
  fn parse(&self, value: &Value, ctx: &Ctx) -> Result<Value, ValidationError> {
    let obj = value.as_object().ok_or_else(|| ValidationError::root("discriminatedUnion expects object"))?;
    let tag = obj
      .get(&self.discriminator)
      .ok_or_else(|| ValidationError::root(format!("missing discriminator field '{}'", self.discriminator,)))?;
    for v in &self.variants {
      if let Ok(parsed) = v.parse(value, ctx) {
        return Ok(parsed);
      }
    }
    Err(ValidationError::root(format!("no discriminatedUnion variant matched for {}={}", self.discriminator, tag,)))
  }
}

pub struct CoerceSchema {
  pub target: CoerceTarget,
}

#[derive(Clone, Copy)]
pub enum CoerceTarget {
  String,
  Number,
  Boolean,
  Date,
}

impl Schema for CoerceSchema {
  fn parse(&self, value: &Value, _ctx: &Ctx) -> Result<Value, ValidationError> {
    match self.target {
      CoerceTarget::String => match value {
        Value::String(s) => Ok(Value::String(s.clone())),
        Value::Number(n) => Ok(Value::String(n.to_string())),
        Value::Bool(b) => Ok(Value::String(b.to_string())),
        Value::Null => Ok(Value::String(String::new())),
        _ => Err(ValidationError::root(format!("cannot coerce {} to string", json_kind(value)))),
      },
      CoerceTarget::Number => match value {
        Value::Number(_) => Ok(value.clone()),
        Value::String(s) => s
          .parse::<f64>()
          .map(|n| serde_json::json!(n))
          .map_err(|_| ValidationError::root(format!("cannot coerce '{s}' to number"))),
        Value::Bool(b) => Ok(serde_json::json!(if *b { 1 } else { 0 })),
        _ => Err(ValidationError::root(format!("cannot coerce {} to number", json_kind(value)))),
      },
      CoerceTarget::Boolean => match value {
        Value::Bool(_) => Ok(value.clone()),
        Value::String(s) => Ok(Value::Bool(!s.is_empty() && s != "false" && s != "0")),
        Value::Number(n) => Ok(Value::Bool(n.as_f64().is_some_and(|f| f != 0.0))),
        Value::Null => Ok(Value::Bool(false)),
        _ => Ok(Value::Bool(true)),
      },
      CoerceTarget::Date => match value {
        Value::String(s) => Ok(Value::String(s.clone())),
        _ => Err(ValidationError::root("date coerce requires string")),
      },
    }
  }
}

#[derive(Default, Clone)]
pub struct NumberSchema {
  pub min: Option<f64>,
  pub max: Option<f64>,
  pub int: bool,
}

impl NumberSchema {
  pub fn min(mut self, n: f64) -> Self {
    self.min = Some(n);
    self
  }
  pub fn max(mut self, n: f64) -> Self {
    self.max = Some(n);
    self
  }
  pub fn int(mut self) -> Self {
    self.int = true;
    self
  }
}

impl Schema for NumberSchema {
  fn parse(&self, value: &Value, _ctx: &Ctx) -> Result<Value, ValidationError> {
    let n =
      value.as_f64().ok_or_else(|| ValidationError::root(format!("expected number, got {}", json_kind(value),)))?;
    if self.int && n.fract() != 0.0 {
      return Err(ValidationError::root(format!("expected integer, got {n}")));
    }
    if let Some(m) = self.min
      && n < m
    {
      return Err(ValidationError::root(format!("below min {m} (got {n})")));
    }
    if let Some(m) = self.max
      && n > m
    {
      return Err(ValidationError::root(format!("above max {m} (got {n})")));
    }
    Ok(value.clone())
  }
}

#[derive(Default, Clone)]
pub struct BooleanSchema;

impl Schema for BooleanSchema {
  fn parse(&self, value: &Value, _ctx: &Ctx) -> Result<Value, ValidationError> {
    if value.is_boolean() {
      Ok(value.clone())
    } else {
      Err(ValidationError::root(format!("expected boolean, got {}", json_kind(value),)))
    }
  }
}

pub struct ArraySchema {
  pub item: Box<dyn Schema>,
  pub min: Option<usize>,
  pub max: Option<usize>,
}

impl ArraySchema {
  pub fn min(mut self, n: usize) -> Self {
    self.min = Some(n);
    self
  }
  pub fn max(mut self, n: usize) -> Self {
    self.max = Some(n);
    self
  }
}

impl Schema for ArraySchema {
  fn parse(&self, value: &Value, ctx: &Ctx) -> Result<Value, ValidationError> {
    let arr =
      value.as_array().ok_or_else(|| ValidationError::root(format!("expected array, got {}", json_kind(value),)))?;
    if let Some(m) = self.min
      && arr.len() < m
    {
      return Err(ValidationError::root(format!("too few items (min {m}, got {})", arr.len())));
    }
    if let Some(m) = self.max
      && arr.len() > m
    {
      return Err(ValidationError::root(format!("too many items (max {m}, got {})", arr.len())));
    }
    let mut out = Vec::with_capacity(arr.len());
    for (idx, item) in arr.iter().enumerate() {
      out.push(self.item.parse(item, ctx).map_err(|e| e.at_index(idx))?);
    }
    Ok(Value::Array(out))
  }
}

pub struct ObjectSchema {
  pub fields: Vec<(String, Box<dyn Schema>)>,
  pub passthrough: bool,
}

impl ObjectSchema {
  pub fn passthrough(mut self) -> Self {
    self.passthrough = true;
    self
  }
}

impl Schema for ObjectSchema {
  fn parse(&self, value: &Value, ctx: &Ctx) -> Result<Value, ValidationError> {
    let obj =
      value.as_object().ok_or_else(|| ValidationError::root(format!("expected object, got {}", json_kind(value),)))?;
    let mut out = serde_json::Map::new();
    for (key, schema) in &self.fields {
      let v = obj.get(key).cloned().unwrap_or(Value::Null);
      let parsed = schema.parse(&v, ctx).map_err(|e| e.at(key))?;
      if !parsed.is_null() {
        out.insert(key.clone(), parsed);
      }
    }
    if self.passthrough {
      for (k, v) in obj {
        if !out.contains_key(k) {
          out.insert(k.clone(), v.clone());
        }
      }
    }
    Ok(Value::Object(out))
  }
}

pub struct EnumSchema {
  pub variants: Vec<Value>,
}

impl Schema for EnumSchema {
  fn parse(&self, value: &Value, _ctx: &Ctx) -> Result<Value, ValidationError> {
    if self.variants.contains(value) {
      Ok(value.clone())
    } else {
      let allowed: Vec<String> = self.variants.iter().map(|v| v.to_string()).collect();
      Err(ValidationError::root(format!("must be one of [{}], got {}", allowed.join(", "), value,)))
    }
  }
}

pub struct LiteralSchema {
  pub expected: Value,
}

impl Schema for LiteralSchema {
  fn parse(&self, value: &Value, _ctx: &Ctx) -> Result<Value, ValidationError> {
    if value == &self.expected {
      Ok(value.clone())
    } else {
      Err(ValidationError::root(format!("must equal {}, got {}", self.expected, value,)))
    }
  }
}

pub struct UnionSchema {
  pub variants: Vec<Box<dyn Schema>>,
}

impl Schema for UnionSchema {
  fn parse(&self, value: &Value, ctx: &Ctx) -> Result<Value, ValidationError> {
    let mut errors = Vec::new();
    for variant in &self.variants {
      match variant.parse(value, ctx) {
        Ok(v) => return Ok(v),
        Err(e) => errors.push(e),
      }
    }
    Err(ValidationError::root(format!(
      "no union variant matched ({} attempts: {})",
      errors.len(),
      errors.iter().map(|e| e.message.clone()).collect::<Vec<_>>().join("; "),
    )))
  }
}

fn json_kind(v: &Value) -> &'static str {
  match v {
    Value::Null => "null",
    Value::Bool(_) => "boolean",
    Value::Number(_) => "number",
    Value::String(_) => "string",
    Value::Array(_) => "array",
    Value::Object(_) => "object",
  }
}