dmc-schema 0.3.2

Velite-style schema builders for dmc collection records
Documentation
use dmc_schema::{BoxSchema, Ctx, Schema, s};
use pretty_assertions::assert_eq;
use serde_json::json;

fn ctx() -> Ctx {
  Ctx::empty()
}

#[test]
fn string_min_max() {
  let schema = s::string().min(2).max(5);
  assert_eq!(schema.parse(&json!("hi"), &ctx()).unwrap(), json!("hi"));
  assert!(schema.parse(&json!("a"), &ctx()).is_err());
  assert!(schema.parse(&json!("toolong"), &ctx()).is_err());
  assert!(schema.parse(&json!(42), &ctx()).is_err());
}

#[test]
fn number_int_range() {
  let schema = s::number().int().min(1.0).max(10.0);
  assert_eq!(schema.parse(&json!(5), &ctx()).unwrap(), json!(5));
  assert!(schema.parse(&json!(0), &ctx()).is_err());
  assert!(schema.parse(&json!(11), &ctx()).is_err());
  assert!(schema.parse(&json!(2.5), &ctx()).is_err());
}

#[test]
fn boolean_strict() {
  let schema = s::boolean();
  assert_eq!(schema.parse(&json!(true), &ctx()).unwrap(), json!(true));
  assert!(schema.parse(&json!(1), &ctx()).is_err());
  assert!(schema.parse(&json!("true"), &ctx()).is_err());
}

#[test]
fn array_of_strings() {
  let schema = s::array(s::string().min(1).boxed()).min(1);
  assert_eq!(schema.parse(&json!(["a", "b"]), &ctx()).unwrap(), json!(["a", "b"]),);
  let err = schema.parse(&json!([""]), &ctx()).unwrap_err();
  assert_eq!(err.path, "[0]");
}

#[test]
fn object_with_optional_and_default() {
  let schema = s::object(vec![
    ("title".into(), s::string().max(99).boxed()),
    ("draft".into(), s::default_(s::boolean().boxed(), json!(false)).boxed()),
    ("tags".into(), s::optional(s::array(s::string().boxed()).boxed()).boxed()),
  ]);
  let out = schema.parse(&json!({"title": "Hello"}), &ctx()).unwrap();
  assert_eq!(out["title"], "Hello");
  assert_eq!(out["draft"], false);
  assert!(out.get("tags").is_none());

  let out = schema.parse(&json!({"title": "Hi", "tags": ["a"]}), &ctx()).unwrap();
  assert_eq!(out["tags"], json!(["a"]));
}

#[test]
fn nested_object_path_in_error() {
  let schema = s::object(vec![("author".into(), s::object(vec![("name".into(), s::string().min(1).boxed())]).boxed())]);
  let err = schema.parse(&json!({"author": {"name": ""}}), &ctx()).unwrap_err();
  assert_eq!(err.path, "author.name");
}

#[test]
fn enum_and_literal() {
  let schema = s::enum_(vec![json!("draft"), json!("published")]);
  assert_eq!(schema.parse(&json!("draft"), &ctx()).unwrap(), json!("draft"));
  assert!(schema.parse(&json!("other"), &ctx()).is_err());

  let schema = s::literal(json!(42));
  assert_eq!(schema.parse(&json!(42), &ctx()).unwrap(), json!(42));
  assert!(schema.parse(&json!(43), &ctx()).is_err());
}

#[test]
fn refine_and_transform() {
  let schema = s::transform(
    s::refine(s::string().boxed(), |v| {
      if v.as_str().unwrap().contains(' ') { Err("must not contain space".into()) } else { Ok(()) }
    })
    .boxed(),
    |v| serde_json::Value::String(v.as_str().unwrap().to_uppercase()),
  );
  assert_eq!(schema.parse(&json!("hello"), &ctx()).unwrap(), json!("HELLO"));
  assert!(schema.parse(&json!("a b"), &ctx()).is_err());
}

#[test]
fn string_regex_with_lookbehind() {
  // fancy-regex supports lookbehind; the `regex` crate does not.
  let schema = s::string().regex("(?<=foo)bar");
  assert!(schema.parse(&json!("foobar"), &ctx()).is_ok());
  assert!(schema.parse(&json!("bazbar"), &ctx()).is_err());
}

#[test]
fn isodate_validates() {
  let schema = s::isodate();
  assert_eq!(schema.parse(&json!("2024-01-01"), &ctx()).unwrap(), json!("2024-01-01"),);
  assert_eq!(schema.parse(&json!("2024-01-01T12:34:56Z"), &ctx()).unwrap(), json!("2024-01-01T12:34:56Z"),);
  assert!(schema.parse(&json!("not-a-date"), &ctx()).is_err());
}

#[test]
fn slug_kebab_check() {
  let schema = s::slug();
  assert_eq!(schema.parse(&json!("my-post"), &ctx()).unwrap(), json!("my-post"));
  let ctx2 = ctx();
  schema.parse(&json!("my-post"), &ctx2).unwrap();
  assert!(schema.parse(&json!("my-post"), &ctx2).is_err());
  assert!(schema.parse(&json!("MyPost"), &ctx()).is_err());
  assert!(schema.parse(&json!("my--post"), &ctx()).is_err());
  assert!(schema.parse(&json!("ab"), &ctx()).is_err());
}

#[test]
fn unique_dedupes() {
  let schema = s::unique().by("posts");
  let ctx2 = ctx();
  schema.parse(&json!("hello"), &ctx2).unwrap();
  assert!(schema.parse(&json!("hello"), &ctx2).is_err());
  let other = s::unique().by("authors");
  other.parse(&json!("hello"), &ctx2).unwrap();
}

#[test]
fn metadata_and_excerpt_use_ctx() {
  use dmc_schema::Ctx;
  let mut c = Ctx::empty();
  // `metadata` reads `ctx.body` (velite parity); `excerpt` reads `plain_text`.
  c.body = "alpha beta gamma delta epsilon zeta".repeat(20);
  c.plain_text = Some("alpha beta gamma delta epsilon zeta".repeat(20));

  let m = s::metadata().parse(&json!(null), &c).unwrap();
  assert!(m["wordCount"].as_u64().unwrap() >= 100);
  assert!(m["readingTime"].as_u64().unwrap() >= 1);

  let e = s::excerpt().length(20).parse(&json!(null), &c).unwrap();
  assert!(e.as_str().unwrap().ends_with("..."));
}