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/// Resolve a `#/$defs/<key>` reference within a bundle's $defs map.
249fn resolve_ref<'a>(
250    ref_str: &str,
251    defs: &'a JsonMap<String, serde_json::Value>,
252) -> Option<&'a serde_json::Value> {
253    let key = ref_str.strip_prefix("#/$defs/")?;
254    defs.get(key)
255}
256
257const VALIDATION_FIELDS: &[&str] = &[
258    "minLength",
259    "maxLength",
260    "pattern",
261    "minimum",
262    "maximum",
263    "exclusiveMinimum",
264    "exclusiveMaximum",
265    "minItems",
266    "maxItems",
267    "enum",
268    "additionalProperties",
269    "required",
270    "description",
271    "title",
272];
273
274/// Merge validation fields from a JSON Schema node into an OpenAPI YAML schema node.
275fn merge_validation(source: &serde_json::Value, target: &mut YamlValue) {
276    for &key in VALIDATION_FIELDS {
277        let val = match source.get(key) {
278            Some(v) => v,
279            None => continue,
280        };
281
282        if key == "exclusiveMinimum" {
283            if let Some(n) = val.as_f64() {
284                // JSON Schema 2020-12 numeric form → OpenAPI 3.0 boolean form
285                yaml_set(
286                    target,
287                    "minimum",
288                    YamlValue::Number(serde_yaml::Number::from(n)),
289                );
290                yaml_set(target, "exclusiveMinimum", YamlValue::Bool(true));
291            }
292            continue;
293        }
294
295        if key == "exclusiveMaximum" {
296            if let Some(n) = val.as_f64() {
297                // JSON Schema 2020-12 numeric form → OpenAPI 3.0 boolean form
298                yaml_set(
299                    target,
300                    "maximum",
301                    YamlValue::Number(serde_yaml::Number::from(n)),
302                );
303                yaml_set(target, "exclusiveMaximum", YamlValue::Bool(true));
304            }
305            continue;
306        }
307
308        yaml_set(target, key, json_to_yaml(val));
309    }
310}
311
312/// Recursively enrich an OpenAPI schema YAML node with validation from a JSON Schema node.
313fn enrich_schema(
314    openapi: &mut YamlValue,
315    json_schema: &serde_json::Value,
316    defs: &JsonMap<String, serde_json::Value>,
317    camel_case: bool,
318) {
319    merge_validation(json_schema, openapi);
320
321    // Recurse into properties
322    if let Some(js_props) = json_schema.get("properties").and_then(|v| v.as_object()) {
323        for (snake_key, js_prop) in js_props {
324            let lookup_key = if camel_case {
325                crate::utils::strings::lower_camel(snake_key)
326            } else {
327                snake_key.clone()
328            };
329
330            let resolved: std::borrow::Cow<serde_json::Value> =
331                if let Some(ref_str) = js_prop.get("$ref").and_then(|v| v.as_str()) {
332                    match resolve_ref(ref_str, defs) {
333                        Some(r) => std::borrow::Cow::Borrowed(r),
334                        None => std::borrow::Cow::Borrowed(js_prop),
335                    }
336                } else {
337                    std::borrow::Cow::Borrowed(js_prop)
338                };
339
340            if let Some(oa_prop) = openapi
341                .get_mut("properties")
342                .and_then(|p| p.get_mut(lookup_key.as_str()))
343            {
344                enrich_schema(oa_prop, &resolved, defs, camel_case);
345            }
346        }
347    }
348
349    // Recurse into items
350    if let Some(js_items) = json_schema.get("items") {
351        let resolved: std::borrow::Cow<serde_json::Value> =
352            if let Some(ref_str) = js_items.get("$ref").and_then(|v| v.as_str()) {
353                match resolve_ref(ref_str, defs) {
354                    Some(r) => std::borrow::Cow::Borrowed(r),
355                    None => std::borrow::Cow::Borrowed(js_items),
356                }
357            } else {
358                std::borrow::Cow::Borrowed(js_items)
359            };
360
361        if let Some(oa_items) = openapi.get_mut("items") {
362            enrich_schema(oa_items, &resolved, defs, camel_case);
363        }
364    }
365
366    // Recurse into combiners
367    for combiner in &["allOf", "oneOf", "anyOf"] {
368        if let Some(js_list) = json_schema.get(combiner).and_then(|v| v.as_array())
369            && let Some(oa_list) = openapi.get_mut(combiner).and_then(|v| v.as_sequence_mut())
370        {
371            for (i, js_entry) in js_list.iter().enumerate() {
372                if i >= oa_list.len() {
373                    break;
374                }
375                let resolved: std::borrow::Cow<serde_json::Value> =
376                    if let Some(ref_str) = js_entry.get("$ref").and_then(|v| v.as_str()) {
377                        match resolve_ref(ref_str, defs) {
378                            Some(r) => std::borrow::Cow::Borrowed(r),
379                            None => std::borrow::Cow::Borrowed(js_entry),
380                        }
381                    } else {
382                        std::borrow::Cow::Borrowed(js_entry)
383                    };
384                enrich_schema(&mut oa_list[i], &resolved, defs, camel_case);
385            }
386        }
387    }
388}
389
390// ── Pass 2 ────────────────────────────────────────────────────────────────────
391
392fn dedup_path_params(spec: &mut YamlValue, metadata: &CodeGenMetadata) {
393    let mut removed_total = 0usize;
394
395    for service in metadata.services.values() {
396        for method in &service.methods {
397            let path_params: HashSet<String> =
398                method.http_pattern.parameters.iter().cloned().collect();
399            if path_params.is_empty() {
400                continue;
401            }
402
403            let input_type = method
404                .input_type
405                .rfind('.')
406                .map(|i| &method.input_type[i + 1..])
407                .unwrap_or(&method.input_type);
408
409            let schema = spec
410                .get_mut("components")
411                .and_then(|c| c.get_mut("schemas"))
412                .and_then(|s| s.get_mut(input_type));
413
414            let schema = match schema {
415                Some(s) => s,
416                None => continue,
417            };
418
419            // Remove from properties
420            if let Some(props) = schema
421                .get_mut("properties")
422                .and_then(|p| p.as_mapping_mut())
423            {
424                let before = props.len();
425                props.retain(|k, _| {
426                    let key = k.as_str().unwrap_or("");
427                    !path_params.contains(key)
428                });
429                removed_total += before - props.len();
430            }
431
432            // Remove from required array
433            if let Some(required) = schema.get_mut("required").and_then(|r| r.as_sequence_mut()) {
434                required.retain(|v| {
435                    let key = v.as_str().unwrap_or("");
436                    !path_params.contains(key)
437                });
438            }
439        }
440    }
441
442    if removed_total > 0 {
443        println!(
444            "enrich-openapi: dedup removed {removed_total} path-bound field(s) from request body schemas"
445        );
446    }
447}
448
449// ── Pass 3 ──────────────────────────────────────────────────────────────────
450
451/// Rename `components/schemas` map keys and rewrite every `$ref` that points at a
452/// renamed key.
453///
454/// The OpenAPI schema key is derived by gnostic from the proto message name, and
455/// gnostic offers no per-message key override. This pass decouples the two: the
456/// proto message (and thus every Rust/Python/Node model type) keeps its clean
457/// name, while the emitted spec presents a different key — used here to align the
458/// spec with the official Unity Catalog `*Info` naming (`Catalog` -> `CatalogInfo`)
459/// so downstream OpenAPI consumers match the reference spec without churning the
460/// internal model API.
461///
462/// `renames` maps current schema key -> desired schema key. Entries whose source
463/// key is absent from the spec are ignored (logged), so a rename table can name
464/// keys that only some builds emit.
465fn rename_schemas(spec: &mut YamlValue, renames: &BTreeMap<String, String>) {
466    if renames.is_empty() {
467        return;
468    }
469
470    // Rename the component definitions themselves. Preserve map order by rebuilding
471    // the schemas mapping with keys substituted in place.
472    let mut applied = 0usize;
473    if let Some(schemas) = spec
474        .get_mut("components")
475        .and_then(|c| c.get_mut("schemas"))
476        .and_then(|s| s.as_mapping_mut())
477    {
478        let mut rebuilt = serde_yaml::Mapping::new();
479        for (k, v) in std::mem::take(schemas) {
480            match k.as_str().and_then(|key| renames.get(key)) {
481                Some(new_key) => {
482                    rebuilt.insert(YamlValue::String(new_key.clone()), v);
483                    applied += 1;
484                }
485                None => {
486                    rebuilt.insert(k, v);
487                }
488            }
489        }
490        *schemas = rebuilt;
491    }
492
493    // Rewrite every `$ref: #/components/schemas/<old>` to the new key, everywhere.
494    rewrite_schema_refs(spec, renames);
495
496    let requested = renames.len();
497    if applied < requested {
498        eprintln!(
499            "enrich-openapi: schema rename applied {applied}/{requested} (some source keys were absent from the spec)"
500        );
501    } else {
502        eprintln!("enrich-openapi: schema rename applied {applied} key(s)");
503    }
504}
505
506fn rewrite_schema_refs(node: &mut YamlValue, renames: &BTreeMap<String, String>) {
507    match node {
508        YamlValue::Mapping(map) => {
509            if let Some(YamlValue::String(s)) = map.get_mut("$ref")
510                && let Some(old) = s.strip_prefix("#/components/schemas/")
511                && let Some(new_key) = renames.get(old)
512            {
513                *s = format!("#/components/schemas/{new_key}");
514            }
515            for (_, v) in map.iter_mut() {
516                rewrite_schema_refs(v, renames);
517            }
518        }
519        YamlValue::Sequence(seq) => {
520            for item in seq.iter_mut() {
521                rewrite_schema_refs(item, renames);
522            }
523        }
524        _ => {}
525    }
526}
527
528// ── YAML helpers ──────────────────────────────────────────────────────────────
529
530fn yaml_set(target: &mut YamlValue, key: &str, value: YamlValue) {
531    if let YamlValue::Mapping(m) = target {
532        m.insert(YamlValue::String(key.to_string()), value);
533    }
534}
535
536fn json_to_yaml(v: &serde_json::Value) -> YamlValue {
537    match v {
538        serde_json::Value::Null => YamlValue::Null,
539        serde_json::Value::Bool(b) => YamlValue::Bool(*b),
540        serde_json::Value::Number(n) => {
541            if let Some(i) = n.as_i64() {
542                YamlValue::Number(serde_yaml::Number::from(i))
543            } else if let Some(f) = n.as_f64() {
544                YamlValue::Number(serde_yaml::Number::from(f))
545            } else {
546                YamlValue::String(n.to_string())
547            }
548        }
549        serde_json::Value::String(s) => YamlValue::String(s.clone()),
550        serde_json::Value::Array(arr) => {
551            YamlValue::Sequence(arr.iter().map(json_to_yaml).collect())
552        }
553        serde_json::Value::Object(obj) => {
554            let mut mapping = serde_yaml::Mapping::new();
555            for (k, val) in obj {
556                mapping.insert(YamlValue::String(k.clone()), json_to_yaml(val));
557            }
558            YamlValue::Mapping(mapping)
559        }
560    }
561}