Skip to main content

soothfast_spec/
serialize.rs

1//! YAML and JSON rendering for generated documents.
2//!
3//! Output is byte-deterministic: schema maps are `BTreeMap`s upstream and keys
4//! emit here in a fixed order. A generated file that reshuffled between runs
5//! would produce an endless stream of no-op commits.
6
7use serde_json::Value;
8use yaml_rust2::{Yaml, YamlLoader};
9
10/// Keys emitted before any others, so a generated document reads in the order
11/// a human expects rather than alphabetically.
12///
13/// One list covers every dialect. A key another dialect never emits simply
14/// never matches, and the alternative — a table per dialect — would make two
15/// documents that share a construct (`info`, `schemas`) order it differently.
16const KEY_ORDER: &[&str] = &[
17    // Document preamble, whichever dialect declares it.
18    "openapi",
19    "asyncapi",
20    "info",
21    // Identity before prose, wherever both appear: a tool, a parameter and a
22    // server are all read by name first.
23    "name",
24    "title",
25    "version",
26    "description",
27    "servers",
28    "url",
29    "host",
30    "protocol",
31    // OpenAPI operations.
32    "paths",
33    "operationId",
34    "summary",
35    "tags",
36    "parameters",
37    "in",
38    "requestBody",
39    "required",
40    "content",
41    "responses",
42    // AsyncAPI channels and operations.
43    "channels",
44    "address",
45    "action",
46    "channel",
47    "messages",
48    "operations",
49    "payload",
50    // MCP tools.
51    "tools",
52    "inputSchema",
53    "outputSchema",
54    // JSON Schema bodies.
55    "schema",
56    "$ref",
57    "type",
58    "format",
59    "properties",
60    "items",
61    "prefixItems",
62    "oneOf",
63    "allOf",
64    "const",
65    "enum",
66    "additionalProperties",
67    // Shared definitions, last because they are the document's appendix.
68    "components",
69    "schemas",
70    "$defs",
71];
72
73/// Keys whose object *values* are keyed by data rather than by keyword: field
74/// names, type names, channel addresses, media types.
75///
76/// Inside one of these, [`KEY_ORDER`] must not apply. A struct with a field
77/// called `tags` or `type` would otherwise have it hoisted above its siblings
78/// for no reason a reader could see, and whether a generated file reshuffles
79/// would depend on whether someone's field name happened to collide with a
80/// spec keyword.
81const NAME_KEYED: &[&str] = &[
82    "properties",
83    "$defs",
84    "schemas",
85    "definitions",
86    "paths",
87    "responses",
88    "content",
89    "channels",
90    "operations",
91    "messages",
92    "servers",
93    "parameters",
94    "types",
95    "roots",
96    "fields",
97    "args",
98];
99
100/// Rank a key for emission. Ranks are doubled so an entry can be slotted
101/// between two others with an odd number.
102///
103/// `required` is the one key whose place depends on context: a boolean on a
104/// parameter, where it reads first, but an array on a schema, where
105/// convention puts it after `properties`. The value type tells them apart.
106fn key_rank(key: &str, value: &Value) -> usize {
107    let position = |name: &str| {
108        KEY_ORDER
109            .iter()
110            .position(|k| *k == name)
111            .unwrap_or(KEY_ORDER.len())
112    };
113    if key == "required" && value.is_array() {
114        return position("properties") * 2 + 1;
115    }
116    position(key) * 2
117}
118
119/// An object's keys in emission order.
120///
121/// `data_keyed` objects sort plainly by name; everything else leads with the
122/// well-known keys.
123fn ordered_keys(o: &serde_json::Map<String, Value>, data_keyed: bool) -> Vec<&String> {
124    let mut keys: Vec<&String> = o.keys().collect();
125    if data_keyed {
126        keys.sort();
127    } else {
128        keys.sort_by_key(|k| (key_rank(k, &o[*k]), (*k).clone()));
129    }
130    keys
131}
132
133/// Render a document as YAML, with well-known keys in conventional order.
134pub fn to_yaml(value: &Value) -> String {
135    let mut out = String::new();
136    let yaml = to_yaml_value(value, false);
137    let mut emitter = yaml_rust2::YamlEmitter::new(&mut out);
138    if emitter.dump(&yaml).is_err() {
139        // The emitter only fails on unrepresentable values; JSON has none.
140        return to_json(value);
141    }
142    // yaml-rust2 opens every document with a `---` marker.
143    let body = out.strip_prefix("---\n").unwrap_or(&out).to_string();
144    format!("{body}\n")
145}
146
147/// Render a document as pretty JSON, for `.json` spec files.
148///
149/// Hand-rolled rather than `serde_json::to_string_pretty` for one reason: the
150/// same key order the YAML path uses. A tool manifest whose `description`
151/// sorts above its `name` is valid and unreadable, and the two serializations
152/// of one document should not disagree about what comes first.
153pub fn to_json(value: &Value) -> String {
154    let mut out = String::new();
155    write_json(&mut out, value, 0, false);
156    out.push('\n');
157    out
158}
159
160fn write_json(out: &mut String, value: &Value, depth: usize, data_keyed: bool) {
161    match value {
162        Value::Object(o) if !o.is_empty() => {
163            out.push_str("{\n");
164            let keys = ordered_keys(o, data_keyed);
165            for (i, key) in keys.iter().enumerate() {
166                indent(out, depth + 1);
167                out.push_str(&Value::String((*key).clone()).to_string());
168                out.push_str(": ");
169                write_json(out, &o[*key], depth + 1, NAME_KEYED.contains(&key.as_str()));
170                if i + 1 < keys.len() {
171                    out.push(',');
172                }
173                out.push('\n');
174            }
175            indent(out, depth);
176            out.push('}');
177        }
178        Value::Array(a) if !a.is_empty() => {
179            out.push_str("[\n");
180            for (i, item) in a.iter().enumerate() {
181                indent(out, depth + 1);
182                write_json(out, item, depth + 1, false);
183                if i + 1 < a.len() {
184                    out.push(',');
185                }
186                out.push('\n');
187            }
188            indent(out, depth);
189            out.push(']');
190        }
191        // Scalars and empty containers already serialize compactly and
192        // correctly, escaping included.
193        other => out.push_str(&other.to_string()),
194    }
195}
196
197fn indent(out: &mut String, depth: usize) {
198    out.extend(std::iter::repeat_n(' ', depth * 2));
199}
200
201fn to_yaml_value(value: &Value, data_keyed: bool) -> yaml_rust2::Yaml {
202    use yaml_rust2::Yaml;
203    match value {
204        Value::Null => Yaml::Null,
205        Value::Bool(b) => Yaml::Boolean(*b),
206        Value::Number(n) => match n.as_i64() {
207            Some(i) => Yaml::Integer(i),
208            None => Yaml::Real(n.to_string()),
209        },
210        Value::String(s) => Yaml::String(s.clone()),
211        Value::Array(a) => Yaml::Array(a.iter().map(|v| to_yaml_value(v, false)).collect()),
212        Value::Object(o) => {
213            let mut hash = yaml_rust2::yaml::Hash::new();
214            for k in ordered_keys(o, data_keyed) {
215                hash.insert(
216                    Yaml::String(k.clone()),
217                    to_yaml_value(&o[k], NAME_KEYED.contains(&k.as_str())),
218                );
219            }
220            Yaml::Hash(hash)
221        }
222    }
223}
224
225/// Parse YAML-or-JSON text into a JSON value (JSON is valid YAML, but JSON
226/// is tried first for exactness).
227pub fn from_text(text: &str) -> Result<Value, String> {
228    if let Ok(v) = serde_json::from_str::<Value>(text) {
229        return Ok(v);
230    }
231    let docs = YamlLoader::load_from_str(text).map_err(|e| format!("YAML parse error: {e}"))?;
232    docs.into_iter()
233        .next()
234        .map(|y| yaml_to_json(&y))
235        .ok_or_else(|| "empty spec document".to_string())
236}
237
238fn yaml_to_json(y: &Yaml) -> Value {
239    match y {
240        Yaml::Real(s) => s
241            .parse::<f64>()
242            .map(|f| serde_json::json!(f))
243            .unwrap_or(Value::Null),
244        Yaml::Integer(i) => serde_json::json!(i),
245        Yaml::String(s) => Value::String(s.clone()),
246        Yaml::Boolean(b) => Value::Bool(*b),
247        Yaml::Array(a) => Value::Array(a.iter().map(yaml_to_json).collect()),
248        Yaml::Hash(h) => {
249            let mut map = serde_json::Map::new();
250            for (k, v) in h {
251                if let Yaml::String(key) = k {
252                    map.insert(key.clone(), yaml_to_json(v));
253                }
254            }
255            Value::Object(map)
256        }
257        _ => Value::Null,
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use serde_json::json;
265
266    #[test]
267    fn the_dialect_marker_leads_the_document() {
268        let doc = json!({ "components": {}, "info": { "version": "1", "title": "T" },
269                          "asyncapi": "3.0.0" });
270        let yaml = to_yaml(&doc);
271        assert!(yaml.starts_with("asyncapi: 3.0.0"), "got:\n{yaml}");
272        let title = yaml.find("title:").expect("title");
273        let version = yaml.find("version:").expect("version");
274        assert!(title < version, "title reads before version:\n{yaml}");
275    }
276
277    #[test]
278    fn rendering_is_byte_identical_between_runs() {
279        let doc = json!({ "tools": [ { "inputSchema": {}, "name": "a" } ] });
280        assert_eq!(to_yaml(&doc), to_yaml(&doc));
281        assert_eq!(to_json(&doc), to_json(&doc));
282    }
283
284    #[test]
285    fn required_reads_before_a_parameter_but_after_schema_properties() {
286        let doc = json!({
287            "parameters": [ { "schema": { "type": "integer" }, "required": true, "name": "id" } ],
288            "components": { "schemas": { "Item": {
289                "required": ["id"],
290                "properties": { "id": { "type": "integer" } },
291                "type": "object" } } },
292        });
293        let yaml = to_yaml(&doc);
294        let param_required = yaml.find("required: true").expect("parameter required");
295        let param_schema = yaml.find("schema:").expect("parameter schema");
296        assert!(param_required < param_schema, "got:\n{yaml}");
297
298        let props = yaml.find("properties:").expect("schema properties");
299        let list = yaml.find("required:\n").expect("schema required list");
300        assert!(props < list, "got:\n{yaml}");
301    }
302
303    #[test]
304    fn json_output_ends_with_a_newline() {
305        let out = to_json(&json!({ "openapi": "3.1.0" }));
306        assert!(out.contains("\"openapi\": \"3.1.0\""), "got {out}");
307        assert!(out.ends_with('\n'));
308    }
309
310    #[test]
311    fn json_reads_in_the_same_order_as_yaml() {
312        let doc = json!({ "tools": [ { "description": "d", "inputSchema": {}, "name": "a" } ] });
313        let out = to_json(&doc);
314        let at = |needle: &str| {
315            out.find(needle)
316                .unwrap_or_else(|| panic!("{needle}\n{out}"))
317        };
318        assert!(at("\"name\"") < at("\"description\""), "got:\n{out}");
319        assert!(at("\"description\"") < at("\"inputSchema\""), "got:\n{out}");
320    }
321
322    #[test]
323    fn json_escapes_and_nests_exactly_as_serde_would() {
324        let doc = json!({ "a": { "quote\"key": "line\nbreak \u{1f600}" },
325                          "b": [1, -2.5, true, null, [], {}] });
326        let out = to_json(&doc);
327        let round_tripped: Value = serde_json::from_str(&out).expect("valid JSON");
328        assert_eq!(round_tripped, doc);
329    }
330
331    #[test]
332    fn a_field_named_after_a_spec_keyword_is_not_hoisted() {
333        // `tags`, `type` and `name` are all real struct field names as well as
334        // spec keywords; inside `properties` they are data and sort plainly.
335        let doc = json!({ "components": { "schemas": { "Item": {
336            "type": "object",
337            "properties": { "tags": { "type": "array" },
338                            "id": { "type": "string" },
339                            "name": { "type": "string" } },
340        } } } });
341        for out in [to_yaml(&doc), to_json(&doc)] {
342            let at = |needle: &str| {
343                out.find(needle)
344                    .unwrap_or_else(|| panic!("{needle}\n{out}"))
345            };
346            assert!(at("id") < at("name"), "alphabetical, got:\n{out}");
347            assert!(at("name") < at("tags"), "alphabetical, got:\n{out}");
348        }
349    }
350
351    #[test]
352    fn keyword_ordering_still_applies_inside_each_schema_body() {
353        // Only the map of field names is data; the schema under each one is
354        // spec keywords again.
355        let doc = json!({ "properties": { "id": { "properties": {}, "type": "object" } } });
356        let yaml = to_yaml(&doc);
357        assert!(
358            yaml.find("type:").expect("type") < yaml.find("properties: {}").expect("props"),
359            "got:\n{yaml}"
360        );
361    }
362
363    #[test]
364    fn empty_containers_stay_on_one_line() {
365        let out = to_json(&json!({ "properties": {}, "servers": [] }));
366        assert!(out.contains("\"properties\": {}"), "got:\n{out}");
367        assert!(out.contains("\"servers\": []"), "got:\n{out}");
368    }
369
370    #[test]
371    fn documents_carry_no_yaml_document_marker() {
372        assert!(!to_yaml(&json!({ "a": 1 })).contains("---"));
373    }
374}