Skip to main content

zenkey_fleet/model/
jsonschema.rs

1//! The bit of JSON Schema this crate has to walk itself (#384).
2//!
3//! Two consumers read a served schema document structurally rather than
4//! handing it to the validator: [`crate::judge::field`]'s declared-path
5//! surface, and [`crate::tape::synth`]'s instance synthesizer. Both hit the
6//! same two shapes, because both are handed whatever `schemars` emits —
7//! combinators (`oneOf`/`anyOf`/`allOf`, which is how a Rust sum type
8//! reaches the wire) and `$ref` into `$defs` (which is where every nested
9//! named type goes).
10//!
11//! Neither walker is a JSON Schema implementation and neither should become
12//! one — validation is `zenkey::schema::validate`'s job, against the real
13//! `jsonschema` crate. What lives here is only the pointer resolution both
14//! walkers need, kept in one place so they cannot disagree about what a
15//! `$ref` means.
16
17use serde_json::Value;
18
19/// The combinator keywords whose branches a structural walk must consider.
20/// `allOf` composes, `oneOf`/`anyOf` alternate; for both the *union* of the
21/// branches is what the document declares.
22pub const COMBINATORS: [&str; 3] = ["oneOf", "anyOf", "allOf"];
23
24/// Resolve a same-document JSON Pointer `$ref` (RFC 6901) against the root.
25///
26/// Only same-document pointers resolve — `#`, `#/$defs/X`, `#/definitions/X`.
27/// An external `$ref` names a document the walker was never handed: the
28/// served schema is one payload (RFC 08 §7), and inventing a fetch for it
29/// would put a network call inside a pure walk.
30///
31/// `None` means **could not follow**, which every caller must render as
32/// "unjudgeable" rather than "absent" — a `$ref` that does not resolve says
33/// nothing about what is underneath it (RFC 13 §3 O4).
34pub fn resolve_ref<'d>(root: &'d Value, pointer: &str) -> Option<&'d Value> {
35    let rest = pointer.strip_prefix('#')?;
36    if rest.is_empty() {
37        return Some(root);
38    }
39    let mut node = root;
40    for token in rest.strip_prefix('/')?.split('/') {
41        // `~1` before `~0`, per RFC 6901 §3 — the other order turns an
42        // escaped tilde into a slash.
43        let token = token.replace("~1", "/").replace("~0", "~");
44        node = match node {
45            Value::Object(map) => map.get(&token)?,
46            Value::Array(items) => items.get(token.parse::<usize>().ok()?)?,
47            _ => return None,
48        };
49    }
50    Some(node)
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56    use serde_json::json;
57
58    #[test]
59    fn pointers_resolve_and_unfollowable_ones_say_so() {
60        let root = json!({
61            "$defs": {"A": {"type": "string"}, "a/b": {"type": "number"}, "c~d": {"x": 1}},
62            "arr": [{"first": true}],
63        });
64        assert_eq!(resolve_ref(&root, "#"), Some(&root), "the root itself");
65        assert_eq!(
66            resolve_ref(&root, "#/$defs/A"),
67            Some(&json!({"type": "string"}))
68        );
69        assert_eq!(
70            resolve_ref(&root, "#/$defs/a~1b"),
71            Some(&json!({"type": "number"})),
72            "~1 is an escaped slash, not a path separator"
73        );
74        assert_eq!(resolve_ref(&root, "#/$defs/c~0d"), Some(&json!({"x": 1})));
75        assert_eq!(
76            resolve_ref(&root, "#/arr/0"),
77            Some(&json!({"first": true})),
78            "array indices are pointer tokens too"
79        );
80
81        for unfollowable in [
82            "#/$defs/Missing",              // dangling
83            "https://example.invalid/s#/A", // external document
84            "$defs/A",                      // not a fragment
85            "#/$defs/A/nope",               // through a scalar
86            "#/arr/9",                      // past the end
87        ] {
88            assert_eq!(
89                resolve_ref(&root, unfollowable),
90                None,
91                "{unfollowable} must read as could-not-follow"
92            );
93        }
94    }
95}