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 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
265fn 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
291fn 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 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 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
329fn 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 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 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 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
407fn 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 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 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
466fn rename_schemas(spec: &mut YamlValue, renames: &BTreeMap<String, String>) {
483 if renames.is_empty() {
484 return;
485 }
486
487 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_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
545fn 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}