Skip to main content

featherbit/debug/
diff.rs

1//! What a plugin changed, derived from two consecutive snapshots.
2//!
3//! This is computed at **read time** (when a trace is fetched), not while
4//! recording. Two reasons: the traced request path stays as cheap as possible,
5//! and the comparison stays a pure function with no engine coupling — trivially
6//! unit-testable in isolation.
7//!
8//! The comparison is purpose-built rather than a generic JSON differ: it knows
9//! that headers are name → values maps and that `errors` is append-only, so it
10//! can emit paths a policy author recognises (`request.headers.x-userinfo`,
11//! `message.user_id`) instead of structural noise.
12
13use serde::Serialize;
14
15use super::trace::ContextSnapshot;
16
17/// How a field changed between two steps.
18#[derive(Debug, Clone, Copy, Serialize, PartialEq)]
19#[serde(rename_all = "snake_case")]
20pub enum ChangeKind {
21    Added,
22    Removed,
23    Modified,
24}
25
26/// One field-level difference.
27#[derive(Debug, Clone, Serialize, PartialEq)]
28pub struct Change {
29    /// Dotted path, e.g. `request.headers.x-userinfo` or `response.status_code`.
30    pub path: String,
31    pub kind: ChangeKind,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub before: Option<String>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub after: Option<String>,
36}
37
38impl Change {
39    fn added(path: String, after: String) -> Self {
40        Self {
41            path,
42            kind: ChangeKind::Added,
43            before: None,
44            after: Some(after),
45        }
46    }
47    fn removed(path: String, before: String) -> Self {
48        Self {
49            path,
50            kind: ChangeKind::Removed,
51            before: Some(before),
52            after: None,
53        }
54    }
55    fn modified(path: String, before: String, after: String) -> Self {
56        Self {
57            path,
58            kind: ChangeKind::Modified,
59            before: Some(before),
60            after: Some(after),
61        }
62    }
63}
64
65/// Computes what changed between two consecutive snapshots.
66///
67/// Ordering is deterministic (request scalars, request headers, query params,
68/// request body, response, message, errors — each map walked in `BTreeMap`
69/// order), so the UI and tests see a stable list.
70pub fn diff(prev: &ContextSnapshot, next: &ContextSnapshot) -> Vec<Change> {
71    let mut out = Vec::new();
72
73    scalar(
74        &mut out,
75        "request.method",
76        &prev.request.method,
77        &next.request.method,
78    );
79    scalar(
80        &mut out,
81        "request.path",
82        &prev.request.path,
83        &next.request.path,
84    );
85    scalar(
86        &mut out,
87        "request.host",
88        &prev.request.host,
89        &next.request.host,
90    );
91    scalar(
92        &mut out,
93        "request.scheme",
94        &prev.request.scheme,
95        &next.request.scheme,
96    );
97
98    multi_map(
99        &mut out,
100        "request.headers",
101        &prev.request.headers,
102        &next.request.headers,
103    );
104    multi_map(
105        &mut out,
106        "request.query_params",
107        &prev.request.query_params,
108        &next.request.query_params,
109    );
110    if prev.request.body.len != next.request.body.len {
111        out.push(Change::modified(
112            "request.body".to_string(),
113            format!("{} bytes", prev.request.body.len),
114            format!("{} bytes", next.request.body.len),
115        ));
116    }
117
118    if prev.response.status_code != next.response.status_code {
119        out.push(Change::modified(
120            "response.status_code".to_string(),
121            prev.response.status_code.to_string(),
122            next.response.status_code.to_string(),
123        ));
124    }
125    multi_map(
126        &mut out,
127        "response.headers",
128        &prev.response.headers,
129        &next.response.headers,
130    );
131    if prev.response.body.len != next.response.body.len {
132        out.push(Change::modified(
133            "response.body".to_string(),
134            format!("{} bytes", prev.response.body.len),
135            format!("{} bytes", next.response.body.len),
136        ));
137    }
138
139    // `message` values are arbitrary JSON; render them compactly.
140    for (k, v) in &next.message {
141        match prev.message.get(k) {
142            None => out.push(Change::added(format!("message.{k}"), compact(v))),
143            Some(old) if old != v => out.push(Change::modified(
144                format!("message.{k}"),
145                compact(old),
146                compact(v),
147            )),
148            Some(_) => {}
149        }
150    }
151    for k in prev.message.keys() {
152        if !next.message.contains_key(k) {
153            out.push(Change::removed(
154                format!("message.{k}"),
155                compact(&prev.message[k]),
156            ));
157        }
158    }
159
160    // `errors` is append-only in the engine, so report the new entries.
161    if next.errors.len() > prev.errors.len() {
162        for (i, e) in next.errors.iter().enumerate().skip(prev.errors.len()) {
163            out.push(Change::added(
164                format!("errors[{i}]"),
165                format!("{}: {}", e.code, e.message),
166            ));
167        }
168    }
169
170    out
171}
172
173fn scalar(out: &mut Vec<Change>, path: &str, prev: &str, next: &str) {
174    if prev != next {
175        out.push(Change::modified(
176            path.to_string(),
177            prev.to_string(),
178            next.to_string(),
179        ));
180    }
181}
182
183/// Compares a `name -> values` map, reporting per-name adds/removes/modifies.
184fn multi_map(
185    out: &mut Vec<Change>,
186    prefix: &str,
187    prev: &std::collections::BTreeMap<String, Vec<String>>,
188    next: &std::collections::BTreeMap<String, Vec<String>>,
189) {
190    for (k, v) in next {
191        match prev.get(k) {
192            None => out.push(Change::added(format!("{prefix}.{k}"), v.join(", "))),
193            Some(old) if old != v => out.push(Change::modified(
194                format!("{prefix}.{k}"),
195                old.join(", "),
196                v.join(", "),
197            )),
198            Some(_) => {}
199        }
200    }
201    for (k, v) in prev {
202        if !next.contains_key(k) {
203            out.push(Change::removed(format!("{prefix}.{k}"), v.join(", ")));
204        }
205    }
206}
207
208fn compact(v: &serde_json::Value) -> String {
209    match v {
210        serde_json::Value::String(s) => s.clone(),
211        other => other.to_string(),
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use crate::context::{Context, GatewayError, GatewayRequest, GatewayResponse, Protocol};
219    use crate::debug::trace::{CaptureOptions, PreviousBodies};
220    use bytes::Bytes;
221    use std::collections::HashMap;
222
223    fn base() -> Context {
224        Context {
225            request: GatewayRequest {
226                method: "GET".to_string(),
227                path: "/api/hello".to_string(),
228                host: "h".to_string(),
229                scheme: "http".to_string(),
230                headers: HashMap::new(),
231                query_params: HashMap::new(),
232                body: Bytes::new(),
233                remote_addr: "1.2.3.4:5".to_string(),
234                protocol: Protocol::Http1,
235            },
236            response: GatewayResponse {
237                status_code: 0,
238                headers: HashMap::new(),
239                body: Bytes::new(),
240            },
241            message: HashMap::new(),
242            errors: Vec::new(),
243        }
244    }
245
246    fn snap(c: &Context) -> ContextSnapshot {
247        ContextSnapshot::capture(c, &CaptureOptions::default(), &PreviousBodies::default())
248    }
249
250    fn find<'a>(changes: &'a [Change], path: &str) -> &'a Change {
251        changes
252            .iter()
253            .find(|c| c.path == path)
254            .unwrap_or_else(|| panic!("no change at {path}; got {changes:?}"))
255    }
256
257    #[test]
258    fn test_identical_snapshots_have_no_changes() {
259        let c = base();
260        assert!(diff(&snap(&c), &snap(&c)).is_empty());
261    }
262
263    #[test]
264    fn test_path_rewrite_detected() {
265        let before = snap(&base());
266        let mut after_ctx = base();
267        after_ctx.request.path = "/hello".to_string();
268        let changes = diff(&before, &snap(&after_ctx));
269        let c = find(&changes, "request.path");
270        assert_eq!(c.kind, ChangeKind::Modified);
271        assert_eq!(c.before.as_deref(), Some("/api/hello"));
272        assert_eq!(c.after.as_deref(), Some("/hello"));
273    }
274
275    #[test]
276    fn test_header_added_modified_removed() {
277        let mut start = base();
278        start
279            .request
280            .headers
281            .insert("x-keep".to_string(), vec!["1".to_string()]);
282        start
283            .request
284            .headers
285            .insert("x-drop".to_string(), vec!["old".to_string()]);
286        let before = snap(&start);
287
288        let mut end = base();
289        end.request
290            .headers
291            .insert("x-keep".to_string(), vec!["2".to_string()]);
292        end.request
293            .headers
294            .insert("x-new".to_string(), vec!["fresh".to_string()]);
295        let changes = diff(&before, &snap(&end));
296
297        assert_eq!(
298            find(&changes, "request.headers.x-keep").kind,
299            ChangeKind::Modified
300        );
301        assert_eq!(
302            find(&changes, "request.headers.x-new").kind,
303            ChangeKind::Added
304        );
305        assert_eq!(
306            find(&changes, "request.headers.x-drop").kind,
307            ChangeKind::Removed
308        );
309    }
310
311    #[test]
312    fn test_status_and_response_header_change() {
313        let before = snap(&base());
314        let mut end = base();
315        end.response.status_code = 403;
316        end.response.headers.insert(
317            "access-control-allow-origin".to_string(),
318            vec!["*".to_string()],
319        );
320        let changes = diff(&before, &snap(&end));
321        assert_eq!(
322            find(&changes, "response.status_code").after.as_deref(),
323            Some("403")
324        );
325        assert_eq!(
326            find(&changes, "response.headers.access-control-allow-origin").kind,
327            ChangeKind::Added
328        );
329    }
330
331    #[test]
332    fn test_message_key_added() {
333        let before = snap(&base());
334        let mut end = base();
335        end.message
336            .insert("user_id".to_string(), serde_json::json!("alice"));
337        let changes = diff(&before, &snap(&end));
338        let c = find(&changes, "message.user_id");
339        assert_eq!(c.kind, ChangeKind::Added);
340        // Strings render bare, not JSON-quoted.
341        assert_eq!(c.after.as_deref(), Some("alice"));
342    }
343
344    #[test]
345    fn test_body_size_change_reported_without_capture() {
346        let before = snap(&base());
347        let mut end = base();
348        end.response.body = Bytes::from_static(b"hello");
349        let changes = diff(&before, &snap(&end));
350        // Bodies are not captured here, yet the size delta is still visible.
351        assert_eq!(
352            find(&changes, "response.body").after.as_deref(),
353            Some("5 bytes")
354        );
355    }
356
357    #[test]
358    fn test_appended_error_reported() {
359        let before = snap(&base());
360        let mut end = base();
361        end.errors.push(GatewayError {
362            node_id: "auth".to_string(),
363            code: "UNAUTHORIZED".to_string(),
364            message: "no token".to_string(),
365            metadata: HashMap::new(),
366        });
367        let changes = diff(&before, &snap(&end));
368        let c = find(&changes, "errors[0]");
369        assert_eq!(c.kind, ChangeKind::Added);
370        assert_eq!(c.after.as_deref(), Some("UNAUTHORIZED: no token"));
371    }
372
373    /// Deterministic ordering is what makes the UI stable and these tests
374    /// meaningful; it comes from the snapshot's BTreeMaps.
375    #[test]
376    fn test_change_order_is_deterministic() {
377        let before = snap(&base());
378        let mut end = base();
379        for name in ["x-c", "x-a", "x-b"] {
380            end.request
381                .headers
382                .insert(name.to_string(), vec!["v".to_string()]);
383        }
384        let first = diff(&before, &snap(&end));
385        let again = diff(&before, &snap(&end));
386        assert_eq!(first, again);
387        let paths: Vec<&str> = first.iter().map(|c| c.path.as_str()).collect();
388        assert_eq!(
389            paths,
390            vec![
391                "request.headers.x-a",
392                "request.headers.x-b",
393                "request.headers.x-c"
394            ]
395        );
396    }
397}