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}