Skip to main content

olai_codegen/
openapi_enrich.rs

1use std::collections::{BTreeMap, 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), optionally Pass 2
14/// (path/body dedup), and Pass 3 (schema-key aliasing).
15pub fn run(
16    spec: &Path,
17    jsonschema_dir: &Path,
18    camel_case: bool,
19    descriptors: Option<&Path>,
20    schema_renames: &BTreeMap<String, String>,
21    schema_overrides: &HashSet<String>,
22) -> Result<()> {
23    let spec_str = fs::read_to_string(spec)
24        .map_err(|e| crate::Error::Build(format!("Failed to read {}: {}", spec.display(), e)))?;
25    let mut doc: YamlValue = serde_yaml::from_str(&spec_str).map_err(|e| {
26        crate::Error::Build(format!("Failed to parse YAML {}: {}", spec.display(), e))
27    })?;
28
29    fix_gnostic_refs(&mut doc);
30    enrich_from_jsonschema(&mut doc, jsonschema_dir, camel_case, schema_overrides)?;
31
32    if let Some(desc_path) = descriptors {
33        let bytes = fs::read(desc_path).map_err(|e| {
34            crate::Error::Build(format!(
35                "Failed to read descriptors {}: {}",
36                desc_path.display(),
37                e
38            ))
39        })?;
40        let fds = protobuf::descriptor::FileDescriptorSet::parse_from_bytes(&bytes)
41            .map_err(|e| crate::Error::Build(format!("Failed to parse descriptors: {e}")))?;
42        let metadata = parse_file_descriptor_set(&fds)?;
43        dedup_path_params(&mut doc, &metadata);
44    }
45
46    rename_schemas(&mut doc, schema_renames);
47
48    let out = serde_yaml::to_string(&doc)
49        .map_err(|e| crate::Error::Build(format!("Failed to serialize YAML: {e}")))?;
50    fs::write(spec, out)
51        .map_err(|e| crate::Error::Build(format!("Failed to write {}: {}", spec.display(), e)))?;
52
53    Ok(())
54}
55
56// ── Pass 0 ────────────────────────────────────────────────────────────────────
57
58/// Rewrite gnostic-generated `#/$defs/a.b.v1.TypeName.schema.strict.json` refs to
59/// valid OpenAPI `#/components/schemas/TypeName` refs throughout the document.
60fn fix_gnostic_refs(doc: &mut YamlValue) {
61    match doc {
62        YamlValue::Mapping(map) => {
63            if let Some(YamlValue::String(s)) = map.get_mut("$ref")
64                && let Some(fixed) = rewrite_gnostic_ref(s)
65            {
66                *s = fixed;
67            }
68            for (_, v) in map.iter_mut() {
69                fix_gnostic_refs(v);
70            }
71        }
72        YamlValue::Sequence(seq) => {
73            for item in seq.iter_mut() {
74                fix_gnostic_refs(item);
75            }
76        }
77        _ => {}
78    }
79}
80
81fn rewrite_gnostic_ref(ref_str: &str) -> Option<String> {
82    let stem = ref_str
83        .strip_prefix("#/$defs/")?
84        .strip_suffix(".schema.strict.json")?;
85    let start = stem.find(|c: char| c.is_uppercase())?;
86    let type_name = &stem[start..];
87    Some(format!("#/components/schemas/{type_name}"))
88}
89
90// ── Pass 1 ────────────────────────────────────────────────────────────────────
91
92fn enrich_from_jsonschema(
93    spec: &mut YamlValue,
94    jsonschema_dir: &Path,
95    camel_case: bool,
96    schema_overrides: &HashSet<String>,
97) -> Result<()> {
98    let pattern = jsonschema_dir
99        .join("*.schema.strict.bundle.json")
100        .to_string_lossy()
101        .into_owned();
102
103    let mut files: Vec<std::path::PathBuf> = glob(&pattern)
104        .map_err(|e| crate::Error::Build(format!("Glob pattern error: {e}")))?
105        .filter_map(|r: Result<std::path::PathBuf, _>| r.ok())
106        .collect();
107    files.sort();
108
109    if files.is_empty() {
110        eprintln!(
111            "enrich-openapi: no JSON Schema files found in {}",
112            jsonschema_dir.display()
113        );
114        return Ok(());
115    }
116
117    let mut updated = 0usize;
118    let mut added = 0usize;
119
120    for path in &files {
121        let filename = path
122            .file_name()
123            .and_then(|f: &std::ffi::OsStr| f.to_str())
124            .unwrap_or_default();
125        let type_name = match type_name_from_filename(filename) {
126            Some(n) => n,
127            None => {
128                eprintln!("enrich-openapi: skipping {filename} (cannot extract type name)");
129                continue;
130            }
131        };
132
133        let content = fs::read_to_string(path).map_err(|e| {
134            crate::Error::Build(format!("Failed to read {}: {}", path.display(), e))
135        })?;
136        let bundle: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
137            crate::Error::Build(format!("Failed to parse JSON {}: {}", path.display(), e))
138        })?;
139
140        let defs: JsonMap<String, serde_json::Value> = bundle
141            .get("$defs")
142            .and_then(|v: &serde_json::Value| v.as_object())
143            .cloned()
144            .unwrap_or_default();
145
146        let root_ref = bundle
147            .get("$ref")
148            .and_then(|v: &serde_json::Value| v.as_str())
149            .unwrap_or("");
150        // Surface an unexpected `$ref` shape instead of silently coercing it to an empty key,
151        // which would mask malformed bundles behind a generic "could not resolve" message.
152        let root_key = match root_ref.strip_prefix("#/$defs/") {
153            Some(key) => key,
154            None => {
155                eprintln!(
156                    "enrich-openapi: unexpected root $ref format '{root_ref}' for {type_name} \
157                     (expected '#/$defs/<name>'), skipping"
158                );
159                continue;
160            }
161        };
162        let root_schema: serde_json::Value = match defs.get(root_key) {
163            Some(s) => s.clone(),
164            None => {
165                eprintln!(
166                    "enrich-openapi: could not resolve root $ref '{root_ref}' for {type_name}, skipping"
167                );
168                continue;
169            }
170        };
171
172        // Navigate to components.schemas.<TypeName>
173        let schemas = spec
174            .get_mut("components")
175            .and_then(|c| c.get_mut("schemas"));
176        let schemas = match schemas {
177            Some(s) => s,
178            None => {
179                eprintln!("enrich-openapi: openapi.yaml has no components.schemas, skipping");
180                break;
181            }
182        };
183
184        let exists = schemas
185            .as_mapping()
186            .map(|m| m.contains_key(type_name.as_str()))
187            .unwrap_or(false);
188
189        if !exists {
190            let ty = root_schema
191                .get("type")
192                .and_then(|v: &serde_json::Value| v.as_str())
193                .unwrap_or("object");
194            if let Some(map) = schemas.as_mapping_mut() {
195                let mut entry = serde_yaml::Mapping::new();
196                entry.insert(
197                    YamlValue::String("type".into()),
198                    YamlValue::String(ty.to_string()),
199                );
200                map.insert(
201                    YamlValue::String(type_name.clone()),
202                    YamlValue::Mapping(entry),
203                );
204            }
205            added += 1;
206        } else {
207            updated += 1;
208        }
209
210        let oa_schema = schemas
211            .as_mapping_mut()
212            .and_then(|m| m.get_mut(type_name.as_str()));
213        if let Some(oa) = oa_schema {
214            // Authoritative override: gnostic can clobber a proto message whose name
215            // collides with one of its own OpenAPI meta-model types (e.g. our
216            // `unitycatalog.schemas.v1.Schema` is overwritten by gnostic's `Schema`),
217            // leaving the emitted component with meta-object fields instead of the
218            // real entity's. For such keys, rebuild the component body from the
219            // authoritative JSON Schema root before the usual validation merge.
220            if schema_overrides.contains(type_name.as_str()) {
221                *oa = json_to_yaml(&root_schema);
222                // Drop JSON-Schema-only keys that must not leak into the OpenAPI spec.
223                if let Some(m) = oa.as_mapping_mut() {
224                    m.remove(YamlValue::String("$schema".into()));
225                }
226            }
227            enrich_schema(oa, &root_schema, &defs, camel_case);
228        }
229    }
230
231    println!(
232        "enrich-openapi: enriched {} schemas ({} updated, {} added)",
233        updated + added,
234        updated,
235        added
236    );
237    Ok(())
238}
239
240/// Extract type name from a JSON Schema bundle filename.
241/// e.g. "dda.coordinator.v1.CreateWaveRequest.schema.strict.bundle.json" → "CreateWaveRequest"
242fn type_name_from_filename(filename: &str) -> Option<String> {
243    let stem = filename.strip_suffix(".schema.strict.bundle.json")?;
244    let start = stem.find(|c: char| c.is_uppercase())?;
245    Some(stem[start..].to_string())
246}
247
248/// Convert snake_case to camelCase.
249fn snake_to_camel(s: &str) -> String {
250    let mut result = String::with_capacity(s.len());
251    let mut capitalise_next = false;
252    for ch in s.chars() {
253        if ch == '_' {
254            capitalise_next = true;
255        } else if capitalise_next {
256            result.push(ch.to_ascii_uppercase());
257            capitalise_next = false;
258        } else {
259            result.push(ch);
260        }
261    }
262    result
263}
264
265/// Resolve a `#/$defs/<key>` reference within a bundle's $defs map.
266fn resolve_ref<'a>(
267    ref_str: &str,
268    defs: &'a JsonMap<String, serde_json::Value>,
269) -> Option<&'a serde_json::Value> {
270    let key = ref_str.strip_prefix("#/$defs/")?;
271    defs.get(key)
272}
273
274const VALIDATION_FIELDS: &[&str] = &[
275    "minLength",
276    "maxLength",
277    "pattern",
278    "minimum",
279    "maximum",
280    "exclusiveMinimum",
281    "exclusiveMaximum",
282    "minItems",
283    "maxItems",
284    "enum",
285    "additionalProperties",
286    "required",
287    "description",
288    "title",
289];
290
291/// Merge validation fields from a JSON Schema node into an OpenAPI YAML schema node.
292fn merge_validation(source: &serde_json::Value, target: &mut YamlValue) {
293    for &key in VALIDATION_FIELDS {
294        let val = match source.get(key) {
295            Some(v) => v,
296            None => continue,
297        };
298
299        if key == "exclusiveMinimum" {
300            if let Some(n) = val.as_f64() {
301                // JSON Schema 2020-12 numeric form → OpenAPI 3.0 boolean form
302                yaml_set(
303                    target,
304                    "minimum",
305                    YamlValue::Number(serde_yaml::Number::from(n)),
306                );
307                yaml_set(target, "exclusiveMinimum", YamlValue::Bool(true));
308            }
309            continue;
310        }
311
312        if key == "exclusiveMaximum" {
313            if let Some(n) = val.as_f64() {
314                // JSON Schema 2020-12 numeric form → OpenAPI 3.0 boolean form
315                yaml_set(
316                    target,
317                    "maximum",
318                    YamlValue::Number(serde_yaml::Number::from(n)),
319                );
320                yaml_set(target, "exclusiveMaximum", YamlValue::Bool(true));
321            }
322            continue;
323        }
324
325        yaml_set(target, key, json_to_yaml(val));
326    }
327}
328
329/// Recursively enrich an OpenAPI schema YAML node with validation from a JSON Schema node.
330fn enrich_schema(
331    openapi: &mut YamlValue,
332    json_schema: &serde_json::Value,
333    defs: &JsonMap<String, serde_json::Value>,
334    camel_case: bool,
335) {
336    merge_validation(json_schema, openapi);
337
338    // Recurse into properties
339    if let Some(js_props) = json_schema.get("properties").and_then(|v| v.as_object()) {
340        for (snake_key, js_prop) in js_props {
341            let lookup_key = if camel_case {
342                snake_to_camel(snake_key)
343            } else {
344                snake_key.clone()
345            };
346
347            let resolved: std::borrow::Cow<serde_json::Value> =
348                if let Some(ref_str) = js_prop.get("$ref").and_then(|v| v.as_str()) {
349                    match resolve_ref(ref_str, defs) {
350                        Some(r) => std::borrow::Cow::Borrowed(r),
351                        None => std::borrow::Cow::Borrowed(js_prop),
352                    }
353                } else {
354                    std::borrow::Cow::Borrowed(js_prop)
355                };
356
357            if let Some(oa_prop) = openapi
358                .get_mut("properties")
359                .and_then(|p| p.get_mut(lookup_key.as_str()))
360            {
361                enrich_schema(oa_prop, &resolved, defs, camel_case);
362            }
363        }
364    }
365
366    // Recurse into items
367    if let Some(js_items) = json_schema.get("items") {
368        let resolved: std::borrow::Cow<serde_json::Value> =
369            if let Some(ref_str) = js_items.get("$ref").and_then(|v| v.as_str()) {
370                match resolve_ref(ref_str, defs) {
371                    Some(r) => std::borrow::Cow::Borrowed(r),
372                    None => std::borrow::Cow::Borrowed(js_items),
373                }
374            } else {
375                std::borrow::Cow::Borrowed(js_items)
376            };
377
378        if let Some(oa_items) = openapi.get_mut("items") {
379            enrich_schema(oa_items, &resolved, defs, camel_case);
380        }
381    }
382
383    // Recurse into combiners
384    for combiner in &["allOf", "oneOf", "anyOf"] {
385        if let Some(js_list) = json_schema.get(combiner).and_then(|v| v.as_array())
386            && let Some(oa_list) = openapi.get_mut(combiner).and_then(|v| v.as_sequence_mut())
387        {
388            for (i, js_entry) in js_list.iter().enumerate() {
389                if i >= oa_list.len() {
390                    break;
391                }
392                let resolved: std::borrow::Cow<serde_json::Value> =
393                    if let Some(ref_str) = js_entry.get("$ref").and_then(|v| v.as_str()) {
394                        match resolve_ref(ref_str, defs) {
395                            Some(r) => std::borrow::Cow::Borrowed(r),
396                            None => std::borrow::Cow::Borrowed(js_entry),
397                        }
398                    } else {
399                        std::borrow::Cow::Borrowed(js_entry)
400                    };
401                enrich_schema(&mut oa_list[i], &resolved, defs, camel_case);
402            }
403        }
404    }
405}
406
407// ── Pass 2 ────────────────────────────────────────────────────────────────────
408
409fn dedup_path_params(spec: &mut YamlValue, metadata: &CodeGenMetadata) {
410    let mut removed_total = 0usize;
411
412    for service in metadata.services.values() {
413        for method in &service.methods {
414            let path_params: HashSet<String> =
415                method.http_pattern.parameters.iter().cloned().collect();
416            if path_params.is_empty() {
417                continue;
418            }
419
420            let input_type = method
421                .input_type
422                .rfind('.')
423                .map(|i| &method.input_type[i + 1..])
424                .unwrap_or(&method.input_type);
425
426            let schema = spec
427                .get_mut("components")
428                .and_then(|c| c.get_mut("schemas"))
429                .and_then(|s| s.get_mut(input_type));
430
431            let schema = match schema {
432                Some(s) => s,
433                None => continue,
434            };
435
436            // Remove from properties
437            if let Some(props) = schema
438                .get_mut("properties")
439                .and_then(|p| p.as_mapping_mut())
440            {
441                let before = props.len();
442                props.retain(|k, _| {
443                    let key = k.as_str().unwrap_or("");
444                    !path_params.contains(key)
445                });
446                removed_total += before - props.len();
447            }
448
449            // Remove from required array
450            if let Some(required) = schema.get_mut("required").and_then(|r| r.as_sequence_mut()) {
451                required.retain(|v| {
452                    let key = v.as_str().unwrap_or("");
453                    !path_params.contains(key)
454                });
455            }
456        }
457    }
458
459    if removed_total > 0 {
460        println!(
461            "enrich-openapi: dedup removed {removed_total} path-bound field(s) from request body schemas"
462        );
463    }
464}
465
466// ── Pass 3 ──────────────────────────────────────────────────────────────────
467
468/// Rename `components/schemas` map keys and rewrite every `$ref` that points at a
469/// renamed key.
470///
471/// The OpenAPI schema key is derived by gnostic from the proto message name, and
472/// gnostic offers no per-message key override. This pass decouples the two: the
473/// proto message (and thus every Rust/Python/Node model type) keeps its clean
474/// name, while the emitted spec presents a different key — used here to align the
475/// spec with the official Unity Catalog `*Info` naming (`Catalog` -> `CatalogInfo`)
476/// so downstream OpenAPI consumers match the reference spec without churning the
477/// internal model API.
478///
479/// `renames` maps current schema key -> desired schema key. Entries whose source
480/// key is absent from the spec are ignored (logged), so a rename table can name
481/// keys that only some builds emit.
482fn rename_schemas(spec: &mut YamlValue, renames: &BTreeMap<String, String>) {
483    if renames.is_empty() {
484        return;
485    }
486
487    // Rename the component definitions themselves. Preserve map order by rebuilding
488    // the schemas mapping with keys substituted in place.
489    let mut applied = 0usize;
490    if let Some(schemas) = spec
491        .get_mut("components")
492        .and_then(|c| c.get_mut("schemas"))
493        .and_then(|s| s.as_mapping_mut())
494    {
495        let mut rebuilt = serde_yaml::Mapping::new();
496        for (k, v) in std::mem::take(schemas) {
497            match k.as_str().and_then(|key| renames.get(key)) {
498                Some(new_key) => {
499                    rebuilt.insert(YamlValue::String(new_key.clone()), v);
500                    applied += 1;
501                }
502                None => {
503                    rebuilt.insert(k, v);
504                }
505            }
506        }
507        *schemas = rebuilt;
508    }
509
510    // Rewrite every `$ref: #/components/schemas/<old>` to the new key, everywhere.
511    rewrite_schema_refs(spec, renames);
512
513    let requested = renames.len();
514    if applied < requested {
515        eprintln!(
516            "enrich-openapi: schema rename applied {applied}/{requested} (some source keys were absent from the spec)"
517        );
518    } else {
519        eprintln!("enrich-openapi: schema rename applied {applied} key(s)");
520    }
521}
522
523fn rewrite_schema_refs(node: &mut YamlValue, renames: &BTreeMap<String, String>) {
524    match node {
525        YamlValue::Mapping(map) => {
526            if let Some(YamlValue::String(s)) = map.get_mut("$ref")
527                && let Some(old) = s.strip_prefix("#/components/schemas/")
528                && let Some(new_key) = renames.get(old)
529            {
530                *s = format!("#/components/schemas/{new_key}");
531            }
532            for (_, v) in map.iter_mut() {
533                rewrite_schema_refs(v, renames);
534            }
535        }
536        YamlValue::Sequence(seq) => {
537            for item in seq.iter_mut() {
538                rewrite_schema_refs(item, renames);
539            }
540        }
541        _ => {}
542    }
543}
544
545// ── YAML helpers ──────────────────────────────────────────────────────────────
546
547fn yaml_set(target: &mut YamlValue, key: &str, value: YamlValue) {
548    if let YamlValue::Mapping(m) = target {
549        m.insert(YamlValue::String(key.to_string()), value);
550    }
551}
552
553fn json_to_yaml(v: &serde_json::Value) -> YamlValue {
554    match v {
555        serde_json::Value::Null => YamlValue::Null,
556        serde_json::Value::Bool(b) => YamlValue::Bool(*b),
557        serde_json::Value::Number(n) => {
558            if let Some(i) = n.as_i64() {
559                YamlValue::Number(serde_yaml::Number::from(i))
560            } else if let Some(f) = n.as_f64() {
561                YamlValue::Number(serde_yaml::Number::from(f))
562            } else {
563                YamlValue::String(n.to_string())
564            }
565        }
566        serde_json::Value::String(s) => YamlValue::String(s.clone()),
567        serde_json::Value::Array(arr) => {
568            YamlValue::Sequence(arr.iter().map(json_to_yaml).collect())
569        }
570        serde_json::Value::Object(obj) => {
571            let mut mapping = serde_yaml::Mapping::new();
572            for (k, val) in obj {
573                mapping.insert(YamlValue::String(k.clone()), json_to_yaml(val));
574            }
575            YamlValue::Mapping(mapping)
576        }
577    }
578}