1#![allow(missing_docs, clippy::doc_markdown)]
4
5use anyhow::{anyhow, bail, Context, Result};
75use serde_yaml::{Mapping, Value};
76
77pub const ERROR_ALIASES: &[&str] = &[];
87
88const NULL_VARIANT_SCHEMA_KEYS: &[&str] = &["oneOf", "anyOf"];
89
90pub fn normalize_spec(yaml: &str) -> Result<String> {
95 let (out, report) = normalize_with_aliases(yaml, ERROR_ALIASES)?;
96 tracing_nothing(&report); Ok(out)
98}
99
100pub fn normalize_spec_with_report(yaml: &str) -> Result<(String, Report)> {
102 normalize_with_aliases(yaml, ERROR_ALIASES)
103}
104
105fn normalize_with_aliases(yaml: &str, aliases: &[&str]) -> Result<(String, Report)> {
110 let mut doc: Value = serde_yaml::from_str(yaml).context("parse spec YAML")?;
111 let report = normalize_in_place(&mut doc, aliases)?;
112 let out = serde_yaml::to_string(&doc).context("re-serialize normalized spec")?;
113 Ok((out, report))
114}
115
116#[derive(Debug, Default, Clone)]
117pub struct Report {
118 pub folded_aliases: Vec<String>,
121 pub refs_rewritten: usize,
123 pub stripped_operations: Vec<String>,
126 pub error_responses_stripped: usize,
129 pub null_variants_stripped: usize,
133 pub request_media_collapsed: usize,
137}
138
139fn normalize_in_place(doc: &mut Value, aliases: &[&str]) -> Result<Report> {
140 let mut report = Report::default();
141
142 let schemas_path = ["components", "schemas"];
143 let canonical_shape = {
144 let error = get_mapping_path(doc, &schemas_path)
145 .and_then(|m| m.get(Value::from("Error")))
146 .ok_or_else(|| anyhow!("canonical schema `Error` not found in components.schemas"))?;
147 structural_shape(error)
148 };
149
150 let present_aliases: Vec<String> = {
152 let schemas = get_mapping_path(doc, &schemas_path)
153 .ok_or_else(|| anyhow!("components.schemas not a mapping"))?;
154 aliases
155 .iter()
156 .copied()
157 .filter(|a| schemas.contains_key(Value::from(*a)))
158 .map(str::to_owned)
159 .collect()
160 };
161
162 for alias in &present_aliases {
163 let schema = get_mapping_path(doc, &schemas_path)
164 .and_then(|m| m.get(Value::from(alias.as_str())))
165 .expect("alias presence confirmed above");
166 let alias_shape = structural_shape(schema);
167 if alias_shape != canonical_shape {
168 bail!(
169 "alias `{alias}` has drifted from canonical `Error` shape — \
170 either restore structural equality or remove `{alias}` from ERROR_ALIASES.\n\
171 canonical: {:#?}\nalias: {:#?}",
172 canonical_shape,
173 alias_shape
174 );
175 }
176 }
177
178 for alias in &present_aliases {
180 let from = format!("#/components/schemas/{alias}");
181 let to = "#/components/schemas/Error";
182 report.refs_rewritten += rewrite_refs(doc, &from, to);
183 }
184
185 if let Some(schemas) = get_mapping_path_mut(doc, &schemas_path) {
187 for alias in &present_aliases {
188 schemas.remove(Value::from(alias.as_str()));
189 }
190 }
191 report.folded_aliases = present_aliases;
192
193 report.stripped_operations = strip_multipart_only_operations(doc);
195
196 report.request_media_collapsed = collapse_multi_media_request_bodies(doc);
200
201 report.error_responses_stripped = strip_non_success_responses(doc);
204
205 report.null_variants_stripped = strip_null_type_variants(doc);
210
211 Ok(report)
212}
213
214fn strip_null_type_variants(doc: &mut Value) -> usize {
225 fn is_null_type_schema(v: &Value) -> bool {
226 v.as_mapping()
227 .and_then(|m| m.get("type"))
228 .and_then(Value::as_str)
229 == Some("null")
230 }
231 fn walk(v: &mut Value, count: &mut usize) {
232 match v {
233 Value::Mapping(map) => {
234 for key in NULL_VARIANT_SCHEMA_KEYS {
235 let Some(seq) = map.get_mut(*key).and_then(Value::as_sequence_mut) else {
236 continue;
237 };
238 let before = seq.len();
239 seq.retain(|item| !is_null_type_schema(item));
240 *count += before - seq.len();
241 if before > 0 && seq.is_empty() {
242 map.remove(*key);
243 }
244 }
245 for (_, child) in map.iter_mut() {
246 walk(child, count);
247 }
248 }
249 Value::Sequence(seq) => {
250 for child in seq.iter_mut() {
251 walk(child, count);
252 }
253 }
254 _ => {}
255 }
256 }
257 let mut count = 0;
258 walk(doc, &mut count);
259 count
260}
261
262fn strip_multipart_only_operations(doc: &mut Value) -> Vec<String> {
269 let mut stripped = Vec::new();
270 let Some(paths) = doc
271 .as_mapping_mut()
272 .and_then(|m| m.get_mut(Value::from("paths")))
273 .and_then(Value::as_mapping_mut)
274 else {
275 return stripped;
276 };
277
278 const HTTP_VERBS: &[&str] = &["get", "put", "post", "delete", "patch", "options", "head"];
279 for (_path_key, path_item) in paths.iter_mut() {
280 let Some(ops) = path_item.as_mapping_mut() else {
281 continue;
282 };
283 let mut to_remove = Vec::new();
284 for verb in HTTP_VERBS {
285 if let Some(op) = ops.get(Value::from(*verb)) {
286 if operation_is_multipart_only(op) {
287 let op_id = op
288 .get(Value::from("operationId"))
289 .and_then(Value::as_str)
290 .unwrap_or("<anonymous>")
291 .to_string();
292 to_remove.push(*verb);
293 stripped.push(op_id);
294 }
295 }
296 }
297 for verb in to_remove {
298 ops.remove(Value::from(verb));
299 }
300 }
301 stripped
302}
303
304fn collapse_multi_media_request_bodies(doc: &mut Value) -> usize {
315 let mut collapsed = 0usize;
316 let Some(paths) = doc
317 .as_mapping_mut()
318 .and_then(|m| m.get_mut(Value::from("paths")))
319 .and_then(Value::as_mapping_mut)
320 else {
321 return 0;
322 };
323
324 const HTTP_VERBS: &[&str] = &["get", "put", "post", "delete", "patch", "options", "head"];
325 for (_path_key, path_item) in paths.iter_mut() {
326 let Some(ops) = path_item.as_mapping_mut() else {
327 continue;
328 };
329 for verb in HTTP_VERBS {
330 let Some(content) = ops
331 .get_mut(Value::from(*verb))
332 .and_then(Value::as_mapping_mut)
333 .and_then(|op| op.get_mut(Value::from("requestBody")))
334 .and_then(Value::as_mapping_mut)
335 .and_then(|body| body.get_mut(Value::from("content")))
336 .and_then(Value::as_mapping_mut)
337 else {
338 continue;
339 };
340 if content.len() <= 1 {
341 continue;
342 }
343 let keep = if content.contains_key(Value::from("application/json")) {
344 "application/json".to_string()
345 } else {
346 content
347 .keys()
348 .next()
349 .and_then(Value::as_str)
350 .map(str::to_owned)
351 .expect("content has >1 entry")
352 };
353 content.retain(|k, _| k.as_str() == Some(keep.as_str()));
354 collapsed += 1;
355 }
356 }
357 collapsed
358}
359
360fn strip_non_success_responses(doc: &mut Value) -> usize {
373 let mut removed = 0usize;
374 let Some(paths) = doc
375 .as_mapping_mut()
376 .and_then(|m| m.get_mut(Value::from("paths")))
377 .and_then(Value::as_mapping_mut)
378 else {
379 return 0;
380 };
381
382 const HTTP_VERBS: &[&str] = &["get", "put", "post", "delete", "patch", "options", "head"];
383 for (_path_key, path_item) in paths.iter_mut() {
384 let Some(ops) = path_item.as_mapping_mut() else {
385 continue;
386 };
387 for verb in HTTP_VERBS {
388 let Some(op) = ops.get_mut(Value::from(*verb)) else {
389 continue;
390 };
391 let Some(responses) = op
392 .as_mapping_mut()
393 .and_then(|m| m.get_mut(Value::from("responses")))
394 .and_then(Value::as_mapping_mut)
395 else {
396 continue;
397 };
398
399 let to_remove: Vec<Value> = responses
400 .iter()
401 .filter_map(|(k, _)| {
402 let key = k.as_str()?;
403 if is_success_or_default(key) {
404 None
405 } else {
406 Some(k.clone())
407 }
408 })
409 .collect();
410 for k in to_remove {
411 if responses.remove(&k).is_some() {
412 removed += 1;
413 }
414 }
415 }
416 }
417 removed
418}
419
420fn is_success_or_default(code: &str) -> bool {
421 if code == "default" {
422 return true;
423 }
424 match code.chars().next() {
426 Some('2') => true,
427 _ => false,
428 }
429}
430
431fn operation_is_multipart_only(op: &Value) -> bool {
432 let Some(body) = op
433 .as_mapping()
434 .and_then(|m| m.get(Value::from("requestBody")))
435 else {
436 return false;
437 };
438 let Some(content) = body
439 .as_mapping()
440 .and_then(|m| m.get(Value::from("content")))
441 .and_then(Value::as_mapping)
442 else {
443 return false;
444 };
445 let keys: Vec<&str> = content.keys().filter_map(Value::as_str).collect();
446 keys.contains(&"multipart/form-data")
451}
452
453fn structural_shape(v: &Value) -> Value {
456 match v {
457 Value::Mapping(m) => {
458 let mut out = Mapping::new();
459 for (k, val) in m {
460 let keep = match k.as_str() {
461 Some("description") | Some("example") | Some("summary") | Some("default")
462 | Some("deprecated") | Some("title") => false,
463 _ => true,
464 };
465 if keep {
466 out.insert(k.clone(), structural_shape(val));
467 }
468 }
469 Value::Mapping(out)
470 }
471 Value::Sequence(s) => Value::Sequence(s.iter().map(structural_shape).collect()),
472 other => other.clone(),
473 }
474}
475
476fn rewrite_refs(doc: &mut Value, from: &str, to: &str) -> usize {
479 let mut count = 0;
480 rewrite_refs_inner(doc, from, to, &mut count);
481 count
482}
483
484fn rewrite_refs_inner(v: &mut Value, from: &str, to: &str, count: &mut usize) {
485 match v {
486 Value::Mapping(m) => {
487 if let Some(target) = m.get_mut(Value::from("$ref")) {
490 if target.as_str() == Some(from) {
491 *target = Value::from(to);
492 *count += 1;
493 }
494 }
495 for (_, val) in m.iter_mut() {
496 rewrite_refs_inner(val, from, to, count);
497 }
498 }
499 Value::Sequence(s) => {
500 for val in s.iter_mut() {
501 rewrite_refs_inner(val, from, to, count);
502 }
503 }
504 _ => {}
505 }
506}
507
508fn get_mapping_path<'a>(v: &'a Value, path: &[&str]) -> Option<&'a Mapping> {
509 let mut cur = v;
510 for key in path {
511 cur = cur.as_mapping()?.get(Value::from(*key))?;
512 }
513 cur.as_mapping()
514}
515
516fn get_mapping_path_mut<'a>(v: &'a mut Value, path: &[&str]) -> Option<&'a mut Mapping> {
517 let mut cur = v;
518 for key in path {
519 cur = cur.as_mapping_mut()?.get_mut(Value::from(*key))?;
520 }
521 cur.as_mapping_mut()
522}
523
524fn tracing_nothing(_r: &Report) {}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529
530 const BASE_SPEC: &str = r#"
531openapi: 3.0.0
532info:
533 title: test
534 version: 0.0.0
535paths:
536 /foo:
537 get:
538 responses:
539 '200':
540 description: ok
541 '404':
542 description: not found
543 content:
544 application/json:
545 schema:
546 $ref: '#/components/schemas/WalletBalanceNotFoundError'
547 '503':
548 description: unavailable
549 content:
550 application/json:
551 schema:
552 $ref: '#/components/schemas/Error'
553components:
554 schemas:
555 Error:
556 type: object
557 required:
558 - error
559 - message
560 properties:
561 error:
562 type: string
563 description: Error code
564 message:
565 type: string
566 description: Human message
567 WalletBalanceNotFoundError:
568 type: object
569 required:
570 - error
571 - message
572 properties:
573 error:
574 type: string
575 description: Error code
576 example: "wallet_not_applicable"
577 message:
578 type: string
579 description: Human message
580 example: "not a tempo agent"
581 Untouched:
582 type: object
583"#;
584
585 #[test]
586 fn normalize_folds_alias_refs_and_drops_schema() {
587 let (out, report) =
588 normalize_with_aliases(BASE_SPEC, &["WalletBalanceNotFoundError"]).unwrap();
589 assert_eq!(
590 report.folded_aliases,
591 vec!["WalletBalanceNotFoundError".to_string()]
592 );
593 assert_eq!(report.refs_rewritten, 1);
594
595 assert!(
598 out.contains("Error:\n type: object"),
599 "canonical Error schema missing:\n{out}"
600 );
601 assert!(
602 !out.contains("WalletBalanceNotFoundError"),
603 "alias name fully removed, got:\n{out}"
604 );
605
606 assert!(out.contains("Untouched:"));
608 }
609
610 #[test]
611 fn idempotent_when_no_aliases_present() {
612 let (out1, _) = normalize_spec_with_report(BASE_SPEC).unwrap();
613 let (out2, report2) = normalize_spec_with_report(&out1).unwrap();
614 assert_eq!(out1, out2);
615 assert!(report2.folded_aliases.is_empty());
616 assert_eq!(report2.refs_rewritten, 0);
617 }
618
619 #[test]
620 fn drift_check_rejects_mismatched_alias() {
621 let drifted = BASE_SPEC.replace(
623 "WalletBalanceNotFoundError:\n type: object\n required:\n - error\n - message",
624 "WalletBalanceNotFoundError:\n type: object\n required:\n - error\n - message\n - drift_field",
625 );
626 let err = normalize_with_aliases(&drifted, &["WalletBalanceNotFoundError"]).unwrap_err();
627 let msg = format!("{err:#}");
628 assert!(msg.contains("drifted from canonical"), "got: {msg}");
629 }
630
631 #[test]
632 fn missing_error_schema_is_error() {
633 let bad = BASE_SPEC.replace("Error:", "NotError:");
634 let err = normalize_spec(bad.as_str()).unwrap_err();
635 assert!(format!("{err:#}").contains("canonical schema `Error` not found"));
636 }
637
638 const MULTIPART_SPEC: &str = r#"
639openapi: 3.0.0
640info: { title: test, version: 0.0.0 }
641paths:
642 /upload:
643 post:
644 operationId: uploadThing
645 requestBody:
646 required: true
647 content:
648 multipart/form-data:
649 schema:
650 type: object
651 required: [file]
652 properties:
653 file: { type: string, format: binary }
654 responses:
655 '201': { description: ok }
656 get:
657 operationId: listThings
658 responses:
659 '200': { description: ok }
660 /json_only:
661 post:
662 operationId: postJsonThing
663 requestBody:
664 required: true
665 content:
666 application/json:
667 schema: { type: object }
668 responses:
669 '201': { description: ok }
670components:
671 schemas:
672 Error:
673 type: object
674 required: [error, message]
675 properties:
676 error: { type: string }
677 message: { type: string }
678"#;
679
680 #[test]
681 fn non_success_responses_are_stripped() {
682 let (out, report) = normalize_spec_with_report(BASE_SPEC).unwrap();
683 assert_eq!(report.error_responses_stripped, 2);
685 assert!(out.contains("'200':"));
687 assert!(!out.contains("'404':"), "404 not stripped:\n{out}");
689 assert!(!out.contains("'503':"), "503 not stripped:\n{out}");
690 }
691
692 #[test]
693 fn multi_media_request_body_collapses_to_json() {
694 const SPEC: &str = r#"
695openapi: 3.0.0
696info: { title: test, version: 0.0.0 }
697paths:
698 /oauth2/token:
699 post:
700 operationId: oauthToken
701 requestBody:
702 required: true
703 content:
704 application/json:
705 schema: { $ref: '#/components/schemas/Tok' }
706 application/x-www-form-urlencoded:
707 schema: { $ref: '#/components/schemas/Tok' }
708 responses:
709 '200': { description: ok }
710 /forms:
711 post:
712 operationId: formOnly
713 requestBody:
714 content:
715 application/x-www-form-urlencoded:
716 schema: { type: object }
717 text/plain:
718 schema: { type: string }
719 responses:
720 '200': { description: ok }
721components:
722 schemas:
723 Error:
724 type: object
725 required: [error, message]
726 properties:
727 error: { type: string }
728 message: { type: string }
729 Tok:
730 type: object
731"#;
732 let (out, report) = normalize_spec_with_report(SPEC).unwrap();
733 assert_eq!(report.request_media_collapsed, 2);
735 assert_eq!(
738 out.matches("application/json:").count(),
739 1,
740 "expected oauth body kept as json:\n{out}"
741 );
742 assert_eq!(
745 out.matches("application/x-www-form-urlencoded:").count(),
746 1,
747 "expected form-only body kept as form-urlencoded:\n{out}"
748 );
749 assert!(
750 !out.contains("text/plain"),
751 "lower-priority text/plain variant not collapsed:\n{out}"
752 );
753 }
754
755 #[test]
756 fn multipart_only_operation_is_stripped() {
757 let (out, report) = normalize_spec_with_report(MULTIPART_SPEC).unwrap();
758 assert_eq!(report.stripped_operations, vec!["uploadThing".to_string()]);
759 assert!(!out.contains("uploadThing"), "uploadThing not stripped");
760 assert!(out.contains("listThings"), "sibling GET removed by mistake");
762 assert!(
763 out.contains("postJsonThing"),
764 "JSON-only POST removed by mistake"
765 );
766 assert!(out.contains("/upload:"));
768 }
769
770 const NULL_VARIANT_SPEC: &str = r#"
771openapi: 3.0.0
772info: { title: test, version: 0.0.0 }
773paths:
774 /thing:
775 get:
776 responses:
777 '200':
778 description: ok
779 content:
780 application/json:
781 schema:
782 oneOf:
783 - { type: object, properties: { name: { type: string } } }
784 - { type: "null" }
785components:
786 schemas:
787 Error:
788 type: object
789 required: [error, message]
790 properties:
791 error: { type: string }
792 message: { type: string }
793 Nullable:
794 anyOf:
795 - { type: integer }
796 - { type: "null", description: "explicit absence" }
797 AllNull:
798 oneOf:
799 - { type: "null" }
800 - { type: "null" }
801 Untouched:
802 oneOf:
803 - { type: integer }
804 - { type: string }
805"#;
806
807 #[test]
808 fn null_type_variants_are_stripped_and_counted() {
809 let (out, report) = normalize_spec_with_report(NULL_VARIANT_SPEC).unwrap();
810 assert_eq!(report.null_variants_stripped, 4);
812
813 assert!(out.contains("Nullable:"));
815 assert!(out.contains("Untouched:"));
816 assert!(
818 !out.contains("explicit absence"),
819 "null variant with sibling description not stripped:\n{out}"
820 );
821 assert!(
823 out.contains("Untouched:") && out.matches("type: string").count() >= 2,
824 "Untouched.oneOf altered:\n{out}"
825 );
826 }
827
828 #[test]
829 fn empty_oneof_after_strip_is_removed_outright() {
830 let (out, _report) = normalize_spec_with_report(NULL_VARIANT_SPEC).unwrap();
831 let idx = out.find("AllNull:").expect("AllNull schema present");
835 let tail = &out[idx..];
836 let block_end = tail[8..].find("\n ").map_or(tail.len(), |n| n + 8);
838 let block = &tail[..block_end];
839 assert!(
840 !block.contains("oneOf"),
841 "empty oneOf not removed from AllNull:\n{block}"
842 );
843 }
844
845 #[test]
846 fn null_strip_is_idempotent() {
847 let (out1, _) = normalize_spec_with_report(NULL_VARIANT_SPEC).unwrap();
848 let (out2, report2) = normalize_spec_with_report(&out1).unwrap();
849 assert_eq!(out1, out2);
850 assert_eq!(report2.null_variants_stripped, 0);
851 }
852}