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
13pub 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
56fn 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
90fn 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 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 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 if schema_overrides.contains(type_name.as_str()) {
221 *oa = json_to_yaml(&root_schema);
222 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
240fn 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
248fn 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
274fn 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 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 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
312fn 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 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 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 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
390fn 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 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 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
449fn rename_schemas(spec: &mut YamlValue, renames: &BTreeMap<String, String>) {
466 if renames.is_empty() {
467 return;
468 }
469
470 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_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
528fn 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}