Skip to main content

soothfast_spec/asyncapi/
diff.rs

1//! Compatibility diffing between two generated AsyncAPI documents.
2//!
3//! The direction that decides what "required" costs is the operation's own
4//! `action`, not a request/response split: a message this application
5//! **sends** is data a consumer reads, so dropping a guaranteed field breaks
6//! them; a message it **receives** is data a producer writes, so demanding a
7//! new field breaks them instead. That is the same asymmetry OpenAPI has
8//! between a response and a request body, read off a different key.
9
10use std::collections::BTreeMap;
11
12use serde_json::Value;
13
14use crate::compat::{Change, Direction, SchemaDiff, Severity, compare_keys, deref, sort};
15
16/// Compare two generated documents, oldest first.
17pub fn diff(old: &Value, new: &Value) -> Vec<Change> {
18    let mut changes = Vec::new();
19    let (old_ops, new_ops) = (operations(old), operations(new));
20
21    compare_keys(
22        &mut changes,
23        &old_ops,
24        &new_ops,
25        "operation",
26        |ch, at, o, n| {
27            let (oa, na) = (action(o), action(n));
28            if oa != na {
29                ch.push(Change::new(
30                    Severity::Breaking,
31                    at,
32                    format!("action {oa} -> {na}"),
33                ));
34                return; // The flow reversed; comparing payloads says nothing.
35            }
36
37            let (oc, nc) = (address(old, o), address(new, n));
38            if oc != nc {
39                ch.push(Change::new(
40                    Severity::Breaking,
41                    at,
42                    format!("channel address {oc} -> {nc}"),
43                ));
44            }
45
46            let dir = match na {
47                // We publish it: a consumer reads it.
48                "send" => Direction::Response,
49                _ => Direction::Request,
50            };
51            match (payload(old, o), payload(new, n)) {
52                (Some(op), Some(np)) => {
53                    SchemaDiff::new(old, new).compare(ch, &format!("{at} payload"), &op, &np, dir);
54                }
55                (Some(_), None) => ch.push(Change::new(
56                    Severity::Breaking,
57                    format!("{at} payload"),
58                    "message payload removed",
59                )),
60                (None, Some(_)) => ch.push(Change::new(
61                    Severity::Additive,
62                    format!("{at} payload"),
63                    "message payload added",
64                )),
65                (None, None) => {}
66            }
67        },
68    );
69
70    sort(&mut changes);
71    changes
72}
73
74fn operations(doc: &Value) -> BTreeMap<String, Value> {
75    doc["operations"]
76        .as_object()
77        .map(|ops| ops.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
78        .unwrap_or_default()
79}
80
81fn action(op: &Value) -> &str {
82    op["action"].as_str().unwrap_or("?")
83}
84
85/// The address of the channel an operation points at — the address, not the
86/// identifier, because renaming the identifier alone changes nothing on the
87/// wire while changing the address moves the traffic.
88fn address<'a>(doc: &'a Value, op: &'a Value) -> &'a str {
89    follow(doc, &op["channel"])["address"]
90        .as_str()
91        .unwrap_or("?")
92}
93
94/// The payload schema of an operation's first message.
95///
96/// Generated operations carry exactly one message; a hand-edited document
97/// with several is read for its first, which is the one a diff can speak
98/// about without guessing at correspondence.
99fn payload(doc: &Value, op: &Value) -> Option<Value> {
100    let first = op["messages"].as_array()?.first()?;
101    let message = follow(doc, first);
102    let payload = &message["payload"];
103    (!payload.is_null()).then(|| payload.clone())
104}
105
106/// Resolve a chain of `$ref`s — an operation's message reaches its payload
107/// through the channel and then the components section.
108fn follow<'a>(doc: &'a Value, mut node: &'a Value) -> &'a Value {
109    // Bounded rather than cycle-tracked: a generated chain is two hops, and a
110    // hand-edited cycle should stop rather than hang.
111    for _ in 0..8 {
112        if !node["$ref"].is_string() {
113            break;
114        }
115        let next = deref(doc, node);
116        if std::ptr::eq(next, node) {
117            break; // Dangling reference: nothing further to resolve.
118        }
119        node = next;
120    }
121    node
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::compat::is_compatible;
128    use serde_json::json;
129
130    /// A document with one operation whose message carries `payload`.
131    fn doc(action: &str, payload: Value) -> Value {
132        json!({
133            "asyncapi": "3.0.0",
134            "channels": {
135                "orders": {
136                    "address": "orders",
137                    "messages": { "Order": { "$ref": "#/components/messages/Order" } },
138                },
139            },
140            "operations": {
141                "onOrder": {
142                    "action": action,
143                    "channel": { "$ref": "#/channels/orders" },
144                    "messages": [ { "$ref": "#/channels/orders/messages/Order" } ],
145                },
146            },
147            "components": { "messages": { "Order": { "payload": payload } } },
148        })
149    }
150
151    fn object(props: Value, required: &[&str]) -> Value {
152        json!({ "type": "object", "properties": props, "required": required })
153    }
154
155    #[test]
156    fn an_unchanged_document_is_compatible_with_itself() {
157        let d = doc(
158            "send",
159            object(json!({ "id": { "type": "string" } }), &["id"]),
160        );
161        assert!(is_compatible(&diff(&d, &d)));
162        assert!(diff(&d, &d).is_empty());
163    }
164
165    #[test]
166    fn a_sent_field_that_stops_being_guaranteed_breaks_consumers() {
167        let old = doc(
168            "send",
169            object(json!({ "id": { "type": "string" } }), &["id"]),
170        );
171        let new = doc("send", object(json!({ "id": { "type": "string" } }), &[]));
172        let changes = diff(&old, &new);
173        assert!(!is_compatible(&changes), "got {changes:?}");
174        assert!(changes[0].detail.contains("no longer guaranteed"));
175    }
176
177    #[test]
178    fn a_new_field_on_a_sent_message_is_additive() {
179        let old = doc(
180            "send",
181            object(json!({ "id": { "type": "string" } }), &["id"]),
182        );
183        let new = doc(
184            "send",
185            object(
186                json!({ "id": { "type": "string" }, "note": { "type": "string" } }),
187                &["id", "note"],
188            ),
189        );
190        assert!(is_compatible(&diff(&old, &new)), "{:?}", diff(&old, &new));
191    }
192
193    #[test]
194    fn the_same_new_field_on_a_received_message_breaks_producers() {
195        let old = doc(
196            "receive",
197            object(json!({ "id": { "type": "string" } }), &["id"]),
198        );
199        let new = doc(
200            "receive",
201            object(
202                json!({ "id": { "type": "string" }, "note": { "type": "string" } }),
203                &["id", "note"],
204            ),
205        );
206        let changes = diff(&old, &new);
207        assert!(!is_compatible(&changes), "got {changes:?}");
208        assert_eq!(changes[0].severity, Severity::Breaking);
209    }
210
211    #[test]
212    fn reversing_the_flow_is_breaking_on_its_own() {
213        let payload = object(json!({ "id": { "type": "string" } }), &["id"]);
214        let changes = diff(&doc("send", payload.clone()), &doc("receive", payload));
215        assert_eq!(changes.len(), 1, "got {changes:?}");
216        assert!(changes[0].detail.contains("send -> receive"));
217    }
218
219    #[test]
220    fn moving_a_channel_to_another_address_is_breaking() {
221        let payload = object(json!({}), &[]);
222        let old = doc("send", payload.clone());
223        let mut new = doc("send", payload);
224        new["channels"]["orders"]["address"] = json!("orders.v2");
225        let changes = diff(&old, &new);
226        assert_eq!(changes.len(), 1, "got {changes:?}");
227        assert!(changes[0].detail.contains("orders -> orders.v2"));
228    }
229
230    #[test]
231    fn a_removed_operation_breaks_consumers_and_a_new_one_does_not() {
232        let payload = object(json!({}), &[]);
233        let old = doc("send", payload.clone());
234        let mut new = doc("send", payload);
235        new["operations"] = json!({ "onOther": new["operations"]["onOrder"].clone() });
236        let changes = diff(&old, &new);
237        assert_eq!(changes.len(), 2, "got {changes:?}");
238        assert_eq!(changes[0].severity, Severity::Breaking);
239        assert_eq!(changes[0].at, "onOrder");
240        assert_eq!(changes[1].severity, Severity::Additive);
241    }
242
243    #[test]
244    fn payloads_resolve_through_the_channel_and_components_chain() {
245        // The regression this guards: reading `messages[0]` without following
246        // its two `$ref` hops finds no schema at all, and every payload change
247        // then reads as "no change".
248        let old = doc(
249            "send",
250            object(json!({ "id": { "type": "string" } }), &["id"]),
251        );
252        let new = doc(
253            "send",
254            object(json!({ "id": { "type": "integer" } }), &["id"]),
255        );
256        let changes = diff(&old, &new);
257        assert_eq!(changes.len(), 1, "got {changes:?}");
258        assert!(changes[0].detail.contains("string -> integer"));
259    }
260
261    #[test]
262    fn a_dangling_reference_stops_rather_than_hanging() {
263        let mut d = doc("send", object(json!({}), &[]));
264        d["operations"]["onOrder"]["messages"] = json!([{ "$ref": "#/components/messages/Gone" }]);
265        assert!(diff(&d, &d).is_empty());
266    }
267}