Skip to main content

haematite_cli/serve/
schema.rs

1//! Collection shapes are records, checked with a declared JSON Schema subset.
2//!
3//! The established validator supplies JSON Schema semantics, including numeric
4//! equality. The service refuses unsupported keywords before compilation and
5//! has no HTTP or file-reference resolver. A shape never transforms a document.
6
7use std::fmt;
8
9use jsonschema::error::ValidationErrorKind;
10use jsonschema::{Keyword, ValidationError, Validator};
11use serde_json::Value;
12
13/// Supported nesting bound, independent of the transport frame limit.
14pub const MAX_SCHEMA_DEPTH: usize = 64;
15
16/// A refusal naming a JSON Pointer in the shape or document being checked.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Violation {
19    /// JSON Pointer; an empty string names the root.
20    pub path: String,
21    /// Explanation suitable for field errors and structured client responses.
22    pub reason: String,
23    kind: ViolationKind,
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27enum ViolationKind {
28    Schema,
29    JsonText,
30}
31
32impl Violation {
33    #[must_use]
34    pub const fn code(&self) -> &'static str {
35        match self.kind {
36            ViolationKind::Schema => "schema_refused",
37            ViolationKind::JsonText => "json_text_invalid",
38        }
39    }
40}
41
42impl fmt::Display for Violation {
43    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
44        write!(formatter, "{}: {}", self.path, self.reason)
45    }
46}
47
48impl std::error::Error for Violation {}
49
50use self::Collection as CollectionSchema;
51
52/// A checked collection shape. Persist source JSON, not this runtime cache.
53#[derive(Debug)]
54pub struct Collection {
55    validator: Validator,
56}
57
58impl CollectionSchema {
59    /// Compile when the stored shape version changes, refusing unsupported
60    /// constraints, malformed shapes and external references.
61    ///
62    /// # Errors
63    ///
64    /// Propagates any error from `check_keywords`.
65    pub fn compile(schema: &Value) -> Result<Self, Violation> {
66        check_keywords(schema, "", 0)?;
67        jsonschema::draft202012::meta::validate(schema).map_err(|error| violation(&error))?;
68        let validator = jsonschema::options()
69            .with_draft(jsonschema::Draft::Draft202012)
70            .with_keyword("contentMediaType", |_, _, _| Ok(Box::new(JsonText)))
71            .build(schema)
72            .map_err(|error| violation(&error))?;
73        Ok(Self { validator })
74    }
75
76    /// Check without changing values, adding defaults or dropping extras.
77    ///
78    /// # Errors
79    ///
80    /// Returns `Violation` if `validate` or `violation` fails.
81    pub fn validate(&self, document: &Value) -> Result<(), Violation> {
82        self.validator
83            .validate(document)
84            .map_err(|error| violation(&error))
85    }
86}
87
88/// A collection with no shape accepts any well-formed JSON document.
89///
90/// # Errors
91///
92/// Returns `Violation` if `validate` fails.
93pub fn validate(shape: Option<&CollectionSchema>, document: &Value) -> Result<(), Violation> {
94    shape.map_or(Ok(()), |shape| shape.validate(document))
95}
96
97struct JsonText;
98
99impl<'i> Keyword<'i> for JsonText {
100    fn validate(&self, instance: &'i Value) -> Result<(), ValidationError<'i>> {
101        let Some(text) = instance.as_str() else {
102            return Ok(());
103        };
104        serde_json::from_str::<Value>(text)
105            .map(|_| ())
106            .map_err(|error| ValidationError::custom(format!("value is not JSON: {error}")))
107    }
108
109    fn is_valid(&self, instance: &'i Value) -> bool {
110        self.validate(instance).is_ok()
111    }
112}
113
114fn violation(error: &ValidationError<'_>) -> Violation {
115    let mut path = error.instance_path().to_string();
116    if let ValidationErrorKind::Required { property } = error.kind()
117        && let Some(name) = property.as_str()
118    {
119        path = pointer(&path, name);
120    }
121    let kind = match error.kind() {
122        ValidationErrorKind::Custom { keyword, .. } if keyword == "contentMediaType" => {
123            ViolationKind::JsonText
124        }
125        _ => ViolationKind::Schema,
126    };
127    Violation {
128        path,
129        reason: error.to_string(),
130        kind,
131    }
132}
133
134fn refusal(path: &str, reason: impl Into<String>) -> Violation {
135    Violation {
136        path: path.to_owned(),
137        reason: reason.into(),
138        kind: ViolationKind::Schema,
139    }
140}
141
142fn pointer(path: &str, segment: &str) -> String {
143    format!("{path}/{}", segment.replace('~', "~0").replace('/', "~1"))
144}
145
146fn check_keywords(schema: &Value, path: &str, depth: usize) -> Result<(), Violation> {
147    if depth > MAX_SCHEMA_DEPTH {
148        return Err(refusal(path, "schema nesting exceeds 64 levels"));
149    }
150    if schema.is_boolean() {
151        return Ok(());
152    }
153    let fields = schema
154        .as_object()
155        .ok_or_else(|| refusal(path, "schema must be an object or boolean"))?;
156    for (keyword, value) in fields {
157        let at = pointer(path, keyword);
158        match keyword.as_str() {
159            "type" | "required" | "enum" | "const" | "minimum" | "maximum" | "title"
160            | "description" | "default" | "examples" | "$comment" => {}
161            "contentMediaType" => {
162                if value.as_str() != Some("application/json") {
163                    return Err(refusal(
164                        &at,
165                        "unsupported contentMediaType: only application/json is supported",
166                    ));
167                }
168                if fields.get("type").and_then(Value::as_str) != Some("string") {
169                    return Err(refusal(
170                        &at,
171                        "contentMediaType is supported only beside type string",
172                    ));
173                }
174            }
175            "$schema" => {
176                if value.as_str() != Some("https://json-schema.org/draft/2020-12/schema") {
177                    return Err(refusal(
178                        &at,
179                        "only the declared draft 2020-12 subset is supported",
180                    ));
181                }
182            }
183            "properties" => {
184                let properties = value
185                    .as_object()
186                    .ok_or_else(|| refusal(&at, "properties must be an object"))?;
187                for (name, child) in properties {
188                    check_keywords(child, &pointer(&at, name), depth + 1)?;
189                }
190            }
191            "items" | "additionalProperties" => check_keywords(value, &at, depth + 1)?,
192            _ => {
193                return Err(refusal(
194                    &at,
195                    format!("unsupported schema keyword {keyword}"),
196                ));
197            }
198        }
199    }
200    Ok(())
201}
202
203#[cfg(test)]
204#[path = "schema_json_text_tests.rs"]
205mod tests;