Skip to main content

olai_codegen/
openapi_enrich.rs

1use std::collections::HashSet;
2use std::fs;
3use std::path::Path;
4
5use glob::glob;
6use protobuf::Message;
7use serde_json::Map as JsonMap;
8use serde_yaml::Value as YamlValue;
9
10use crate::Result;
11use crate::parsing::{CodeGenMetadata, parse_file_descriptor_set};
12
13/// Run Pass 0 (gnostic ref fix), Pass 1 (validation enrichment), and optionally Pass 2 (path/body dedup).
14pub fn run(
15    spec: &Path,
16    jsonschema_dir: &Path,
17    camel_case: bool,
18    descriptors: Option<&Path>,
19) -> Result<()> {
20    let spec_str = fs::read_to_string(spec)
21        .map_err(|e| crate::Error::Build(format!("Failed to read {}: {}", spec.display(), e)))?;
22    let mut doc: YamlValue = serde_yaml::from_str(&spec_str).map_err(|e| {
23        crate::Error::Build(format!("Failed to parse YAML {}: {}", spec.display(), e))
24    })?;
25
26    fix_gnostic_refs(&mut doc);
27    enrich_from_jsonschema(&mut doc, jsonschema_dir, camel_case)?;
28
29    if let Some(desc_path) = descriptors {
30        let bytes = fs::read(desc_path).map_err(|e| {
31            crate::Error::Build(format!(
32                "Failed to read descriptors {}: {}",
33                desc_path.display(),
34                e
35            ))
36        })?;
37        let fds = protobuf::descriptor::FileDescriptorSet::parse_from_bytes(&bytes)
38            .map_err(|e| crate::Error::Build(format!("Failed to parse descriptors: {e}")))?;
39        let metadata = parse_file_descriptor_set(&fds)?;
40        dedup_path_params(&mut doc, &metadata);
41    }
42
43    let out = serde_yaml::to_string(&doc)
44        .map_err(|e| crate::Error::Build(format!("Failed to serialize YAML: {e}")))?;
45    fs::write(spec, out)
46        .map_err(|e| crate::Error::Build(format!("Failed to write {}: {}", spec.display(), e)))?;
47
48    Ok(())
49}
50
51// ── Pass 0 ────────────────────────────────────────────────────────────────────
52
53/// Rewrite gnostic-generated `#/$defs/a.b.v1.TypeName.schema.strict.json` refs to
54/// valid OpenAPI `#/components/schemas/TypeName` refs throughout the document.
55fn fix_gnostic_refs(doc: &mut YamlValue) {
56    match doc {
57        YamlValue::Mapping(map) => {
58            if let Some(YamlValue::String(s)) = map.get_mut("$ref")
59                && let Some(fixed) = rewrite_gnostic_ref(s)
60            {
61                *s = fixed;
62            }
63            for (_, v) in map.iter_mut() {
64                fix_gnostic_refs(v);
65            }
66        }
67        YamlValue::Sequence(seq) => {
68            for item in seq.iter_mut() {
69                fix_gnostic_refs(item);
70            }
71        }
72        _ => {}
73    }
74}
75
76fn rewrite_gnostic_ref(ref_str: &str) -> Option<String> {
77    let stem = ref_str
78        .strip_prefix("#/$defs/")?
79        .strip_suffix(".schema.strict.json")?;
80    let start = stem.find(|c: char| c.is_uppercase())?;
81    let type_name = &stem[start..];
82    Some(format!("#/components/schemas/{type_name}"))
83}
84
85// ── Pass 1 ────────────────────────────────────────────────────────────────────
86
87fn enrich_from_jsonschema(
88    spec: &mut YamlValue,
89    jsonschema_dir: &Path,
90    camel_case: bool,
91) -> Result<()> {
92    let pattern = jsonschema_dir
93        .join("*.schema.strict.bundle.json")
94        .to_string_lossy()
95        .into_owned();
96
97    let mut files: Vec<std::path::PathBuf> = glob(&pattern)
98        .map_err(|e| crate::Error::Build(format!("Glob pattern error: {e}")))?
99        .filter_map(|r: Result<std::path::PathBuf, _>| r.ok())
100        .collect();
101    files.sort();
102
103    if files.is_empty() {
104        eprintln!(
105            "enrich-openapi: no JSON Schema files found in {}",
106            jsonschema_dir.display()
107        );
108        return Ok(());
109    }
110
111    let mut updated = 0usize;
112    let mut added = 0usize;
113
114    for path in &files {
115        let filename = path
116            .file_name()
117            .and_then(|f: &std::ffi::OsStr| f.to_str())
118            .unwrap_or_default();
119        let type_name = match type_name_from_filename(filename) {
120            Some(n) => n,
121            None => {
122                eprintln!("enrich-openapi: skipping {filename} (cannot extract type name)");
123                continue;
124            }
125        };
126
127        let content = fs::read_to_string(path).map_err(|e| {
128            crate::Error::Build(format!("Failed to read {}: {}", path.display(), e))
129        })?;
130        let bundle: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
131            crate::Error::Build(format!("Failed to parse JSON {}: {}", path.display(), e))
132        })?;
133
134        let defs: JsonMap<String, serde_json::Value> = bundle
135            .get("$defs")
136            .and_then(|v: &serde_json::Value| v.as_object())
137            .cloned()
138            .unwrap_or_default();
139
140        let root_ref = bundle
141            .get("$ref")
142            .and_then(|v: &serde_json::Value| v.as_str())
143            .unwrap_or("");
144        // Surface an unexpected `$ref` shape instead of silently coercing it to an empty key,
145        // which would mask malformed bundles behind a generic "could not resolve" message.
146        let root_key = match root_ref.strip_prefix("#/$defs/") {
147            Some(key) => key,
148            None => {
149                eprintln!(
150                    "enrich-openapi: unexpected root $ref format '{root_ref}' for {type_name} \
151                     (expected '#/$defs/<name>'), skipping"
152                );
153                continue;
154            }
155        };
156        let root_schema: serde_json::Value = match defs.get(root_key) {
157            Some(s) => s.clone(),
158            None => {
159                eprintln!(
160                    "enrich-openapi: could not resolve root $ref '{root_ref}' for {type_name}, skipping"
161                );
162                continue;
163            }
164        };
165
166        // Navigate to components.schemas.<TypeName>
167        let schemas = spec
168            .get_mut("components")
169            .and_then(|c| c.get_mut("schemas"));
170        let schemas = match schemas {
171            Some(s) => s,
172            None => {
173                eprintln!("enrich-openapi: openapi.yaml has no components.schemas, skipping");
174                break;
175            }
176        };
177
178        let exists = schemas
179            .as_mapping()
180            .map(|m| m.contains_key(type_name.as_str()))
181            .unwrap_or(false);
182
183        if !exists {
184            let ty = root_schema
185                .get("type")
186                .and_then(|v: &serde_json::Value| v.as_str())
187                .unwrap_or("object");
188            if let Some(map) = schemas.as_mapping_mut() {
189                let mut entry = serde_yaml::Mapping::new();
190                entry.insert(
191                    YamlValue::String("type".into()),
192                    YamlValue::String(ty.to_string()),
193                );
194                map.insert(
195                    YamlValue::String(type_name.clone()),
196                    YamlValue::Mapping(entry),
197                );
198            }
199            added += 1;
200        } else {
201            updated += 1;
202        }
203
204        let oa_schema = schemas
205            .as_mapping_mut()
206            .and_then(|m| m.get_mut(type_name.as_str()));
207        if let Some(oa) = oa_schema {
208            enrich_schema(oa, &root_schema, &defs, camel_case);
209        }
210    }
211
212    println!(
213        "enrich-openapi: enriched {} schemas ({} updated, {} added)",
214        updated + added,
215        updated,
216        added
217    );
218    Ok(())
219}
220
221/// Extract type name from a JSON Schema bundle filename.
222/// e.g. "dda.coordinator.v1.CreateWaveRequest.schema.strict.bundle.json" → "CreateWaveRequest"
223fn type_name_from_filename(filename: &str) -> Option<String> {
224    let stem = filename.strip_suffix(".schema.strict.bundle.json")?;
225    let start = stem.find(|c: char| c.is_uppercase())?;
226    Some(stem[start..].to_string())
227}
228
229/// Convert snake_case to camelCase.
230fn snake_to_camel(s: &str) -> String {
231    let mut result = String::with_capacity(s.len());
232    let mut capitalise_next = false;
233    for ch in s.chars() {
234        if ch == '_' {
235            capitalise_next = true;
236        } else if capitalise_next {
237            result.push(ch.to_ascii_uppercase());
238            capitalise_next = false;
239        } else {
240            result.push(ch);
241        }
242    }
243    result
244}
245
246/// Resolve a `#/$defs/<key>` reference within a bundle's $defs map.
247fn resolve_ref<'a>(
248    ref_str: &str,
249    defs: &'a JsonMap<String, serde_json::Value>,
250) -> Option<&'a serde_json::Value> {
251    let key = ref_str.strip_prefix("#/$defs/")?;
252    defs.get(key)
253}
254
255const VALIDATION_FIELDS: &[&str] = &[
256    "minLength",
257    "maxLength",
258    "pattern",
259    "minimum",
260    "maximum",
261    "exclusiveMinimum",
262    "exclusiveMaximum",
263    "minItems",
264    "maxItems",
265    "enum",
266    "additionalProperties",
267    "required",
268    "description",
269    "title",
270];
271
272/// Merge validation fields from a JSON Schema node into an OpenAPI YAML schema node.
273fn merge_validation(source: &serde_json::Value, target: &mut YamlValue) {
274    for &key in VALIDATION_FIELDS {
275        let val = match source.get(key) {
276            Some(v) => v,
277            None => continue,
278        };
279
280        if key == "exclusiveMinimum" {
281            if let Some(n) = val.as_f64() {
282                // JSON Schema 2020-12 numeric form → OpenAPI 3.0 boolean form
283                yaml_set(
284                    target,
285                    "minimum",
286                    YamlValue::Number(serde_yaml::Number::from(n)),
287                );
288                yaml_set(target, "exclusiveMinimum", YamlValue::Bool(true));
289            }
290            continue;
291        }
292
293        if key == "exclusiveMaximum" {
294            if let Some(n) = val.as_f64() {
295                // JSON Schema 2020-12 numeric form → OpenAPI 3.0 boolean form
296                yaml_set(
297                    target,
298                    "maximum",
299                    YamlValue::Number(serde_yaml::Number::from(n)),
300                );
301                yaml_set(target, "exclusiveMaximum", YamlValue::Bool(true));
302            }
303            continue;
304        }
305
306        yaml_set(target, key, json_to_yaml(val));
307    }
308}
309
310/// Recursively enrich an OpenAPI schema YAML node with validation from a JSON Schema node.
311fn enrich_schema(
312    openapi: &mut YamlValue,
313    json_schema: &serde_json::Value,
314    defs: &JsonMap<String, serde_json::Value>,
315    camel_case: bool,
316) {
317    merge_validation(json_schema, openapi);
318
319    // Recurse into properties
320    if let Some(js_props) = json_schema.get("properties").and_then(|v| v.as_object()) {
321        for (snake_key, js_prop) in js_props {
322            let lookup_key = if camel_case {
323                snake_to_camel(snake_key)
324            } else {
325                snake_key.clone()
326            };
327
328            let resolved: std::borrow::Cow<serde_json::Value> =
329                if let Some(ref_str) = js_prop.get("$ref").and_then(|v| v.as_str()) {
330                    match resolve_ref(ref_str, defs) {
331                        Some(r) => std::borrow::Cow::Borrowed(r),
332                        None => std::borrow::Cow::Borrowed(js_prop),
333                    }
334                } else {
335                    std::borrow::Cow::Borrowed(js_prop)
336                };
337
338            if let Some(oa_prop) = openapi
339                .get_mut("properties")
340                .and_then(|p| p.get_mut(lookup_key.as_str()))
341            {
342                enrich_schema(oa_prop, &resolved, defs, camel_case);
343            }
344        }
345    }
346
347    // Recurse into items
348    if let Some(js_items) = json_schema.get("items") {
349        let resolved: std::borrow::Cow<serde_json::Value> =
350            if let Some(ref_str) = js_items.get("$ref").and_then(|v| v.as_str()) {
351                match resolve_ref(ref_str, defs) {
352                    Some(r) => std::borrow::Cow::Borrowed(r),
353                    None => std::borrow::Cow::Borrowed(js_items),
354                }
355            } else {
356                std::borrow::Cow::Borrowed(js_items)
357            };
358
359        if let Some(oa_items) = openapi.get_mut("items") {
360            enrich_schema(oa_items, &resolved, defs, camel_case);
361        }
362    }
363
364    // Recurse into combiners
365    for combiner in &["allOf", "oneOf", "anyOf"] {
366        if let Some(js_list) = json_schema.get(combiner).and_then(|v| v.as_array())
367            && let Some(oa_list) = openapi.get_mut(combiner).and_then(|v| v.as_sequence_mut())
368        {
369            for (i, js_entry) in js_list.iter().enumerate() {
370                if i >= oa_list.len() {
371                    break;
372                }
373                let resolved: std::borrow::Cow<serde_json::Value> =
374                    if let Some(ref_str) = js_entry.get("$ref").and_then(|v| v.as_str()) {
375                        match resolve_ref(ref_str, defs) {
376                            Some(r) => std::borrow::Cow::Borrowed(r),
377                            None => std::borrow::Cow::Borrowed(js_entry),
378                        }
379                    } else {
380                        std::borrow::Cow::Borrowed(js_entry)
381                    };
382                enrich_schema(&mut oa_list[i], &resolved, defs, camel_case);
383            }
384        }
385    }
386}
387
388// ── Pass 2 ────────────────────────────────────────────────────────────────────
389
390fn dedup_path_params(spec: &mut YamlValue, metadata: &CodeGenMetadata) {
391    let mut removed_total = 0usize;
392
393    for service in metadata.services.values() {
394        for method in &service.methods {
395            let path_params: HashSet<String> =
396                method.http_pattern.parameters.iter().cloned().collect();
397            if path_params.is_empty() {
398                continue;
399            }
400
401            let input_type = method
402                .input_type
403                .rfind('.')
404                .map(|i| &method.input_type[i + 1..])
405                .unwrap_or(&method.input_type);
406
407            let schema = spec
408                .get_mut("components")
409                .and_then(|c| c.get_mut("schemas"))
410                .and_then(|s| s.get_mut(input_type));
411
412            let schema = match schema {
413                Some(s) => s,
414                None => continue,
415            };
416
417            // Remove from properties
418            if let Some(props) = schema
419                .get_mut("properties")
420                .and_then(|p| p.as_mapping_mut())
421            {
422                let before = props.len();
423                props.retain(|k, _| {
424                    let key = k.as_str().unwrap_or("");
425                    !path_params.contains(key)
426                });
427                removed_total += before - props.len();
428            }
429
430            // Remove from required array
431            if let Some(required) = schema.get_mut("required").and_then(|r| r.as_sequence_mut()) {
432                required.retain(|v| {
433                    let key = v.as_str().unwrap_or("");
434                    !path_params.contains(key)
435                });
436            }
437        }
438    }
439
440    if removed_total > 0 {
441        println!(
442            "enrich-openapi: dedup removed {removed_total} path-bound field(s) from request body schemas"
443        );
444    }
445}
446
447// ── YAML helpers ──────────────────────────────────────────────────────────────
448
449fn yaml_set(target: &mut YamlValue, key: &str, value: YamlValue) {
450    if let YamlValue::Mapping(m) = target {
451        m.insert(YamlValue::String(key.to_string()), value);
452    }
453}
454
455fn json_to_yaml(v: &serde_json::Value) -> YamlValue {
456    match v {
457        serde_json::Value::Null => YamlValue::Null,
458        serde_json::Value::Bool(b) => YamlValue::Bool(*b),
459        serde_json::Value::Number(n) => {
460            if let Some(i) = n.as_i64() {
461                YamlValue::Number(serde_yaml::Number::from(i))
462            } else if let Some(f) = n.as_f64() {
463                YamlValue::Number(serde_yaml::Number::from(f))
464            } else {
465                YamlValue::String(n.to_string())
466            }
467        }
468        serde_json::Value::String(s) => YamlValue::String(s.clone()),
469        serde_json::Value::Array(arr) => {
470            YamlValue::Sequence(arr.iter().map(json_to_yaml).collect())
471        }
472        serde_json::Value::Object(obj) => {
473            let mut mapping = serde_yaml::Mapping::new();
474            for (k, val) in obj {
475                mapping.insert(YamlValue::String(k.clone()), json_to_yaml(val));
476            }
477            YamlValue::Mapping(mapping)
478        }
479    }
480}