Skip to main content

chia_query/
drift.rs

1//! coinset.org API drift detection.
2//!
3//! coinset.org publishes no OpenAPI schema, so the only way to notice a
4//! breaking API change is to watch it. This module reduces a live JSON
5//! response to its *shape* — the set of keys and the JSON type of each value,
6//! with all concrete values discarded — and diffs that shape against a
7//! committed baseline snapshot.
8//!
9//! Values are dropped on purpose: a block's `difficulty` or a mempool's
10//! `size` changes every block, but its *shape* (the key `difficulty` holding
11//! an integer) is the actual contract chia-query depends on. Drift in the
12//! shape — a renamed key, a removed field, a type flip — is what breaks a
13//! consumer, and is what this detects.
14
15use serde_json::{Map, Value};
16
17/// Reduce a JSON value to its type-shape: objects keep their keys (each mapped
18/// to the shape of its value), arrays collapse to a single-element shape, and
19/// every scalar becomes a type tag (`"string"`, `"integer"`, `"number"`,
20/// `"boolean"`, `"null"`).
21///
22/// The result is itself JSON so it serializes cleanly into the committed
23/// snapshot and diffs with the same machinery.
24pub fn shape_of(value: &Value) -> Value {
25    match value {
26        Value::Object(map) => {
27            let shaped: Map<String, Value> = map
28                .iter()
29                .map(|(key, val)| (key.clone(), shape_of(val)))
30                .collect();
31            Value::Object(shaped)
32        }
33        // An array's shape is the shape of its elements. We assume homogeneity
34        // (true for every coinset list endpoint) and sample the first element;
35        // an empty array carries no element shape.
36        Value::Array(items) => match items.first() {
37            Some(first) => Value::Array(vec![shape_of(first)]),
38            None => Value::Array(vec![]),
39        },
40        Value::String(_) => Value::String("string".into()),
41        Value::Number(n) => {
42            let tag = if n.is_i64() || n.is_u64() {
43                "integer"
44            } else {
45                "number"
46            };
47            Value::String(tag.into())
48        }
49        Value::Bool(_) => Value::String("boolean".into()),
50        Value::Null => Value::String("null".into()),
51    }
52}
53
54/// Compare a `current` shape against the `baseline` shape and return one
55/// human-readable line per drift found. An empty vec means the shapes match.
56///
57/// `path` is the dotted JSON path used to locate each drift (e.g.
58/// `blockchain_state.difficulty`); callers pass the endpoint name as the root.
59pub fn diff_shapes(path: &str, baseline: &Value, current: &Value) -> Vec<String> {
60    match (baseline, current) {
61        (Value::Object(base), Value::Object(cur)) => diff_objects(path, base, cur),
62        (Value::Array(base), Value::Array(cur)) => match (base.first(), cur.first()) {
63            (Some(b), Some(c)) => diff_shapes(&format!("{path}[]"), b, c),
64            // One side is an empty array: no element shape to compare. This is
65            // not drift — an endpoint can legitimately return an empty list.
66            _ => Vec::new(),
67        },
68        (Value::String(base_tag), Value::String(cur_tag)) if base_tag != cur_tag => {
69            vec![format!("{path}: type changed {base_tag} -> {cur_tag}")]
70        }
71        (Value::String(_), Value::String(_)) => Vec::new(),
72        _ => vec![format!(
73            "{path}: structure changed {} -> {}",
74            kind(baseline),
75            kind(current)
76        )],
77    }
78}
79
80/// Diff two shaped objects: report keys that vanished, keys that appeared, and
81/// recurse into keys present in both.
82fn diff_objects(path: &str, base: &Map<String, Value>, cur: &Map<String, Value>) -> Vec<String> {
83    let mut drifts = Vec::new();
84
85    for (key, base_val) in base {
86        let child = join(path, key);
87        match cur.get(key) {
88            Some(cur_val) => drifts.extend(diff_shapes(&child, base_val, cur_val)),
89            None => drifts.push(format!("{child}: key removed")),
90        }
91    }
92    for key in cur.keys() {
93        if !base.contains_key(key) {
94            drifts.push(format!("{}: key added", join(path, key)));
95        }
96    }
97
98    drifts
99}
100
101/// The coarse structural kind of a shape node, for drift messages.
102fn kind(value: &Value) -> &'static str {
103    match value {
104        Value::Object(_) => "object",
105        Value::Array(_) => "array",
106        _ => "scalar",
107    }
108}
109
110/// Join a dotted JSON path with a child key, handling the empty root.
111fn join(path: &str, key: &str) -> String {
112    if path.is_empty() {
113        key.to_string()
114    } else {
115        format!("{path}.{key}")
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use serde_json::json;
123
124    #[test]
125    fn shape_discards_scalar_values_but_keeps_keys_and_types() {
126        let response = json!({
127            "difficulty": 2656,
128            "average_block_time": 19.5,
129            "network_name": "mainnet",
130            "initialized": true,
131            "node_id": null
132        });
133        assert_eq!(
134            shape_of(&response),
135            json!({
136                "difficulty": "integer",
137                "average_block_time": "number",
138                "network_name": "string",
139                "initialized": "boolean",
140                "node_id": "null"
141            })
142        );
143    }
144
145    #[test]
146    fn shape_samples_array_element() {
147        let response = json!({ "coin_records": [{ "amount": 100 }, { "amount": 200 }] });
148        assert_eq!(
149            shape_of(&response),
150            json!({ "coin_records": [{ "amount": "integer" }] })
151        );
152    }
153
154    #[test]
155    fn identical_shapes_show_no_drift() {
156        let a = shape_of(&json!({ "blockchain_state": { "difficulty": 100 } }));
157        let b = shape_of(&json!({ "blockchain_state": { "difficulty": 999 } }));
158        assert!(diff_shapes("get_blockchain_state", &a, &b).is_empty());
159    }
160
161    #[test]
162    fn detects_removed_key() {
163        let base = shape_of(&json!({ "a": 1, "b": 2 }));
164        let cur = shape_of(&json!({ "a": 1 }));
165        let drift = diff_shapes("ep", &base, &cur);
166        assert_eq!(drift, vec!["ep.b: key removed"]);
167    }
168
169    #[test]
170    fn detects_added_key() {
171        let base = shape_of(&json!({ "a": 1 }));
172        let cur = shape_of(&json!({ "a": 1, "c": 3 }));
173        let drift = diff_shapes("ep", &base, &cur);
174        assert_eq!(drift, vec!["ep.c: key added"]);
175    }
176
177    #[test]
178    fn detects_type_flip() {
179        let base = shape_of(&json!({ "amount": 100 }));
180        let cur = shape_of(&json!({ "amount": "100" }));
181        let drift = diff_shapes("ep", &base, &cur);
182        assert_eq!(drift, vec!["ep.amount: type changed integer -> string"]);
183    }
184
185    #[test]
186    fn detects_structure_change() {
187        let base = shape_of(&json!({ "x": { "y": 1 } }));
188        let cur = shape_of(&json!({ "x": 1 }));
189        let drift = diff_shapes("ep", &base, &cur);
190        assert_eq!(drift, vec!["ep.x: structure changed object -> scalar"]);
191    }
192
193    #[test]
194    fn recurses_into_arrays() {
195        let base = shape_of(&json!({ "items": [{ "amount": 1 }] }));
196        let cur = shape_of(&json!({ "items": [{ "amount": "1" }] }));
197        let drift = diff_shapes("ep", &base, &cur);
198        assert_eq!(
199            drift,
200            vec!["ep.items[].amount: type changed integer -> string"]
201        );
202    }
203
204    #[test]
205    fn empty_array_is_not_drift() {
206        let base = shape_of(&json!({ "items": [{ "amount": 1 }] }));
207        let cur = shape_of(&json!({ "items": [] }));
208        assert!(diff_shapes("ep", &base, &cur).is_empty());
209    }
210
211    /// A deliberately-mutated snapshot MUST be detected as drift — the guard
212    /// the CI drift-monitor relies on (unit 2 acceptance).
213    #[test]
214    fn mutated_snapshot_is_detected() {
215        let live = shape_of(&json!({
216            "get_network_info": { "network_name": "mainnet", "network_prefix": "xch", "success": true }
217        }));
218        let mut mutated = live.clone();
219        // coinset renames a field: network_prefix -> address_prefix.
220        let obj = mutated["get_network_info"].as_object_mut().unwrap();
221        let tag = obj.remove("network_prefix").unwrap();
222        obj.insert("address_prefix".into(), tag);
223
224        let drift = diff_shapes("", &live, &mutated);
225        assert!(!drift.is_empty());
226        assert!(drift
227            .iter()
228            .any(|d| d.contains("network_prefix: key removed")));
229        assert!(drift
230            .iter()
231            .any(|d| d.contains("address_prefix: key added")));
232    }
233}