haematite-cli 0.21.3

The haematite command: serve a store, its door and Studio, answer MCP, query with jq, and host the store's assistant seat
Documentation
//! Collection shapes are records, checked with a declared JSON Schema subset.
//!
//! The established validator supplies JSON Schema semantics, including numeric
//! equality. The service refuses unsupported keywords before compilation and
//! has no HTTP or file-reference resolver. A shape never transforms a document.

use std::fmt;

use jsonschema::error::ValidationErrorKind;
use jsonschema::{Keyword, ValidationError, Validator};
use serde_json::Value;

/// Supported nesting bound, independent of the transport frame limit.
pub const MAX_SCHEMA_DEPTH: usize = 64;

/// A refusal naming a JSON Pointer in the shape or document being checked.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Violation {
    /// JSON Pointer; an empty string names the root.
    pub path: String,
    /// Explanation suitable for field errors and structured client responses.
    pub reason: String,
    kind: ViolationKind,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ViolationKind {
    Schema,
    JsonText,
}

impl Violation {
    #[must_use]
    pub const fn code(&self) -> &'static str {
        match self.kind {
            ViolationKind::Schema => "schema_refused",
            ViolationKind::JsonText => "json_text_invalid",
        }
    }
}

impl fmt::Display for Violation {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(formatter, "{}: {}", self.path, self.reason)
    }
}

impl std::error::Error for Violation {}

use self::Collection as CollectionSchema;

/// A checked collection shape. Persist source JSON, not this runtime cache.
#[derive(Debug)]
pub struct Collection {
    validator: Validator,
}

impl CollectionSchema {
    /// Compile when the stored shape version changes, refusing unsupported
    /// constraints, malformed shapes and external references.
    ///
    /// # Errors
    ///
    /// Propagates any error from `check_keywords`.
    pub fn compile(schema: &Value) -> Result<Self, Violation> {
        check_keywords(schema, "", 0)?;
        jsonschema::draft202012::meta::validate(schema).map_err(|error| violation(&error))?;
        let validator = jsonschema::options()
            .with_draft(jsonschema::Draft::Draft202012)
            .with_keyword("contentMediaType", |_, _, _| Ok(Box::new(JsonText)))
            .build(schema)
            .map_err(|error| violation(&error))?;
        Ok(Self { validator })
    }

    /// Check without changing values, adding defaults or dropping extras.
    ///
    /// # Errors
    ///
    /// Returns `Violation` if `validate` or `violation` fails.
    pub fn validate(&self, document: &Value) -> Result<(), Violation> {
        self.validator
            .validate(document)
            .map_err(|error| violation(&error))
    }
}

/// A collection with no shape accepts any well-formed JSON document.
///
/// # Errors
///
/// Returns `Violation` if `validate` fails.
pub fn validate(shape: Option<&CollectionSchema>, document: &Value) -> Result<(), Violation> {
    shape.map_or(Ok(()), |shape| shape.validate(document))
}

struct JsonText;

impl<'i> Keyword<'i> for JsonText {
    fn validate(&self, instance: &'i Value) -> Result<(), ValidationError<'i>> {
        let Some(text) = instance.as_str() else {
            return Ok(());
        };
        serde_json::from_str::<Value>(text)
            .map(|_| ())
            .map_err(|error| ValidationError::custom(format!("value is not JSON: {error}")))
    }

    fn is_valid(&self, instance: &'i Value) -> bool {
        self.validate(instance).is_ok()
    }
}

fn violation(error: &ValidationError<'_>) -> Violation {
    let mut path = error.instance_path().to_string();
    if let ValidationErrorKind::Required { property } = error.kind()
        && let Some(name) = property.as_str()
    {
        path = pointer(&path, name);
    }
    let kind = match error.kind() {
        ValidationErrorKind::Custom { keyword, .. } if keyword == "contentMediaType" => {
            ViolationKind::JsonText
        }
        _ => ViolationKind::Schema,
    };
    Violation {
        path,
        reason: error.to_string(),
        kind,
    }
}

fn refusal(path: &str, reason: impl Into<String>) -> Violation {
    Violation {
        path: path.to_owned(),
        reason: reason.into(),
        kind: ViolationKind::Schema,
    }
}

fn pointer(path: &str, segment: &str) -> String {
    format!("{path}/{}", segment.replace('~', "~0").replace('/', "~1"))
}

fn check_keywords(schema: &Value, path: &str, depth: usize) -> Result<(), Violation> {
    if depth > MAX_SCHEMA_DEPTH {
        return Err(refusal(path, "schema nesting exceeds 64 levels"));
    }
    if schema.is_boolean() {
        return Ok(());
    }
    let fields = schema
        .as_object()
        .ok_or_else(|| refusal(path, "schema must be an object or boolean"))?;
    for (keyword, value) in fields {
        let at = pointer(path, keyword);
        match keyword.as_str() {
            "type" | "required" | "enum" | "const" | "minimum" | "maximum" | "title"
            | "description" | "default" | "examples" | "$comment" => {}
            "contentMediaType" => {
                if value.as_str() != Some("application/json") {
                    return Err(refusal(
                        &at,
                        "unsupported contentMediaType: only application/json is supported",
                    ));
                }
                if fields.get("type").and_then(Value::as_str) != Some("string") {
                    return Err(refusal(
                        &at,
                        "contentMediaType is supported only beside type string",
                    ));
                }
            }
            "$schema" => {
                if value.as_str() != Some("https://json-schema.org/draft/2020-12/schema") {
                    return Err(refusal(
                        &at,
                        "only the declared draft 2020-12 subset is supported",
                    ));
                }
            }
            "properties" => {
                let properties = value
                    .as_object()
                    .ok_or_else(|| refusal(&at, "properties must be an object"))?;
                for (name, child) in properties {
                    check_keywords(child, &pointer(&at, name), depth + 1)?;
                }
            }
            "items" | "additionalProperties" => check_keywords(value, &at, depth + 1)?,
            _ => {
                return Err(refusal(
                    &at,
                    format!("unsupported schema keyword {keyword}"),
                ));
            }
        }
    }
    Ok(())
}

#[cfg(test)]
#[path = "schema_json_text_tests.rs"]
mod tests;