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}