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