1#![allow(missing_docs, clippy::doc_markdown)]
4
5use std::collections::BTreeSet;
87
88use anyhow::{anyhow, bail, Context, Result};
89use serde_yaml::{Mapping, Value};
90
91pub const ERROR_ALIASES: &[&str] = &[];
101
102const NULL_VARIANT_SCHEMA_KEYS: &[&str] = &["oneOf", "anyOf"];
103
104pub fn normalize_spec(yaml: &str) -> Result<String> {
109 let (out, report) = normalize_with_aliases(yaml, ERROR_ALIASES)?;
110 tracing_nothing(&report); Ok(out)
112}
113
114pub fn normalize_spec_with_report(yaml: &str) -> Result<(String, Report)> {
116 normalize_with_aliases(yaml, ERROR_ALIASES)
117}
118
119fn normalize_with_aliases(yaml: &str, aliases: &[&str]) -> Result<(String, Report)> {
124 let mut doc: Value = serde_yaml::from_str(yaml).context("parse spec YAML")?;
125 let report = normalize_in_place(&mut doc, aliases)?;
126 let out = serde_yaml::to_string(&doc).context("re-serialize normalized spec")?;
127 Ok((out, report))
128}
129
130#[derive(Debug, Default, Clone)]
131pub struct Report {
132 pub folded_aliases: Vec<String>,
135 pub refs_rewritten: usize,
137 pub stripped_operations: Vec<String>,
140 pub error_responses_stripped: usize,
143 pub null_variants_stripped: usize,
147 pub request_media_collapsed: usize,
151 pub response_enums_stripped: usize,
153}
154
155fn normalize_in_place(doc: &mut Value, aliases: &[&str]) -> Result<Report> {
156 let mut report = Report::default();
157
158 let schemas_path = ["components", "schemas"];
159 let canonical_shape = {
160 let error = get_mapping_path(doc, &schemas_path)
161 .and_then(|m| m.get(Value::from("Error")))
162 .ok_or_else(|| anyhow!("canonical schema `Error` not found in components.schemas"))?;
163 structural_shape(error)
164 };
165
166 let present_aliases: Vec<String> = {
168 let schemas = get_mapping_path(doc, &schemas_path)
169 .ok_or_else(|| anyhow!("components.schemas not a mapping"))?;
170 aliases
171 .iter()
172 .copied()
173 .filter(|a| schemas.contains_key(Value::from(*a)))
174 .map(str::to_owned)
175 .collect()
176 };
177
178 for alias in &present_aliases {
179 let schema = get_mapping_path(doc, &schemas_path)
180 .and_then(|m| m.get(Value::from(alias.as_str())))
181 .expect("alias presence confirmed above");
182 let alias_shape = structural_shape(schema);
183 if alias_shape != canonical_shape {
184 bail!(
185 "alias `{alias}` has drifted from canonical `Error` shape — \
186 either restore structural equality or remove `{alias}` from ERROR_ALIASES.\n\
187 canonical: {:#?}\nalias: {:#?}",
188 canonical_shape,
189 alias_shape
190 );
191 }
192 }
193
194 for alias in &present_aliases {
196 let from = format!("#/components/schemas/{alias}");
197 let to = "#/components/schemas/Error";
198 report.refs_rewritten += rewrite_refs(doc, &from, to);
199 }
200
201 if let Some(schemas) = get_mapping_path_mut(doc, &schemas_path) {
203 for alias in &present_aliases {
204 schemas.remove(Value::from(alias.as_str()));
205 }
206 }
207 report.folded_aliases = present_aliases;
208
209 report.stripped_operations = strip_multipart_only_operations(doc);
211
212 report.request_media_collapsed = collapse_multi_media_request_bodies(doc);
216
217 report.error_responses_stripped = strip_non_success_responses(doc);
220
221 report.null_variants_stripped = strip_null_type_variants(doc);
226
227 strip_closed_objects(doc);
229
230 report.response_enums_stripped = strip_response_enums(doc);
232
233 Ok(report)
234}
235
236fn strip_response_enums(doc: &mut Value) -> usize {
248 let mut visited = BTreeSet::new();
249 let mut enums = BTreeSet::new();
250 if let Some(paths) = doc.get("paths").and_then(Value::as_mapping) {
251 for (path, item) in paths {
252 let Some(path) = path.as_str() else { continue };
253 for verb in [
254 "get", "put", "post", "delete", "patch", "options", "head", "trace",
255 ] {
256 let Some(responses) = item
257 .get(verb)
258 .and_then(|op| op.get("responses"))
259 .and_then(Value::as_mapping)
260 else {
261 continue;
262 };
263 for code in responses
264 .keys()
265 .filter_map(Value::as_str)
266 .filter(|code| is_success_or_default(code))
267 {
268 let root = ["paths", path, verb, "responses", code]
269 .map(str::to_owned)
270 .to_vec();
271 collect_response_enums(doc, &root, &mut visited, &mut enums);
272 }
273 }
274 }
275 }
276 let mut stripped = 0;
277 for path in enums {
278 let mut node = &mut *doc;
279 for key in &path {
280 node = if let Value::Sequence(seq) = node {
281 &mut seq[key.parse::<usize>().expect("collected sequence index")]
282 } else {
283 node.get_mut(key.as_str()).expect("collected schema path")
284 };
285 }
286 if node
287 .as_mapping_mut()
288 .and_then(|map| map.remove("enum"))
289 .is_some()
290 {
291 stripped += 1;
292 }
293 }
294 stripped
295}
296
297fn value_at_path<'a>(doc: &'a Value, path: &[String]) -> Option<&'a Value> {
298 path.iter().try_fold(doc, |node, key| match node {
299 Value::Sequence(seq) => seq.get(key.parse::<usize>().ok()?),
300 _ => node.get(key.as_str()),
301 })
302}
303
304fn local_ref_path(node: &Value) -> Option<Vec<String>> {
305 Some(
306 node.get("$ref")?
307 .as_str()?
308 .strip_prefix("#/")?
309 .split('/')
310 .map(|key| key.replace("~1", "/").replace("~0", "~"))
311 .collect(),
312 )
313}
314
315fn collect_response_enums(
316 doc: &Value,
317 path: &[String],
318 visited: &mut BTreeSet<Vec<String>>,
319 enums: &mut BTreeSet<Vec<String>>,
320) {
321 if !visited.insert(path.to_vec()) {
322 return;
323 }
324 let Some(response) = value_at_path(doc, path) else {
325 return;
326 };
327 if let Some(target) = local_ref_path(response) {
328 collect_response_enums(doc, &target, visited, enums);
329 }
330 if let Some(content) = response.get("content").and_then(Value::as_mapping) {
331 for media in content.keys().filter_map(Value::as_str) {
332 let mut schema_path = path.to_vec();
333 schema_path.extend(["content".to_owned(), media.to_owned(), "schema".to_owned()]);
334 collect_schema_enums(doc, &schema_path, visited, enums);
335 }
336 }
337}
338
339fn collect_schema_enums(
340 doc: &Value,
341 path: &[String],
342 visited: &mut BTreeSet<Vec<String>>,
343 enums: &mut BTreeSet<Vec<String>>,
344) {
345 if !visited.insert(path.to_vec()) {
346 return;
347 }
348 let Some(schema) = value_at_path(doc, path) else {
349 return;
350 };
351 if schema.get("type").and_then(Value::as_str) == Some("string") && schema.get("enum").is_some()
352 {
353 enums.insert(path.to_vec());
354 }
355 if let Some(target) = local_ref_path(schema) {
356 collect_schema_enums(doc, &target, visited, enums);
357 }
358 for key in [
359 "allOf",
360 "oneOf",
361 "anyOf",
362 "properties",
363 "additionalProperties",
364 "items",
365 ] {
366 let Some(child) = schema.get(key) else {
367 continue;
368 };
369 let mut child_path = path.to_vec();
370 child_path.push(key.to_owned());
371 match child {
372 Value::Sequence(seq) => {
373 for index in 0..seq.len() {
374 let mut item_path = child_path.clone();
375 item_path.push(index.to_string());
376 collect_schema_enums(doc, &item_path, visited, enums);
377 }
378 }
379 Value::Mapping(map) if key == "properties" => {
380 for property in map.keys().filter_map(Value::as_str) {
381 let mut property_path = child_path.clone();
382 property_path.push(property.to_owned());
383 collect_schema_enums(doc, &property_path, visited, enums);
384 }
385 }
386 _ => collect_schema_enums(doc, &child_path, visited, enums),
387 }
388 }
389}
390
391fn strip_closed_objects(v: &mut Value) {
400 match v {
401 Value::Mapping(map) => {
402 if map.get("additionalProperties") == Some(&Value::Bool(false)) {
403 map.remove("additionalProperties");
404 }
405 for (_, child) in map.iter_mut() {
406 strip_closed_objects(child);
407 }
408 }
409 Value::Sequence(seq) => seq.iter_mut().for_each(strip_closed_objects),
410 _ => {}
411 }
412}
413
414fn strip_null_type_variants(doc: &mut Value) -> usize {
425 fn is_null_type_schema(v: &Value) -> bool {
426 v.as_mapping()
427 .and_then(|m| m.get("type"))
428 .and_then(Value::as_str)
429 == Some("null")
430 }
431 fn walk(v: &mut Value, count: &mut usize) {
432 match v {
433 Value::Mapping(map) => {
434 for key in NULL_VARIANT_SCHEMA_KEYS {
435 let Some(seq) = map.get_mut(*key).and_then(Value::as_sequence_mut) else {
436 continue;
437 };
438 let before = seq.len();
439 seq.retain(|item| !is_null_type_schema(item));
440 *count += before - seq.len();
441 if before > 0 && seq.is_empty() {
442 map.remove(*key);
443 }
444 }
445 for (_, child) in map.iter_mut() {
446 walk(child, count);
447 }
448 }
449 Value::Sequence(seq) => {
450 for child in seq.iter_mut() {
451 walk(child, count);
452 }
453 }
454 _ => {}
455 }
456 }
457 let mut count = 0;
458 walk(doc, &mut count);
459 count
460}
461
462fn strip_multipart_only_operations(doc: &mut Value) -> Vec<String> {
469 let mut stripped = Vec::new();
470 let Some(paths) = doc
471 .as_mapping_mut()
472 .and_then(|m| m.get_mut(Value::from("paths")))
473 .and_then(Value::as_mapping_mut)
474 else {
475 return stripped;
476 };
477
478 const HTTP_VERBS: &[&str] = &["get", "put", "post", "delete", "patch", "options", "head"];
479 for (_path_key, path_item) in paths.iter_mut() {
480 let Some(ops) = path_item.as_mapping_mut() else {
481 continue;
482 };
483 let mut to_remove = Vec::new();
484 for verb in HTTP_VERBS {
485 if let Some(op) = ops.get(Value::from(*verb)) {
486 if operation_is_multipart_only(op) {
487 let op_id = op
488 .get(Value::from("operationId"))
489 .and_then(Value::as_str)
490 .unwrap_or("<anonymous>")
491 .to_string();
492 to_remove.push(*verb);
493 stripped.push(op_id);
494 }
495 }
496 }
497 for verb in to_remove {
498 ops.remove(Value::from(verb));
499 }
500 }
501 stripped
502}
503
504fn collapse_multi_media_request_bodies(doc: &mut Value) -> usize {
515 let mut collapsed = 0usize;
516 let Some(paths) = doc
517 .as_mapping_mut()
518 .and_then(|m| m.get_mut(Value::from("paths")))
519 .and_then(Value::as_mapping_mut)
520 else {
521 return 0;
522 };
523
524 const HTTP_VERBS: &[&str] = &["get", "put", "post", "delete", "patch", "options", "head"];
525 for (_path_key, path_item) in paths.iter_mut() {
526 let Some(ops) = path_item.as_mapping_mut() else {
527 continue;
528 };
529 for verb in HTTP_VERBS {
530 let Some(content) = ops
531 .get_mut(Value::from(*verb))
532 .and_then(Value::as_mapping_mut)
533 .and_then(|op| op.get_mut(Value::from("requestBody")))
534 .and_then(Value::as_mapping_mut)
535 .and_then(|body| body.get_mut(Value::from("content")))
536 .and_then(Value::as_mapping_mut)
537 else {
538 continue;
539 };
540 if content.len() <= 1 {
541 continue;
542 }
543 let keep = if content.contains_key(Value::from("application/json")) {
544 "application/json".to_string()
545 } else {
546 content
547 .keys()
548 .next()
549 .and_then(Value::as_str)
550 .map(str::to_owned)
551 .expect("content has >1 entry")
552 };
553 content.retain(|k, _| k.as_str() == Some(keep.as_str()));
554 collapsed += 1;
555 }
556 }
557 collapsed
558}
559
560fn strip_non_success_responses(doc: &mut Value) -> usize {
573 let mut removed = 0usize;
574 let Some(paths) = doc
575 .as_mapping_mut()
576 .and_then(|m| m.get_mut(Value::from("paths")))
577 .and_then(Value::as_mapping_mut)
578 else {
579 return 0;
580 };
581
582 const HTTP_VERBS: &[&str] = &["get", "put", "post", "delete", "patch", "options", "head"];
583 for (_path_key, path_item) in paths.iter_mut() {
584 let Some(ops) = path_item.as_mapping_mut() else {
585 continue;
586 };
587 for verb in HTTP_VERBS {
588 let Some(op) = ops.get_mut(Value::from(*verb)) else {
589 continue;
590 };
591 let Some(responses) = op
592 .as_mapping_mut()
593 .and_then(|m| m.get_mut(Value::from("responses")))
594 .and_then(Value::as_mapping_mut)
595 else {
596 continue;
597 };
598
599 let to_remove: Vec<Value> = responses
600 .iter()
601 .filter_map(|(k, _)| {
602 let key = k.as_str()?;
603 if is_success_or_default(key) {
604 None
605 } else {
606 Some(k.clone())
607 }
608 })
609 .collect();
610 for k in to_remove {
611 if responses.remove(&k).is_some() {
612 removed += 1;
613 }
614 }
615 }
616 }
617 removed
618}
619
620fn is_success_or_default(code: &str) -> bool {
621 if code == "default" {
622 return true;
623 }
624 match code.chars().next() {
626 Some('2') => true,
627 _ => false,
628 }
629}
630
631fn operation_is_multipart_only(op: &Value) -> bool {
632 let Some(body) = op
633 .as_mapping()
634 .and_then(|m| m.get(Value::from("requestBody")))
635 else {
636 return false;
637 };
638 let Some(content) = body
639 .as_mapping()
640 .and_then(|m| m.get(Value::from("content")))
641 .and_then(Value::as_mapping)
642 else {
643 return false;
644 };
645 let keys: Vec<&str> = content.keys().filter_map(Value::as_str).collect();
646 keys.contains(&"multipart/form-data")
651}
652
653fn structural_shape(v: &Value) -> Value {
656 match v {
657 Value::Mapping(m) => {
658 let mut out = Mapping::new();
659 for (k, val) in m {
660 let keep = match k.as_str() {
661 Some("description") | Some("example") | Some("summary") | Some("default")
662 | Some("deprecated") | Some("title") => false,
663 _ => true,
664 };
665 if keep {
666 out.insert(k.clone(), structural_shape(val));
667 }
668 }
669 Value::Mapping(out)
670 }
671 Value::Sequence(s) => Value::Sequence(s.iter().map(structural_shape).collect()),
672 other => other.clone(),
673 }
674}
675
676fn rewrite_refs(doc: &mut Value, from: &str, to: &str) -> usize {
679 let mut count = 0;
680 rewrite_refs_inner(doc, from, to, &mut count);
681 count
682}
683
684fn rewrite_refs_inner(v: &mut Value, from: &str, to: &str, count: &mut usize) {
685 match v {
686 Value::Mapping(m) => {
687 if let Some(target) = m.get_mut(Value::from("$ref")) {
690 if target.as_str() == Some(from) {
691 *target = Value::from(to);
692 *count += 1;
693 }
694 }
695 for (_, val) in m.iter_mut() {
696 rewrite_refs_inner(val, from, to, count);
697 }
698 }
699 Value::Sequence(s) => {
700 for val in s.iter_mut() {
701 rewrite_refs_inner(val, from, to, count);
702 }
703 }
704 _ => {}
705 }
706}
707
708fn get_mapping_path<'a>(v: &'a Value, path: &[&str]) -> Option<&'a Mapping> {
709 let mut cur = v;
710 for key in path {
711 cur = cur.as_mapping()?.get(Value::from(*key))?;
712 }
713 cur.as_mapping()
714}
715
716fn get_mapping_path_mut<'a>(v: &'a mut Value, path: &[&str]) -> Option<&'a mut Mapping> {
717 let mut cur = v;
718 for key in path {
719 cur = cur.as_mapping_mut()?.get_mut(Value::from(*key))?;
720 }
721 cur.as_mapping_mut()
722}
723
724fn tracing_nothing(_r: &Report) {}
725
726#[cfg(test)]
727mod tests {
728 use super::*;
729
730 const BASE_SPEC: &str = r#"
731openapi: 3.0.0
732info:
733 title: test
734 version: 0.0.0
735paths:
736 /foo:
737 get:
738 responses:
739 '200':
740 description: ok
741 '404':
742 description: not found
743 content:
744 application/json:
745 schema:
746 $ref: '#/components/schemas/WalletBalanceNotFoundError'
747 '503':
748 description: unavailable
749 content:
750 application/json:
751 schema:
752 $ref: '#/components/schemas/Error'
753components:
754 schemas:
755 Error:
756 type: object
757 required:
758 - error
759 - message
760 properties:
761 error:
762 type: string
763 description: Error code
764 message:
765 type: string
766 description: Human message
767 WalletBalanceNotFoundError:
768 type: object
769 required:
770 - error
771 - message
772 properties:
773 error:
774 type: string
775 description: Error code
776 example: "wallet_not_applicable"
777 message:
778 type: string
779 description: Human message
780 example: "not a tempo agent"
781 Untouched:
782 type: object
783"#;
784
785 #[test]
786 fn normalize_folds_alias_refs_and_drops_schema() {
787 let (out, report) =
788 normalize_with_aliases(BASE_SPEC, &["WalletBalanceNotFoundError"]).unwrap();
789 assert_eq!(
790 report.folded_aliases,
791 vec!["WalletBalanceNotFoundError".to_string()]
792 );
793 assert_eq!(report.refs_rewritten, 1);
794
795 assert!(
798 out.contains("Error:\n type: object"),
799 "canonical Error schema missing:\n{out}"
800 );
801 assert!(
802 !out.contains("WalletBalanceNotFoundError"),
803 "alias name fully removed, got:\n{out}"
804 );
805
806 assert!(out.contains("Untouched:"));
808 }
809
810 #[test]
811 fn idempotent_when_no_aliases_present() {
812 let (out1, _) = normalize_spec_with_report(BASE_SPEC).unwrap();
813 let (out2, report2) = normalize_spec_with_report(&out1).unwrap();
814 assert_eq!(out1, out2);
815 assert!(report2.folded_aliases.is_empty());
816 assert_eq!(report2.refs_rewritten, 0);
817 }
818
819 #[test]
820 fn drift_check_rejects_mismatched_alias() {
821 let drifted = BASE_SPEC.replace(
823 "WalletBalanceNotFoundError:\n type: object\n required:\n - error\n - message",
824 "WalletBalanceNotFoundError:\n type: object\n required:\n - error\n - message\n - drift_field",
825 );
826 let err = normalize_with_aliases(&drifted, &["WalletBalanceNotFoundError"]).unwrap_err();
827 let msg = format!("{err:#}");
828 assert!(msg.contains("drifted from canonical"), "got: {msg}");
829 }
830
831 #[test]
832 fn missing_error_schema_is_error() {
833 let bad = BASE_SPEC.replace("Error:", "NotError:");
834 let err = normalize_spec(bad.as_str()).unwrap_err();
835 assert!(format!("{err:#}").contains("canonical schema `Error` not found"));
836 }
837
838 const MULTIPART_SPEC: &str = r#"
839openapi: 3.0.0
840info: { title: test, version: 0.0.0 }
841paths:
842 /upload:
843 post:
844 operationId: uploadThing
845 requestBody:
846 required: true
847 content:
848 multipart/form-data:
849 schema:
850 type: object
851 required: [file]
852 properties:
853 file: { type: string, format: binary }
854 responses:
855 '201': { description: ok }
856 get:
857 operationId: listThings
858 responses:
859 '200': { description: ok }
860 /json_only:
861 post:
862 operationId: postJsonThing
863 requestBody:
864 required: true
865 content:
866 application/json:
867 schema: { type: object }
868 responses:
869 '201': { description: ok }
870components:
871 schemas:
872 Error:
873 type: object
874 required: [error, message]
875 properties:
876 error: { type: string }
877 message: { type: string }
878"#;
879
880 #[test]
881 fn non_success_responses_are_stripped() {
882 let (out, report) = normalize_spec_with_report(BASE_SPEC).unwrap();
883 assert_eq!(report.error_responses_stripped, 2);
885 assert!(out.contains("'200':"));
887 assert!(!out.contains("'404':"), "404 not stripped:\n{out}");
889 assert!(!out.contains("'503':"), "503 not stripped:\n{out}");
890 }
891
892 #[test]
893 fn multi_media_request_body_collapses_to_json() {
894 const SPEC: &str = r#"
895openapi: 3.0.0
896info: { title: test, version: 0.0.0 }
897paths:
898 /oauth2/token:
899 post:
900 operationId: oauthToken
901 requestBody:
902 required: true
903 content:
904 application/json:
905 schema: { $ref: '#/components/schemas/Tok' }
906 application/x-www-form-urlencoded:
907 schema: { $ref: '#/components/schemas/Tok' }
908 responses:
909 '200': { description: ok }
910 /forms:
911 post:
912 operationId: formOnly
913 requestBody:
914 content:
915 application/x-www-form-urlencoded:
916 schema: { type: object }
917 text/plain:
918 schema: { type: string }
919 responses:
920 '200': { description: ok }
921components:
922 schemas:
923 Error:
924 type: object
925 required: [error, message]
926 properties:
927 error: { type: string }
928 message: { type: string }
929 Tok:
930 type: object
931"#;
932 let (out, report) = normalize_spec_with_report(SPEC).unwrap();
933 assert_eq!(report.request_media_collapsed, 2);
935 assert_eq!(
938 out.matches("application/json:").count(),
939 1,
940 "expected oauth body kept as json:\n{out}"
941 );
942 assert_eq!(
945 out.matches("application/x-www-form-urlencoded:").count(),
946 1,
947 "expected form-only body kept as form-urlencoded:\n{out}"
948 );
949 assert!(
950 !out.contains("text/plain"),
951 "lower-priority text/plain variant not collapsed:\n{out}"
952 );
953 }
954
955 #[test]
956 fn multipart_only_operation_is_stripped() {
957 let (out, report) = normalize_spec_with_report(MULTIPART_SPEC).unwrap();
958 assert_eq!(report.stripped_operations, vec!["uploadThing".to_string()]);
959 assert!(!out.contains("uploadThing"), "uploadThing not stripped");
960 assert!(out.contains("listThings"), "sibling GET removed by mistake");
962 assert!(
963 out.contains("postJsonThing"),
964 "JSON-only POST removed by mistake"
965 );
966 assert!(out.contains("/upload:"));
968 }
969
970 const NULL_VARIANT_SPEC: &str = r#"
971openapi: 3.0.0
972info: { title: test, version: 0.0.0 }
973paths:
974 /thing:
975 get:
976 responses:
977 '200':
978 description: ok
979 content:
980 application/json:
981 schema:
982 oneOf:
983 - { type: object, properties: { name: { type: string } } }
984 - { type: "null" }
985components:
986 schemas:
987 Error:
988 type: object
989 required: [error, message]
990 properties:
991 error: { type: string }
992 message: { type: string }
993 Nullable:
994 anyOf:
995 - { type: integer }
996 - { type: "null", description: "explicit absence" }
997 AllNull:
998 oneOf:
999 - { type: "null" }
1000 - { type: "null" }
1001 Untouched:
1002 oneOf:
1003 - { type: integer }
1004 - { type: string }
1005"#;
1006
1007 #[test]
1008 fn null_type_variants_are_stripped_and_counted() {
1009 let (out, report) = normalize_spec_with_report(NULL_VARIANT_SPEC).unwrap();
1010 assert_eq!(report.null_variants_stripped, 4);
1012
1013 assert!(out.contains("Nullable:"));
1015 assert!(out.contains("Untouched:"));
1016 assert!(
1018 !out.contains("explicit absence"),
1019 "null variant with sibling description not stripped:\n{out}"
1020 );
1021 assert!(
1023 out.contains("Untouched:") && out.matches("type: string").count() >= 2,
1024 "Untouched.oneOf altered:\n{out}"
1025 );
1026 }
1027
1028 #[test]
1029 fn empty_oneof_after_strip_is_removed_outright() {
1030 let (out, _report) = normalize_spec_with_report(NULL_VARIANT_SPEC).unwrap();
1031 let idx = out.find("AllNull:").expect("AllNull schema present");
1035 let tail = &out[idx..];
1036 let block_end = tail[8..].find("\n ").map_or(tail.len(), |n| n + 8);
1038 let block = &tail[..block_end];
1039 assert!(
1040 !block.contains("oneOf"),
1041 "empty oneOf not removed from AllNull:\n{block}"
1042 );
1043 }
1044
1045 #[test]
1046 fn null_strip_is_idempotent() {
1047 let (out1, _) = normalize_spec_with_report(NULL_VARIANT_SPEC).unwrap();
1048 let (out2, report2) = normalize_spec_with_report(&out1).unwrap();
1049 assert_eq!(out1, out2);
1050 assert_eq!(report2.null_variants_stripped, 0);
1051 }
1052
1053 #[test]
1054 fn response_enums_are_opened_but_request_enums_stay_typed() {
1055 const SPEC: &str = r#"
1056openapi: 3.0.0
1057info: { title: test, version: 0.0.0 }
1058paths:
1059 /thing:
1060 post:
1061 parameters:
1062 - { name: filter, in: query, schema: { type: string, enum: [known] } }
1063 requestBody:
1064 content:
1065 application/json:
1066 schema: { $ref: '#/components/schemas/Request' }
1067 responses:
1068 '200':
1069 description: ok
1070 content:
1071 application/json:
1072 schema: { $ref: '#/components/schemas/Response' }
1073 default:
1074 description: fallback
1075 content:
1076 application/json:
1077 schema: { type: string, enum: [fallback] }
1078 '400':
1079 description: bad request
1080 content:
1081 application/json:
1082 schema: { $ref: '#/components/schemas/Error' }
1083components:
1084 schemas:
1085 Error: { type: string, enum: [bad] }
1086 Request:
1087 type: object
1088 properties:
1089 kind: { type: string, enum: [known] }
1090 shared: { $ref: '#/components/schemas/Shared' }
1091 Shared: { type: string, enum: [known] }
1092 Response:
1093 allOf:
1094 - type: object
1095 properties:
1096 status: { type: string, enum: [active] }
1097 nullable_status: { type: string, nullable: true, enum: [active, null] }
1098 shared: { $ref: '#/components/schemas/Shared' }
1099 recursive: { $ref: '#/components/schemas/Response' }
1100 choices:
1101 oneOf:
1102 - { $ref: '#/components/schemas/Choice' }
1103 - { type: "null" }
1104 alternatives:
1105 anyOf:
1106 - { type: string, enum: [known] }
1107 list:
1108 type: array
1109 items: { $ref: '#/components/schemas/Choice' }
1110 map:
1111 type: object
1112 additionalProperties: { $ref: '#/components/schemas/Choice' }
1113 number: { type: integer, enum: [1, 2] }
1114 Choice: { type: string, enum: [known] }
1115"#;
1116 let (out, _) = normalize_spec_with_report(SPEC).unwrap();
1117 let doc: Value = serde_yaml::from_str(&out).unwrap();
1118 let schemas = &doc["components"]["schemas"];
1119 let properties = &schemas["Response"]["allOf"][0]["properties"];
1120 assert!(properties["status"].get("enum").is_none());
1121 assert!(properties["nullable_status"].get("enum").is_none());
1122 assert_eq!(properties["nullable_status"]["nullable"], Value::Bool(true));
1123 assert!(schemas["Request"]["properties"]["kind"]
1124 .get("enum")
1125 .is_some());
1126 assert!(doc["paths"]["/thing"]["post"]["parameters"][0]["schema"]
1127 .get("enum")
1128 .is_some());
1129 assert!(schemas["Shared"].get("enum").is_none());
1130 assert!(schemas["Choice"].get("enum").is_none());
1131 assert!(properties["alternatives"]["anyOf"][0].get("enum").is_none());
1132 assert!(properties["number"].get("enum").is_some());
1133 assert!(schemas["Error"].get("enum").is_some());
1134 assert!(
1135 doc["paths"]["/thing"]["post"]["responses"]["default"]["content"]["application/json"]
1136 ["schema"]
1137 .get("enum")
1138 .is_none()
1139 );
1140 }
1141
1142 #[test]
1143 fn reusable_responses_follow_refs_without_opening_example_enums() {
1144 const SPEC: &str = r#"
1145openapi: 3.0.0
1146info: { title: test, version: 0.0.0 }
1147paths:
1148 /thing:
1149 get:
1150 responses:
1151 '2XX': { $ref: '#/components/responses/Thing' }
1152components:
1153 responses:
1154 Thing:
1155 description: ok
1156 content:
1157 application/json:
1158 schema: { $ref: '#/components/schemas/Thing~1Status~0' }
1159 example: { type: string, enum: [literal_payload] }
1160 schemas:
1161 Error: { type: object }
1162 Thing/Status~: { type: string, enum: [active] }
1163"#;
1164 let (out, report) = normalize_spec_with_report(SPEC).unwrap();
1165 let doc: Value = serde_yaml::from_str(&out).unwrap();
1166 assert!(doc["components"]["schemas"]["Thing/Status~"]
1167 .get("enum")
1168 .is_none());
1169 assert!(
1170 doc["components"]["responses"]["Thing"]["content"]["application/json"]["example"]
1171 .get("enum")
1172 .is_some()
1173 );
1174 assert_eq!(report.response_enums_stripped, 1);
1175 }
1176
1177 #[test]
1178 fn live_spec_response_enum_inventory_is_normalized() {
1179 fn enum_count(node: &Value) -> usize {
1182 match node {
1183 Value::Mapping(map) => {
1184 usize::from(
1185 map.get("type").and_then(Value::as_str) == Some("string")
1186 && map.contains_key("enum"),
1187 ) + map.values().map(enum_count).sum::<usize>()
1188 }
1189 Value::Sequence(seq) => seq.iter().map(enum_count).sum(),
1190 _ => 0,
1191 }
1192 }
1193 fn refs(node: &Value, schemas: &Mapping, found: &mut BTreeSet<String>) {
1194 match node {
1195 Value::Mapping(map) => {
1196 if let Some(name) = map
1197 .get("$ref")
1198 .and_then(Value::as_str)
1199 .and_then(|target| target.strip_prefix("#/components/schemas/"))
1200 {
1201 if found.insert(name.to_owned()) {
1202 refs(&schemas[name], schemas, found);
1203 }
1204 }
1205 for child in map.values() {
1206 refs(child, schemas, found);
1207 }
1208 }
1209 Value::Sequence(seq) => {
1210 for child in seq {
1211 refs(child, schemas, found);
1212 }
1213 }
1214 _ => {}
1215 }
1216 }
1217 let src = include_str!("../../../spec/openapi.yaml");
1218 let doc: Value = serde_yaml::from_str(src).unwrap();
1219 let schemas = doc["components"]["schemas"].as_mapping().unwrap();
1220 let mut response_refs = BTreeSet::new();
1221 let mut request_refs = BTreeSet::new();
1222 let mut inline_response_ops = 0;
1223 let mut inline_request_ops = 0;
1224 let mut inline_response_enums = 0;
1225 for item in doc["paths"].as_mapping().unwrap().values() {
1226 refs(&item["parameters"], schemas, &mut request_refs);
1227 for verb in [
1228 "get", "put", "post", "delete", "patch", "options", "head", "trace",
1229 ] {
1230 let Some(op) = item.get(verb) else { continue };
1231 refs(&op["requestBody"], schemas, &mut request_refs);
1232 refs(&op["parameters"], schemas, &mut request_refs);
1233 inline_request_ops +=
1234 usize::from(enum_count(&op["requestBody"]) + enum_count(&op["parameters"]) > 0);
1235 let mut op_enums = 0;
1236 for (code, response) in op["responses"].as_mapping().unwrap() {
1237 let code = code.as_str().unwrap();
1238 if code == "default" || code.starts_with('2') {
1239 refs(&response["content"], schemas, &mut response_refs);
1240 op_enums += enum_count(&response["content"]);
1241 }
1242 }
1243 inline_response_ops += usize::from(op_enums > 0);
1244 inline_response_enums += op_enums;
1245 }
1246 }
1247 let enum_components: BTreeSet<String> = schemas
1248 .iter()
1249 .filter(|(_, schema)| enum_count(schema) > 0)
1250 .map(|(name, _)| name.as_str().unwrap().to_owned())
1251 .collect();
1252 let response_components: BTreeSet<_> = enum_components
1253 .intersection(&response_refs)
1254 .cloned()
1255 .collect();
1256 let request_components: BTreeSet<_> = enum_components
1257 .intersection(&request_refs)
1258 .cloned()
1259 .collect();
1260 assert_eq!(
1261 response_components.difference(&request_components).count(),
1262 50
1263 );
1264 assert_eq!(
1265 request_components.difference(&response_components).count(),
1266 8
1267 );
1268 assert_eq!(
1269 response_components
1270 .intersection(&request_components)
1271 .map(String::as_str)
1272 .collect::<Vec<_>>(),
1273 ["CompletionCriterionInput"]
1274 );
1275 assert_eq!(
1276 enum_components
1277 .difference(
1278 &response_components
1279 .union(&request_components)
1280 .cloned()
1281 .collect()
1282 )
1283 .map(String::as_str)
1284 .collect::<Vec<_>>(),
1285 ["RateLimitError"]
1286 );
1287 assert_eq!(inline_response_ops, 3);
1288 assert_eq!(inline_request_ops, 10);
1289 let expected = inline_response_enums
1290 + response_components
1291 .iter()
1292 .map(|name| enum_count(&schemas[name.as_str()]))
1293 .sum::<usize>();
1294 let (out, report) = normalize_spec_with_report(src).unwrap();
1295 assert_eq!(report.response_enums_stripped, expected);
1296 let normalized: Value = serde_yaml::from_str(&out).unwrap();
1297 for name in &response_components {
1298 assert_eq!(
1299 enum_count(&normalized["components"]["schemas"][name.as_str()]),
1300 0,
1301 "response component {name}"
1302 );
1303 }
1304 for name in request_components.difference(&response_components) {
1305 assert_eq!(
1306 enum_count(&normalized["components"]["schemas"][name.as_str()]),
1307 enum_count(&schemas[name.as_str()]),
1308 "request-only component {name}"
1309 );
1310 }
1311 let (again, report) = normalize_spec_with_report(&out).unwrap();
1312 assert_eq!(again, out);
1313 assert_eq!(report.response_enums_stripped, 0);
1314 }
1315
1316 const CLOSED_OBJECT_SPEC: &str = r#"
1317openapi: 3.0.0
1318info:
1319 title: test
1320 version: 0.0.0
1321paths:
1322 /thing:
1323 get:
1324 responses:
1325 '200':
1326 description: ok
1327 content:
1328 application/json:
1329 schema:
1330 type: object
1331 additionalProperties: false
1332 properties:
1333 id:
1334 type: string
1335components:
1336 schemas:
1337 Error:
1338 type: object
1339 additionalProperties: false
1340 properties:
1341 error:
1342 type: string
1343 Open:
1344 type: object
1345 additionalProperties: true
1346"#;
1347
1348 #[test]
1349 fn closed_objects_are_opened_everywhere() {
1350 let (out, _) = normalize_spec_with_report(CLOSED_OBJECT_SPEC).unwrap();
1351 assert!(
1352 !out.contains("additionalProperties: false"),
1353 "closed object survived:\n{out}"
1354 );
1355 assert!(
1356 out.contains("additionalProperties: true"),
1357 "non-false value must be left alone:\n{out}"
1358 );
1359 }
1360}