1use serde::Serialize;
14
15use super::trace::ContextSnapshot;
16
17#[derive(Debug, Clone, Copy, Serialize, PartialEq)]
19#[serde(rename_all = "snake_case")]
20pub enum ChangeKind {
21 Added,
22 Removed,
23 Modified,
24}
25
26#[derive(Debug, Clone, Serialize, PartialEq)]
28pub struct Change {
29 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
65pub 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 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 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
183fn 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 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 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 #[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}