Skip to main content

zenkey/schema/
validate.rs

1//! Payload conformance verdicts (#159) — the three-state answer to "does
2//! this payload conform to its declared schema?".
3//!
4//! Three states, never a boolean, for the same reason
5//! [`Registration`](https://docs.rs/zenkey-fleet) has four: "I did not
6//! check" must never render like "I checked and it passed". The
7//! [`Verdict`] rides every [`DecodedPayload`](super::decode::DecodedPayload),
8//! so every consumer of a decode sees the same answer.
9//!
10//! What "valid" means is kind-dependent, and honestly so:
11//!
12//! - `json-schema` — real draft 2020-12 validation (feature `validate-json`;
13//!   without it the verdict is [`NotValidated::FeatureOff`]). This is the
14//!   thick tier: required fields, types, enums, bounds.
15//! - `protobuf` / `cdr` — a successful decode already proves structural
16//!   conformance to the served descriptor / field list; that decode **is**
17//!   the check, and its `Valid` is the thinner claim. There is no schema
18//!   language underneath to violate while still decoding.
19
20/// Did the payload conform to its declared schema?
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum Verdict {
23    /// Checked and conformant (see the module doc for the per-kind depth).
24    Valid,
25    /// Checked and non-conformant; each violation is one human-readable
26    /// sentence with its instance path.
27    Invalid(Vec<String>),
28    /// Not checked — and here is why. Never collapse this into either answer.
29    NotValidated(NotValidated),
30}
31
32/// Why a payload was not validated. A reason is not a failure: most of these
33/// are ordinary states of a live bus (O4 — "not asked" is not "no").
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum NotValidated {
36    /// A registry was consulted and no schema is served/known for the
37    /// observed type. This is "asked, and the answer was silence about the
38    /// type" — never the same fact as [`NoRegistry`](Self::NoRegistry)'s
39    /// "nobody looked" (RFC 09 §5.1 O4; #246).
40    NoSchema,
41    /// No registry was loaded, so no type was ever looked up. "Not asked"
42    /// must not masquerade as a fact about the type (RFC 09 §5.1 O4): a run
43    /// whose registry was merely unreachable used to emit
44    /// [`NoSchema`](Self::NoSchema) on every row, which reads as a claim
45    /// about the *types* (#246).
46    NoRegistry,
47    /// The `validate-json` feature is compiled out of this binary.
48    FeatureOff,
49    /// The schema kind has no validator beyond its own decode.
50    KindUnsupported,
51    /// The bytes did not decode, so conformance was never reachable.
52    Undecodable,
53    /// The schema document itself did not compile as a schema.
54    BadSchema,
55}
56
57impl std::fmt::Display for NotValidated {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        f.write_str(match self {
60            NotValidated::NoSchema => "no schema served for this type",
61            NotValidated::NoRegistry => "no registry loaded, so no type was looked up",
62            NotValidated::FeatureOff => "validation compiled out (validate-json)",
63            NotValidated::KindUnsupported => "schema kind has no validator beyond decode",
64            NotValidated::Undecodable => "bytes did not decode",
65            NotValidated::BadSchema => "served schema does not compile",
66        })
67    }
68}
69
70impl std::fmt::Display for Verdict {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        match self {
73            Verdict::Valid => f.write_str("valid"),
74            Verdict::Invalid(errors) => write!(f, "invalid ({} violation(s))", errors.len()),
75            Verdict::NotValidated(reason) => write!(f, "not validated — {reason}"),
76        }
77    }
78}
79
80/// Validate a decoded JSON value against a `json-schema` document.
81///
82/// Compiled per call site's cache — callers hold the compiled validator via
83/// [`super::compiled::CompiledCache`], keyed by schema hash, exactly like the
84/// protobuf descriptor pools (#100): compiling per sample would put a schema
85/// compile on every echo line.
86#[cfg(feature = "validate-json")]
87pub fn validate_json(validator: &jsonschema::Validator, value: &serde_json::Value) -> Verdict {
88    let errors: Vec<String> = validator
89        .iter_errors(value)
90        .map(|e| {
91            let path = e.instance_path().to_string();
92            if path.is_empty() {
93                e.to_string()
94            } else {
95                format!("{path}: {e}")
96            }
97        })
98        .collect();
99    if errors.is_empty() {
100        Verdict::Valid
101    } else {
102        Verdict::Invalid(errors)
103    }
104}
105
106#[cfg(test)]
107mod vocabulary_tests {
108    use super::*;
109
110    /// The reason strings are wire vocabulary: every ndjson `verdict` field
111    /// carries `not-validated: <reason>` verbatim, so a consumer greps them.
112    /// In particular the two silences must never share a spelling —
113    /// "no registry loaded" is "not asked", "no schema served" is "asked,
114    /// and the type has none" (RFC 09 §5.1 O4; #246).
115    #[test]
116    fn the_two_silences_have_distinct_wire_spellings() {
117        assert_eq!(
118            NotValidated::NoSchema.to_string(),
119            "no schema served for this type"
120        );
121        assert_eq!(
122            NotValidated::NoRegistry.to_string(),
123            "no registry loaded, so no type was looked up"
124        );
125    }
126}
127
128#[cfg(all(test, feature = "validate-json"))]
129mod tests {
130    use super::*;
131    use serde_json::json;
132
133    fn validator() -> jsonschema::Validator {
134        jsonschema::validator_for(&json!({
135            "type": "object",
136            "required": ["x"],
137            "properties": {
138                "x": { "type": "integer", "minimum": 0 },
139                "name": { "type": "string" },
140            },
141        }))
142        .expect("fixture schema compiles")
143    }
144
145    #[test]
146    fn conformant_and_nonconformant_values_get_opposite_verdicts() {
147        let v = validator();
148        assert_eq!(validate_json(&v, &json!({"x": 3})), Verdict::Valid);
149        match validate_json(&v, &json!({"x": -2, "name": 7})) {
150            Verdict::Invalid(errors) => {
151                assert_eq!(errors.len(), 2, "{errors:?}");
152                assert!(errors.iter().any(|e| e.contains("/x")), "{errors:?}");
153                assert!(errors.iter().any(|e| e.contains("/name")), "{errors:?}");
154            }
155            other => panic!("expected Invalid, got {other:?}"),
156        }
157        // A missing required field is a violation, not a shrug.
158        assert!(matches!(validate_json(&v, &json!({})), Verdict::Invalid(_)));
159    }
160}