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
14pub 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
49fn 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
54fn 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
88fn 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 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 if schema_overrides.contains(type_name.as_str()) {
157 *oa = json_to_yaml(&root_schema);
158 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
176struct SchemaBundle {
178 type_name: String,
180 defs: JsonMap<String, serde_json::Value>,
182 root_schema: serde_json::Value,
184}
185
186fn 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 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
234fn 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
267fn 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
275fn 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
284fn 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
313fn 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 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
343fn 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 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 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 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
389fn 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 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 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
448fn rename_schemas(spec: &mut YamlValue, renames: &BTreeMap<String, String>) {
464 if renames.is_empty() {
465 return;
466 }
467
468 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_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
526fn 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}