1#![allow(missing_docs, clippy::doc_markdown)]
4
5use anyhow::{anyhow, bail, Context, Result};
81use serde_yaml::{Mapping, Value};
82
83pub const ERROR_ALIASES: &[&str] = &[];
93
94const NULL_VARIANT_SCHEMA_KEYS: &[&str] = &["oneOf", "anyOf"];
95
96pub fn normalize_spec(yaml: &str) -> Result<String> {
101 let (out, report) = normalize_with_aliases(yaml, ERROR_ALIASES)?;
102 tracing_nothing(&report); Ok(out)
104}
105
106pub fn normalize_spec_with_report(yaml: &str) -> Result<(String, Report)> {
108 normalize_with_aliases(yaml, ERROR_ALIASES)
109}
110
111fn normalize_with_aliases(yaml: &str, aliases: &[&str]) -> Result<(String, Report)> {
116 let mut doc: Value = serde_yaml::from_str(yaml).context("parse spec YAML")?;
117 let report = normalize_in_place(&mut doc, aliases)?;
118 let out = serde_yaml::to_string(&doc).context("re-serialize normalized spec")?;
119 Ok((out, report))
120}
121
122#[derive(Debug, Default, Clone)]
123pub struct Report {
124 pub folded_aliases: Vec<String>,
127 pub refs_rewritten: usize,
129 pub stripped_operations: Vec<String>,
132 pub error_responses_stripped: usize,
135 pub null_variants_stripped: usize,
139 pub request_media_collapsed: usize,
143}
144
145fn normalize_in_place(doc: &mut Value, aliases: &[&str]) -> Result<Report> {
146 let mut report = Report::default();
147
148 let schemas_path = ["components", "schemas"];
149 let canonical_shape = {
150 let error = get_mapping_path(doc, &schemas_path)
151 .and_then(|m| m.get(Value::from("Error")))
152 .ok_or_else(|| anyhow!("canonical schema `Error` not found in components.schemas"))?;
153 structural_shape(error)
154 };
155
156 let present_aliases: Vec<String> = {
158 let schemas = get_mapping_path(doc, &schemas_path)
159 .ok_or_else(|| anyhow!("components.schemas not a mapping"))?;
160 aliases
161 .iter()
162 .copied()
163 .filter(|a| schemas.contains_key(Value::from(*a)))
164 .map(str::to_owned)
165 .collect()
166 };
167
168 for alias in &present_aliases {
169 let schema = get_mapping_path(doc, &schemas_path)
170 .and_then(|m| m.get(Value::from(alias.as_str())))
171 .expect("alias presence confirmed above");
172 let alias_shape = structural_shape(schema);
173 if alias_shape != canonical_shape {
174 bail!(
175 "alias `{alias}` has drifted from canonical `Error` shape — \
176 either restore structural equality or remove `{alias}` from ERROR_ALIASES.\n\
177 canonical: {:#?}\nalias: {:#?}",
178 canonical_shape,
179 alias_shape
180 );
181 }
182 }
183
184 for alias in &present_aliases {
186 let from = format!("#/components/schemas/{alias}");
187 let to = "#/components/schemas/Error";
188 report.refs_rewritten += rewrite_refs(doc, &from, to);
189 }
190
191 if let Some(schemas) = get_mapping_path_mut(doc, &schemas_path) {
193 for alias in &present_aliases {
194 schemas.remove(Value::from(alias.as_str()));
195 }
196 }
197 report.folded_aliases = present_aliases;
198
199 report.stripped_operations = strip_multipart_only_operations(doc);
201
202 report.request_media_collapsed = collapse_multi_media_request_bodies(doc);
206
207 report.error_responses_stripped = strip_non_success_responses(doc);
210
211 report.null_variants_stripped = strip_null_type_variants(doc);
216
217 strip_closed_objects(doc);
219
220 Ok(report)
221}
222
223fn strip_closed_objects(v: &mut Value) {
232 match v {
233 Value::Mapping(map) => {
234 if map.get("additionalProperties") == Some(&Value::Bool(false)) {
235 map.remove("additionalProperties");
236 }
237 for (_, child) in map.iter_mut() {
238 strip_closed_objects(child);
239 }
240 }
241 Value::Sequence(seq) => seq.iter_mut().for_each(strip_closed_objects),
242 _ => {}
243 }
244}
245
246fn strip_null_type_variants(doc: &mut Value) -> usize {
257 fn is_null_type_schema(v: &Value) -> bool {
258 v.as_mapping()
259 .and_then(|m| m.get("type"))
260 .and_then(Value::as_str)
261 == Some("null")
262 }
263 fn walk(v: &mut Value, count: &mut usize) {
264 match v {
265 Value::Mapping(map) => {
266 for key in NULL_VARIANT_SCHEMA_KEYS {
267 let Some(seq) = map.get_mut(*key).and_then(Value::as_sequence_mut) else {
268 continue;
269 };
270 let before = seq.len();
271 seq.retain(|item| !is_null_type_schema(item));
272 *count += before - seq.len();
273 if before > 0 && seq.is_empty() {
274 map.remove(*key);
275 }
276 }
277 for (_, child) in map.iter_mut() {
278 walk(child, count);
279 }
280 }
281 Value::Sequence(seq) => {
282 for child in seq.iter_mut() {
283 walk(child, count);
284 }
285 }
286 _ => {}
287 }
288 }
289 let mut count = 0;
290 walk(doc, &mut count);
291 count
292}
293
294fn strip_multipart_only_operations(doc: &mut Value) -> Vec<String> {
301 let mut stripped = Vec::new();
302 let Some(paths) = doc
303 .as_mapping_mut()
304 .and_then(|m| m.get_mut(Value::from("paths")))
305 .and_then(Value::as_mapping_mut)
306 else {
307 return stripped;
308 };
309
310 const HTTP_VERBS: &[&str] = &["get", "put", "post", "delete", "patch", "options", "head"];
311 for (_path_key, path_item) in paths.iter_mut() {
312 let Some(ops) = path_item.as_mapping_mut() else {
313 continue;
314 };
315 let mut to_remove = Vec::new();
316 for verb in HTTP_VERBS {
317 if let Some(op) = ops.get(Value::from(*verb)) {
318 if operation_is_multipart_only(op) {
319 let op_id = op
320 .get(Value::from("operationId"))
321 .and_then(Value::as_str)
322 .unwrap_or("<anonymous>")
323 .to_string();
324 to_remove.push(*verb);
325 stripped.push(op_id);
326 }
327 }
328 }
329 for verb in to_remove {
330 ops.remove(Value::from(verb));
331 }
332 }
333 stripped
334}
335
336fn collapse_multi_media_request_bodies(doc: &mut Value) -> usize {
347 let mut collapsed = 0usize;
348 let Some(paths) = doc
349 .as_mapping_mut()
350 .and_then(|m| m.get_mut(Value::from("paths")))
351 .and_then(Value::as_mapping_mut)
352 else {
353 return 0;
354 };
355
356 const HTTP_VERBS: &[&str] = &["get", "put", "post", "delete", "patch", "options", "head"];
357 for (_path_key, path_item) in paths.iter_mut() {
358 let Some(ops) = path_item.as_mapping_mut() else {
359 continue;
360 };
361 for verb in HTTP_VERBS {
362 let Some(content) = ops
363 .get_mut(Value::from(*verb))
364 .and_then(Value::as_mapping_mut)
365 .and_then(|op| op.get_mut(Value::from("requestBody")))
366 .and_then(Value::as_mapping_mut)
367 .and_then(|body| body.get_mut(Value::from("content")))
368 .and_then(Value::as_mapping_mut)
369 else {
370 continue;
371 };
372 if content.len() <= 1 {
373 continue;
374 }
375 let keep = if content.contains_key(Value::from("application/json")) {
376 "application/json".to_string()
377 } else {
378 content
379 .keys()
380 .next()
381 .and_then(Value::as_str)
382 .map(str::to_owned)
383 .expect("content has >1 entry")
384 };
385 content.retain(|k, _| k.as_str() == Some(keep.as_str()));
386 collapsed += 1;
387 }
388 }
389 collapsed
390}
391
392fn strip_non_success_responses(doc: &mut Value) -> usize {
405 let mut removed = 0usize;
406 let Some(paths) = doc
407 .as_mapping_mut()
408 .and_then(|m| m.get_mut(Value::from("paths")))
409 .and_then(Value::as_mapping_mut)
410 else {
411 return 0;
412 };
413
414 const HTTP_VERBS: &[&str] = &["get", "put", "post", "delete", "patch", "options", "head"];
415 for (_path_key, path_item) in paths.iter_mut() {
416 let Some(ops) = path_item.as_mapping_mut() else {
417 continue;
418 };
419 for verb in HTTP_VERBS {
420 let Some(op) = ops.get_mut(Value::from(*verb)) else {
421 continue;
422 };
423 let Some(responses) = op
424 .as_mapping_mut()
425 .and_then(|m| m.get_mut(Value::from("responses")))
426 .and_then(Value::as_mapping_mut)
427 else {
428 continue;
429 };
430
431 let to_remove: Vec<Value> = responses
432 .iter()
433 .filter_map(|(k, _)| {
434 let key = k.as_str()?;
435 if is_success_or_default(key) {
436 None
437 } else {
438 Some(k.clone())
439 }
440 })
441 .collect();
442 for k in to_remove {
443 if responses.remove(&k).is_some() {
444 removed += 1;
445 }
446 }
447 }
448 }
449 removed
450}
451
452fn is_success_or_default(code: &str) -> bool {
453 if code == "default" {
454 return true;
455 }
456 match code.chars().next() {
458 Some('2') => true,
459 _ => false,
460 }
461}
462
463fn operation_is_multipart_only(op: &Value) -> bool {
464 let Some(body) = op
465 .as_mapping()
466 .and_then(|m| m.get(Value::from("requestBody")))
467 else {
468 return false;
469 };
470 let Some(content) = body
471 .as_mapping()
472 .and_then(|m| m.get(Value::from("content")))
473 .and_then(Value::as_mapping)
474 else {
475 return false;
476 };
477 let keys: Vec<&str> = content.keys().filter_map(Value::as_str).collect();
478 keys.contains(&"multipart/form-data")
483}
484
485fn structural_shape(v: &Value) -> Value {
488 match v {
489 Value::Mapping(m) => {
490 let mut out = Mapping::new();
491 for (k, val) in m {
492 let keep = match k.as_str() {
493 Some("description") | Some("example") | Some("summary") | Some("default")
494 | Some("deprecated") | Some("title") => false,
495 _ => true,
496 };
497 if keep {
498 out.insert(k.clone(), structural_shape(val));
499 }
500 }
501 Value::Mapping(out)
502 }
503 Value::Sequence(s) => Value::Sequence(s.iter().map(structural_shape).collect()),
504 other => other.clone(),
505 }
506}
507
508fn rewrite_refs(doc: &mut Value, from: &str, to: &str) -> usize {
511 let mut count = 0;
512 rewrite_refs_inner(doc, from, to, &mut count);
513 count
514}
515
516fn rewrite_refs_inner(v: &mut Value, from: &str, to: &str, count: &mut usize) {
517 match v {
518 Value::Mapping(m) => {
519 if let Some(target) = m.get_mut(Value::from("$ref")) {
522 if target.as_str() == Some(from) {
523 *target = Value::from(to);
524 *count += 1;
525 }
526 }
527 for (_, val) in m.iter_mut() {
528 rewrite_refs_inner(val, from, to, count);
529 }
530 }
531 Value::Sequence(s) => {
532 for val in s.iter_mut() {
533 rewrite_refs_inner(val, from, to, count);
534 }
535 }
536 _ => {}
537 }
538}
539
540fn get_mapping_path<'a>(v: &'a Value, path: &[&str]) -> Option<&'a Mapping> {
541 let mut cur = v;
542 for key in path {
543 cur = cur.as_mapping()?.get(Value::from(*key))?;
544 }
545 cur.as_mapping()
546}
547
548fn get_mapping_path_mut<'a>(v: &'a mut Value, path: &[&str]) -> Option<&'a mut Mapping> {
549 let mut cur = v;
550 for key in path {
551 cur = cur.as_mapping_mut()?.get_mut(Value::from(*key))?;
552 }
553 cur.as_mapping_mut()
554}
555
556fn tracing_nothing(_r: &Report) {}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561
562 const BASE_SPEC: &str = r#"
563openapi: 3.0.0
564info:
565 title: test
566 version: 0.0.0
567paths:
568 /foo:
569 get:
570 responses:
571 '200':
572 description: ok
573 '404':
574 description: not found
575 content:
576 application/json:
577 schema:
578 $ref: '#/components/schemas/WalletBalanceNotFoundError'
579 '503':
580 description: unavailable
581 content:
582 application/json:
583 schema:
584 $ref: '#/components/schemas/Error'
585components:
586 schemas:
587 Error:
588 type: object
589 required:
590 - error
591 - message
592 properties:
593 error:
594 type: string
595 description: Error code
596 message:
597 type: string
598 description: Human message
599 WalletBalanceNotFoundError:
600 type: object
601 required:
602 - error
603 - message
604 properties:
605 error:
606 type: string
607 description: Error code
608 example: "wallet_not_applicable"
609 message:
610 type: string
611 description: Human message
612 example: "not a tempo agent"
613 Untouched:
614 type: object
615"#;
616
617 #[test]
618 fn normalize_folds_alias_refs_and_drops_schema() {
619 let (out, report) =
620 normalize_with_aliases(BASE_SPEC, &["WalletBalanceNotFoundError"]).unwrap();
621 assert_eq!(
622 report.folded_aliases,
623 vec!["WalletBalanceNotFoundError".to_string()]
624 );
625 assert_eq!(report.refs_rewritten, 1);
626
627 assert!(
630 out.contains("Error:\n type: object"),
631 "canonical Error schema missing:\n{out}"
632 );
633 assert!(
634 !out.contains("WalletBalanceNotFoundError"),
635 "alias name fully removed, got:\n{out}"
636 );
637
638 assert!(out.contains("Untouched:"));
640 }
641
642 #[test]
643 fn idempotent_when_no_aliases_present() {
644 let (out1, _) = normalize_spec_with_report(BASE_SPEC).unwrap();
645 let (out2, report2) = normalize_spec_with_report(&out1).unwrap();
646 assert_eq!(out1, out2);
647 assert!(report2.folded_aliases.is_empty());
648 assert_eq!(report2.refs_rewritten, 0);
649 }
650
651 #[test]
652 fn drift_check_rejects_mismatched_alias() {
653 let drifted = BASE_SPEC.replace(
655 "WalletBalanceNotFoundError:\n type: object\n required:\n - error\n - message",
656 "WalletBalanceNotFoundError:\n type: object\n required:\n - error\n - message\n - drift_field",
657 );
658 let err = normalize_with_aliases(&drifted, &["WalletBalanceNotFoundError"]).unwrap_err();
659 let msg = format!("{err:#}");
660 assert!(msg.contains("drifted from canonical"), "got: {msg}");
661 }
662
663 #[test]
664 fn missing_error_schema_is_error() {
665 let bad = BASE_SPEC.replace("Error:", "NotError:");
666 let err = normalize_spec(bad.as_str()).unwrap_err();
667 assert!(format!("{err:#}").contains("canonical schema `Error` not found"));
668 }
669
670 const MULTIPART_SPEC: &str = r#"
671openapi: 3.0.0
672info: { title: test, version: 0.0.0 }
673paths:
674 /upload:
675 post:
676 operationId: uploadThing
677 requestBody:
678 required: true
679 content:
680 multipart/form-data:
681 schema:
682 type: object
683 required: [file]
684 properties:
685 file: { type: string, format: binary }
686 responses:
687 '201': { description: ok }
688 get:
689 operationId: listThings
690 responses:
691 '200': { description: ok }
692 /json_only:
693 post:
694 operationId: postJsonThing
695 requestBody:
696 required: true
697 content:
698 application/json:
699 schema: { type: object }
700 responses:
701 '201': { description: ok }
702components:
703 schemas:
704 Error:
705 type: object
706 required: [error, message]
707 properties:
708 error: { type: string }
709 message: { type: string }
710"#;
711
712 #[test]
713 fn non_success_responses_are_stripped() {
714 let (out, report) = normalize_spec_with_report(BASE_SPEC).unwrap();
715 assert_eq!(report.error_responses_stripped, 2);
717 assert!(out.contains("'200':"));
719 assert!(!out.contains("'404':"), "404 not stripped:\n{out}");
721 assert!(!out.contains("'503':"), "503 not stripped:\n{out}");
722 }
723
724 #[test]
725 fn multi_media_request_body_collapses_to_json() {
726 const SPEC: &str = r#"
727openapi: 3.0.0
728info: { title: test, version: 0.0.0 }
729paths:
730 /oauth2/token:
731 post:
732 operationId: oauthToken
733 requestBody:
734 required: true
735 content:
736 application/json:
737 schema: { $ref: '#/components/schemas/Tok' }
738 application/x-www-form-urlencoded:
739 schema: { $ref: '#/components/schemas/Tok' }
740 responses:
741 '200': { description: ok }
742 /forms:
743 post:
744 operationId: formOnly
745 requestBody:
746 content:
747 application/x-www-form-urlencoded:
748 schema: { type: object }
749 text/plain:
750 schema: { type: string }
751 responses:
752 '200': { description: ok }
753components:
754 schemas:
755 Error:
756 type: object
757 required: [error, message]
758 properties:
759 error: { type: string }
760 message: { type: string }
761 Tok:
762 type: object
763"#;
764 let (out, report) = normalize_spec_with_report(SPEC).unwrap();
765 assert_eq!(report.request_media_collapsed, 2);
767 assert_eq!(
770 out.matches("application/json:").count(),
771 1,
772 "expected oauth body kept as json:\n{out}"
773 );
774 assert_eq!(
777 out.matches("application/x-www-form-urlencoded:").count(),
778 1,
779 "expected form-only body kept as form-urlencoded:\n{out}"
780 );
781 assert!(
782 !out.contains("text/plain"),
783 "lower-priority text/plain variant not collapsed:\n{out}"
784 );
785 }
786
787 #[test]
788 fn multipart_only_operation_is_stripped() {
789 let (out, report) = normalize_spec_with_report(MULTIPART_SPEC).unwrap();
790 assert_eq!(report.stripped_operations, vec!["uploadThing".to_string()]);
791 assert!(!out.contains("uploadThing"), "uploadThing not stripped");
792 assert!(out.contains("listThings"), "sibling GET removed by mistake");
794 assert!(
795 out.contains("postJsonThing"),
796 "JSON-only POST removed by mistake"
797 );
798 assert!(out.contains("/upload:"));
800 }
801
802 const NULL_VARIANT_SPEC: &str = r#"
803openapi: 3.0.0
804info: { title: test, version: 0.0.0 }
805paths:
806 /thing:
807 get:
808 responses:
809 '200':
810 description: ok
811 content:
812 application/json:
813 schema:
814 oneOf:
815 - { type: object, properties: { name: { type: string } } }
816 - { type: "null" }
817components:
818 schemas:
819 Error:
820 type: object
821 required: [error, message]
822 properties:
823 error: { type: string }
824 message: { type: string }
825 Nullable:
826 anyOf:
827 - { type: integer }
828 - { type: "null", description: "explicit absence" }
829 AllNull:
830 oneOf:
831 - { type: "null" }
832 - { type: "null" }
833 Untouched:
834 oneOf:
835 - { type: integer }
836 - { type: string }
837"#;
838
839 #[test]
840 fn null_type_variants_are_stripped_and_counted() {
841 let (out, report) = normalize_spec_with_report(NULL_VARIANT_SPEC).unwrap();
842 assert_eq!(report.null_variants_stripped, 4);
844
845 assert!(out.contains("Nullable:"));
847 assert!(out.contains("Untouched:"));
848 assert!(
850 !out.contains("explicit absence"),
851 "null variant with sibling description not stripped:\n{out}"
852 );
853 assert!(
855 out.contains("Untouched:") && out.matches("type: string").count() >= 2,
856 "Untouched.oneOf altered:\n{out}"
857 );
858 }
859
860 #[test]
861 fn empty_oneof_after_strip_is_removed_outright() {
862 let (out, _report) = normalize_spec_with_report(NULL_VARIANT_SPEC).unwrap();
863 let idx = out.find("AllNull:").expect("AllNull schema present");
867 let tail = &out[idx..];
868 let block_end = tail[8..].find("\n ").map_or(tail.len(), |n| n + 8);
870 let block = &tail[..block_end];
871 assert!(
872 !block.contains("oneOf"),
873 "empty oneOf not removed from AllNull:\n{block}"
874 );
875 }
876
877 #[test]
878 fn null_strip_is_idempotent() {
879 let (out1, _) = normalize_spec_with_report(NULL_VARIANT_SPEC).unwrap();
880 let (out2, report2) = normalize_spec_with_report(&out1).unwrap();
881 assert_eq!(out1, out2);
882 assert_eq!(report2.null_variants_stripped, 0);
883 }
884
885 const CLOSED_OBJECT_SPEC: &str = r#"
886openapi: 3.0.0
887info:
888 title: test
889 version: 0.0.0
890paths:
891 /thing:
892 get:
893 responses:
894 '200':
895 description: ok
896 content:
897 application/json:
898 schema:
899 type: object
900 additionalProperties: false
901 properties:
902 id:
903 type: string
904components:
905 schemas:
906 Error:
907 type: object
908 additionalProperties: false
909 properties:
910 error:
911 type: string
912 Open:
913 type: object
914 additionalProperties: true
915"#;
916
917 #[test]
918 fn closed_objects_are_opened_everywhere() {
919 let (out, _) = normalize_spec_with_report(CLOSED_OBJECT_SPEC).unwrap();
920 assert!(
921 !out.contains("additionalProperties: false"),
922 "closed object survived:\n{out}"
923 );
924 assert!(
925 out.contains("additionalProperties: true"),
926 "non-false value must be left alone:\n{out}"
927 );
928 }
929}