Skip to main content

semiont_codegen/
bundle.rs

1//! Bundling specs/src into whole documents: every file `$ref` followed,
2//! each file the root document lists under `components` named by its
3//! component and the rest inlined, as the published bundles are, and the
4//! component schemas as JSON Schema draft 7.
5
6use serde_json::{Map, Value, json};
7use std::collections::BTreeMap;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11pub fn read_json(path: &Path) -> Value {
12    let text =
13        fs::read_to_string(path).unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
14    serde_json::from_str(&text).unwrap_or_else(|e| panic!("{} is not JSON: {e}", path.display()))
15}
16
17pub fn write_json(path: &Path, value: &Value) {
18    fs::write(
19        path,
20        serde_json::to_vec(value).expect("a JSON value serializes"),
21    )
22    .unwrap_or_else(|e| panic!("cannot write {}: {e}", path.display()));
23}
24
25/// One OpenAPI document with every file `$ref` resolved.
26pub struct Bundle {
27    /// Each file the document names as a component → (kind, name).
28    named: BTreeMap<PathBuf, (String, String)>,
29    /// The components, resolved, as they will be written.
30    components: BTreeMap<String, Map<String, Value>>,
31}
32
33impl Bundle {
34    pub fn of(root_file: &Path) -> Value {
35        let root_file = root_file
36            .canonicalize()
37            .unwrap_or_else(|e| panic!("{}: {e}", root_file.display()));
38        let root = read_json(&root_file);
39        let base = root_file
40            .parent()
41            .expect("a file has a directory")
42            .to_path_buf();
43        let mut bundle = Bundle {
44            named: BTreeMap::new(),
45            components: BTreeMap::new(),
46        };
47
48        let declared = root
49            .get("components")
50            .and_then(Value::as_object)
51            .cloned()
52            .unwrap_or_default();
53        for (kind, entries) in &declared {
54            let Some(entries) = entries.as_object() else {
55                continue;
56            };
57            for (name, entry) in entries {
58                if let Some(file) = entry.get("$ref").and_then(Value::as_str) {
59                    bundle.name(&base.join(file), kind, name);
60                }
61            }
62        }
63        let mut document = root.clone();
64        for (kind, entries) in &declared {
65            let Some(entries) = entries.as_object() else {
66                continue;
67            };
68            for (name, entry) in entries {
69                let resolved = match entry.get("$ref").and_then(Value::as_str) {
70                    Some(file) => bundle.load(&base.join(file)),
71                    None => {
72                        let mut inline = entry.clone();
73                        bundle.resolve(&mut inline, &base);
74                        inline
75                    }
76                };
77                bundle
78                    .components
79                    .entry(kind.clone())
80                    .or_default()
81                    .insert(name.clone(), resolved);
82            }
83        }
84        if let Some(object) = document.as_object_mut() {
85            object.remove("components");
86            for (key, value) in object.iter_mut() {
87                if key != "components" {
88                    bundle.resolve(value, &base);
89                }
90            }
91        }
92        let components: Map<String, Value> = bundle
93            .components
94            .into_iter()
95            .map(|(kind, entries)| (kind, Value::Object(entries)))
96            .collect();
97        document["components"] = Value::Object(components);
98        document
99    }
100
101    fn name(&mut self, file: &Path, kind: &str, name: &str) {
102        let file = file
103            .canonicalize()
104            .unwrap_or_else(|e| panic!("{}: {e}", file.display()));
105        if let Some((k, n)) = self.named.get(&file)
106            && (k.as_str(), n.as_str()) != (kind, name)
107        {
108            panic!("{} is named both {k}/{n} and {kind}/{name}", file.display());
109        }
110        self.named.insert(file, (kind.to_owned(), name.to_owned()));
111    }
112
113    /// A file's contents, with every `$ref` in it resolved relative to it.
114    fn load(&mut self, file: &Path) -> Value {
115        let file = file
116            .canonicalize()
117            .unwrap_or_else(|e| panic!("{}: {e}", file.display()));
118        let mut value = read_json(&file);
119        let base = file.parent().expect("a file has a directory").to_path_buf();
120        self.resolve(&mut value, &base);
121        value
122    }
123
124    fn resolve(&mut self, node: &mut Value, base: &Path) {
125        match node {
126            Value::Array(items) => items.iter_mut().for_each(|item| self.resolve(item, base)),
127            Value::Object(object) => {
128                if let Some(reference) = object
129                    .get("$ref")
130                    .and_then(Value::as_str)
131                    .map(str::to_owned)
132                {
133                    let (file, pointer) = reference
134                        .split_once('#')
135                        .unwrap_or((reference.as_str(), ""));
136                    if file.is_empty() {
137                        panic!(
138                            "an internal $ref {reference} in a file under {}: specs/src refers by file",
139                            base.display()
140                        );
141                    }
142                    if !pointer.is_empty() {
143                        panic!(
144                            "$ref {reference} under {} points into a file: specs/src refers to whole files",
145                            base.display()
146                        );
147                    }
148                    let target = base.join(file).canonicalize().unwrap_or_else(|e| {
149                        panic!("$ref {reference} under {}: {e}", base.display())
150                    });
151                    let named = self.named.get(&target).cloned().or_else(|| {
152                        let kind = target.parent()?.file_name()?.to_str()?.to_owned();
153                        let name = target.file_stem()?.to_str()?.to_owned();
154                        (kind == "schemas" || kind == "responses").then_some((kind, name))
155                    });
156                    match named {
157                        Some((kind, name)) => {
158                            if !self.named.contains_key(&target) {
159                                self.name(&target, &kind, &name);
160                                let resolved = self.load(&target);
161                                self.components
162                                    .entry(kind.clone())
163                                    .or_default()
164                                    .insert(name.clone(), resolved);
165                            }
166                            *node = json!({ "$ref": format!("#/components/{kind}/{name}") });
167                        }
168                        None => *node = self.load(&target),
169                    }
170                    return;
171                }
172                object
173                    .values_mut()
174                    .for_each(|value| self.resolve(value, base));
175            }
176            _ => {}
177        }
178    }
179}
180
181/// The document's component schemas as JSON Schema draft 7, under
182/// `definitions`: `nullable` beside a `type` adds `null` to it; beside
183/// anything else it becomes `anyOf: [{type: null}, <the rest>]`.
184pub fn draft7_definitions(document: &Value) -> Value {
185    let mut schemas = document["components"]["schemas"].clone();
186    fn convert(node: &mut Value) {
187        match node {
188            Value::Array(items) => items.iter_mut().for_each(convert),
189            Value::Object(object) => {
190                if let Some(Value::String(reference)) = object.get_mut("$ref")
191                    && let Some(name) = reference.strip_prefix("#/components/schemas/")
192                {
193                    *reference = format!("#/definitions/{name}");
194                }
195                if object.get("nullable") == Some(&Value::Bool(true)) {
196                    object.remove("nullable");
197                    match object.get("type").cloned() {
198                        Some(Value::String(kind)) => {
199                            object.insert("type".into(), json!([kind, "null"]));
200                        }
201                        _ => {
202                            let inner = Value::Object(std::mem::take(object));
203                            object.insert("anyOf".into(), json!([{ "type": "null" }, inner]));
204                        }
205                    }
206                }
207                object.values_mut().for_each(convert);
208            }
209            _ => {}
210        }
211    }
212    convert(&mut schemas);
213    json!({ "$schema": "http://json-schema.org/draft-07/schema#", "definitions": schemas })
214}