1use std::collections::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(
15 spec: &Path,
16 jsonschema_dir: &Path,
17 camel_case: bool,
18 descriptors: Option<&Path>,
19) -> Result<()> {
20 let spec_str = fs::read_to_string(spec)
21 .map_err(|e| crate::Error::Build(format!("Failed to read {}: {}", spec.display(), e)))?;
22 let mut doc: YamlValue = serde_yaml::from_str(&spec_str).map_err(|e| {
23 crate::Error::Build(format!("Failed to parse YAML {}: {}", spec.display(), e))
24 })?;
25
26 fix_gnostic_refs(&mut doc);
27 enrich_from_jsonschema(&mut doc, jsonschema_dir, camel_case)?;
28
29 if let Some(desc_path) = descriptors {
30 let bytes = fs::read(desc_path).map_err(|e| {
31 crate::Error::Build(format!(
32 "Failed to read descriptors {}: {}",
33 desc_path.display(),
34 e
35 ))
36 })?;
37 let fds = protobuf::descriptor::FileDescriptorSet::parse_from_bytes(&bytes)
38 .map_err(|e| crate::Error::Build(format!("Failed to parse descriptors: {}", e)))?;
39 let metadata = parse_file_descriptor_set(&fds)?;
40 dedup_path_params(&mut doc, &metadata);
41 }
42
43 let out = serde_yaml::to_string(&doc)
44 .map_err(|e| crate::Error::Build(format!("Failed to serialize YAML: {}", e)))?;
45 fs::write(spec, out)
46 .map_err(|e| crate::Error::Build(format!("Failed to write {}: {}", spec.display(), e)))?;
47
48 Ok(())
49}
50
51fn fix_gnostic_refs(doc: &mut YamlValue) {
56 match doc {
57 YamlValue::Mapping(map) => {
58 if let Some(YamlValue::String(s)) = map.get_mut("$ref") {
59 if let Some(fixed) = rewrite_gnostic_ref(s) {
60 *s = fixed;
61 }
62 }
63 for (_, v) in map.iter_mut() {
64 fix_gnostic_refs(v);
65 }
66 }
67 YamlValue::Sequence(seq) => {
68 for item in seq.iter_mut() {
69 fix_gnostic_refs(item);
70 }
71 }
72 _ => {}
73 }
74}
75
76fn rewrite_gnostic_ref(ref_str: &str) -> Option<String> {
77 let stem = ref_str
78 .strip_prefix("#/$defs/")?
79 .strip_suffix(".schema.strict.json")?;
80 let start = stem.find(|c: char| c.is_uppercase())?;
81 let type_name = &stem[start..];
82 Some(format!("#/components/schemas/{type_name}"))
83}
84
85fn enrich_from_jsonschema(
88 spec: &mut YamlValue,
89 jsonschema_dir: &Path,
90 camel_case: bool,
91) -> Result<()> {
92 let pattern = jsonschema_dir
93 .join("*.schema.strict.bundle.json")
94 .to_string_lossy()
95 .into_owned();
96
97 let mut files: Vec<std::path::PathBuf> = glob(&pattern)
98 .map_err(|e| crate::Error::Build(format!("Glob pattern error: {}", e)))?
99 .filter_map(|r: Result<std::path::PathBuf, _>| r.ok())
100 .collect();
101 files.sort();
102
103 if files.is_empty() {
104 eprintln!(
105 "enrich-openapi: no JSON Schema files found in {}",
106 jsonschema_dir.display()
107 );
108 return Ok(());
109 }
110
111 let mut updated = 0usize;
112 let mut added = 0usize;
113
114 for path in &files {
115 let filename = path
116 .file_name()
117 .and_then(|f: &std::ffi::OsStr| f.to_str())
118 .unwrap_or_default();
119 let type_name = match type_name_from_filename(filename) {
120 Some(n) => n,
121 None => {
122 eprintln!("enrich-openapi: skipping {filename} (cannot extract type name)");
123 continue;
124 }
125 };
126
127 let content = fs::read_to_string(path).map_err(|e| {
128 crate::Error::Build(format!("Failed to read {}: {}", path.display(), e))
129 })?;
130 let bundle: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
131 crate::Error::Build(format!("Failed to parse JSON {}: {}", path.display(), e))
132 })?;
133
134 let defs: JsonMap<String, serde_json::Value> = bundle
135 .get("$defs")
136 .and_then(|v: &serde_json::Value| v.as_object())
137 .cloned()
138 .unwrap_or_default();
139
140 let root_ref = bundle
141 .get("$ref")
142 .and_then(|v: &serde_json::Value| v.as_str())
143 .unwrap_or("");
144 let root_key = match root_ref.strip_prefix("#/$defs/") {
147 Some(key) => key,
148 None => {
149 eprintln!(
150 "enrich-openapi: unexpected root $ref format '{root_ref}' for {type_name} \
151 (expected '#/$defs/<name>'), skipping"
152 );
153 continue;
154 }
155 };
156 let root_schema: serde_json::Value = match defs.get(root_key) {
157 Some(s) => s.clone(),
158 None => {
159 eprintln!(
160 "enrich-openapi: could not resolve root $ref '{root_ref}' for {type_name}, skipping"
161 );
162 continue;
163 }
164 };
165
166 let schemas = spec
168 .get_mut("components")
169 .and_then(|c| c.get_mut("schemas"));
170 let schemas = match schemas {
171 Some(s) => s,
172 None => {
173 eprintln!("enrich-openapi: openapi.yaml has no components.schemas, skipping");
174 break;
175 }
176 };
177
178 let exists = schemas
179 .as_mapping()
180 .map(|m| m.contains_key(type_name.as_str()))
181 .unwrap_or(false);
182
183 if !exists {
184 let ty = root_schema
185 .get("type")
186 .and_then(|v: &serde_json::Value| v.as_str())
187 .unwrap_or("object");
188 if let Some(map) = schemas.as_mapping_mut() {
189 let mut entry = serde_yaml::Mapping::new();
190 entry.insert(
191 YamlValue::String("type".into()),
192 YamlValue::String(ty.to_string()),
193 );
194 map.insert(
195 YamlValue::String(type_name.clone()),
196 YamlValue::Mapping(entry),
197 );
198 }
199 added += 1;
200 } else {
201 updated += 1;
202 }
203
204 let oa_schema = schemas
205 .as_mapping_mut()
206 .and_then(|m| m.get_mut(type_name.as_str()));
207 if let Some(oa) = oa_schema {
208 enrich_schema(oa, &root_schema, &defs, camel_case);
209 }
210 }
211
212 println!(
213 "enrich-openapi: enriched {} schemas ({} updated, {} added)",
214 updated + added,
215 updated,
216 added
217 );
218 Ok(())
219}
220
221fn type_name_from_filename(filename: &str) -> Option<String> {
224 let stem = filename.strip_suffix(".schema.strict.bundle.json")?;
225 let start = stem.find(|c: char| c.is_uppercase())?;
226 Some(stem[start..].to_string())
227}
228
229fn snake_to_camel(s: &str) -> String {
231 let mut result = String::with_capacity(s.len());
232 let mut capitalise_next = false;
233 for ch in s.chars() {
234 if ch == '_' {
235 capitalise_next = true;
236 } else if capitalise_next {
237 result.push(ch.to_ascii_uppercase());
238 capitalise_next = false;
239 } else {
240 result.push(ch);
241 }
242 }
243 result
244}
245
246fn resolve_ref<'a>(
248 ref_str: &str,
249 defs: &'a JsonMap<String, serde_json::Value>,
250) -> Option<&'a serde_json::Value> {
251 let key = ref_str.strip_prefix("#/$defs/")?;
252 defs.get(key)
253}
254
255const VALIDATION_FIELDS: &[&str] = &[
256 "minLength",
257 "maxLength",
258 "pattern",
259 "minimum",
260 "maximum",
261 "exclusiveMinimum",
262 "exclusiveMaximum",
263 "minItems",
264 "maxItems",
265 "enum",
266 "additionalProperties",
267 "required",
268 "description",
269 "title",
270];
271
272fn merge_validation(source: &serde_json::Value, target: &mut YamlValue) {
274 for &key in VALIDATION_FIELDS {
275 let val = match source.get(key) {
276 Some(v) => v,
277 None => continue,
278 };
279
280 if key == "exclusiveMinimum" {
281 if let Some(n) = val.as_f64() {
282 yaml_set(
284 target,
285 "minimum",
286 YamlValue::Number(serde_yaml::Number::from(n)),
287 );
288 yaml_set(target, "exclusiveMinimum", YamlValue::Bool(true));
289 }
290 continue;
291 }
292
293 if key == "exclusiveMaximum" {
294 if let Some(n) = val.as_f64() {
295 yaml_set(
297 target,
298 "maximum",
299 YamlValue::Number(serde_yaml::Number::from(n)),
300 );
301 yaml_set(target, "exclusiveMaximum", YamlValue::Bool(true));
302 }
303 continue;
304 }
305
306 yaml_set(target, key, json_to_yaml(val));
307 }
308}
309
310fn enrich_schema(
312 openapi: &mut YamlValue,
313 json_schema: &serde_json::Value,
314 defs: &JsonMap<String, serde_json::Value>,
315 camel_case: bool,
316) {
317 merge_validation(json_schema, openapi);
318
319 if let Some(js_props) = json_schema.get("properties").and_then(|v| v.as_object()) {
321 for (snake_key, js_prop) in js_props {
322 let lookup_key = if camel_case {
323 snake_to_camel(snake_key)
324 } else {
325 snake_key.clone()
326 };
327
328 let resolved: std::borrow::Cow<serde_json::Value> =
329 if let Some(ref_str) = js_prop.get("$ref").and_then(|v| v.as_str()) {
330 match resolve_ref(ref_str, defs) {
331 Some(r) => std::borrow::Cow::Borrowed(r),
332 None => std::borrow::Cow::Borrowed(js_prop),
333 }
334 } else {
335 std::borrow::Cow::Borrowed(js_prop)
336 };
337
338 if let Some(oa_prop) = openapi
339 .get_mut("properties")
340 .and_then(|p| p.get_mut(lookup_key.as_str()))
341 {
342 enrich_schema(oa_prop, &resolved, defs, camel_case);
343 }
344 }
345 }
346
347 if let Some(js_items) = json_schema.get("items") {
349 let resolved: std::borrow::Cow<serde_json::Value> =
350 if let Some(ref_str) = js_items.get("$ref").and_then(|v| v.as_str()) {
351 match resolve_ref(ref_str, defs) {
352 Some(r) => std::borrow::Cow::Borrowed(r),
353 None => std::borrow::Cow::Borrowed(js_items),
354 }
355 } else {
356 std::borrow::Cow::Borrowed(js_items)
357 };
358
359 if let Some(oa_items) = openapi.get_mut("items") {
360 enrich_schema(oa_items, &resolved, defs, camel_case);
361 }
362 }
363
364 for combiner in &["allOf", "oneOf", "anyOf"] {
366 if let Some(js_list) = json_schema.get(combiner).and_then(|v| v.as_array()) {
367 if let Some(oa_list) = openapi.get_mut(combiner).and_then(|v| v.as_sequence_mut()) {
368 for (i, js_entry) in js_list.iter().enumerate() {
369 if i >= oa_list.len() {
370 break;
371 }
372 let resolved: std::borrow::Cow<serde_json::Value> =
373 if let Some(ref_str) = js_entry.get("$ref").and_then(|v| v.as_str()) {
374 match resolve_ref(ref_str, defs) {
375 Some(r) => std::borrow::Cow::Borrowed(r),
376 None => std::borrow::Cow::Borrowed(js_entry),
377 }
378 } else {
379 std::borrow::Cow::Borrowed(js_entry)
380 };
381 enrich_schema(&mut oa_list[i], &resolved, defs, camel_case);
382 }
383 }
384 }
385 }
386}
387
388fn dedup_path_params(spec: &mut YamlValue, metadata: &CodeGenMetadata) {
391 let mut removed_total = 0usize;
392
393 for service in metadata.services.values() {
394 for method in &service.methods {
395 let path_params: HashSet<String> =
396 method.http_pattern.parameters.iter().cloned().collect();
397 if path_params.is_empty() {
398 continue;
399 }
400
401 let input_type = method
402 .input_type
403 .rfind('.')
404 .map(|i| &method.input_type[i + 1..])
405 .unwrap_or(&method.input_type);
406
407 let schema = spec
408 .get_mut("components")
409 .and_then(|c| c.get_mut("schemas"))
410 .and_then(|s| s.get_mut(input_type));
411
412 let schema = match schema {
413 Some(s) => s,
414 None => continue,
415 };
416
417 if let Some(props) = schema
419 .get_mut("properties")
420 .and_then(|p| p.as_mapping_mut())
421 {
422 let before = props.len();
423 props.retain(|k, _| {
424 let key = k.as_str().unwrap_or("");
425 !path_params.contains(key)
426 });
427 removed_total += before - props.len();
428 }
429
430 if let Some(required) = schema.get_mut("required").and_then(|r| r.as_sequence_mut()) {
432 required.retain(|v| {
433 let key = v.as_str().unwrap_or("");
434 !path_params.contains(key)
435 });
436 }
437 }
438 }
439
440 if removed_total > 0 {
441 println!(
442 "enrich-openapi: dedup removed {removed_total} path-bound field(s) from request body schemas"
443 );
444 }
445}
446
447fn yaml_set(target: &mut YamlValue, key: &str, value: YamlValue) {
450 if let YamlValue::Mapping(m) = target {
451 m.insert(YamlValue::String(key.to_string()), value);
452 }
453}
454
455fn json_to_yaml(v: &serde_json::Value) -> YamlValue {
456 match v {
457 serde_json::Value::Null => YamlValue::Null,
458 serde_json::Value::Bool(b) => YamlValue::Bool(*b),
459 serde_json::Value::Number(n) => {
460 if let Some(i) = n.as_i64() {
461 YamlValue::Number(serde_yaml::Number::from(i))
462 } else if let Some(f) = n.as_f64() {
463 YamlValue::Number(serde_yaml::Number::from(f))
464 } else {
465 YamlValue::String(n.to_string())
466 }
467 }
468 serde_json::Value::String(s) => YamlValue::String(s.clone()),
469 serde_json::Value::Array(arr) => {
470 YamlValue::Sequence(arr.iter().map(json_to_yaml).collect())
471 }
472 serde_json::Value::Object(obj) => {
473 let mut mapping = serde_yaml::Mapping::new();
474 for (k, val) in obj {
475 mapping.insert(YamlValue::String(k.clone()), json_to_yaml(val));
476 }
477 YamlValue::Mapping(mapping)
478 }
479 }
480}