1#![allow(missing_docs, clippy::doc_markdown)]
4
5use std::collections::BTreeSet;
94
95use anyhow::{anyhow, bail, Context, Result};
96use serde_yaml::{Mapping, Value};
97
98pub const ERROR_ALIASES: &[&str] = &[];
108
109const NULL_VARIANT_SCHEMA_KEYS: &[&str] = &["oneOf", "anyOf"];
110
111pub const KEEP_REQUIRED: &[(&str, &str)] = &[
126 ("AgentEventListResponse", "meta"),
128 ("PaginatedMeta", "has_more"),
129 ("AgentEvent", "data"),
134 ("AgentEvent", "event"),
135 ("AgentEvent", "event_id"),
136 ("AgentEvent", "occurred_at"),
137 ("BidEscrowParamsResponse", "amount"),
139 ("BidEscrowParamsResponse", "arbitrator_address"),
140 ("BidEscrowParamsResponse", "chain_id"),
141 ("BidEscrowParamsResponse", "decimals"),
142 ("BidEscrowParamsResponse", "platform_fee_amount"),
143 ("BidEscrowParamsResponse", "platform_wallet"),
144 ("BidEscrowParamsResponse", "poster_approval_deadline"),
145 ("BidEscrowParamsResponse", "poster_approval_min_lifetime"),
146 ("BidEscrowParamsResponse", "task_escrow_contract"),
147 ("BidEscrowParamsResponse", "task_id"),
148 ("BidEscrowParamsResponse", "token_address"),
149 ("BidEscrowParamsResponse", "worker_address"),
150 ("getStakeQuote.200", "required_amount"),
152 ("TaskDraftPrepareResponse", "draft_id"),
154 ("TaskDraftPrepareResponse", "payload_to_sign"),
155 ("TaskDraftPrepareResponse", "token_address"),
156 ("TaskDraftSubmitResponse", "id"),
157 ("TaskDraftSubmitResponse", "status"),
158 ("AgentReadiness", "settlement_domain"),
160 ("AgentReadiness.settlement_domain", "chain_id"),
161 ("AgentReadiness.settlement_domain", "verifying_contract"),
162 ("TaskDetail.allOf.1.settlement_domain", "chain_id"),
163 ("TaskDetail.allOf.1.settlement_domain", "verifying_contract"),
164 ("AgentReadiness", "checks"),
166 ("AgentReadiness.checks", "funded"),
167 ("AgentReadiness.checks", "wallet"),
168 ("AgentReadinessCheck", "status"),
169 ("WebhookConfig", "url"),
170 ("Artifact", "id"),
172];
173
174pub fn normalize_spec(yaml: &str) -> Result<String> {
179 let (out, report) = normalize_with_aliases(yaml, ERROR_ALIASES)?;
180 tracing_nothing(&report); Ok(out)
182}
183
184pub fn normalize_spec_with_report(yaml: &str) -> Result<(String, Report)> {
186 normalize_with_aliases(yaml, ERROR_ALIASES)
187}
188
189fn normalize_with_aliases(yaml: &str, aliases: &[&str]) -> Result<(String, Report)> {
194 let mut doc: Value = serde_yaml::from_str(yaml).context("parse spec YAML")?;
195 let report = normalize_in_place(&mut doc, aliases)?;
196 let out = serde_yaml::to_string(&doc).context("re-serialize normalized spec")?;
197 Ok((out, report))
198}
199
200#[derive(Debug, Default, Clone)]
201pub struct Report {
202 pub folded_aliases: Vec<String>,
205 pub refs_rewritten: usize,
207 pub stripped_operations: Vec<String>,
210 pub error_responses_stripped: usize,
213 pub null_variants_stripped: usize,
217 pub request_media_collapsed: usize,
221 pub response_enums_stripped: usize,
223 pub response_required_relaxed: usize,
226}
227
228fn normalize_in_place(doc: &mut Value, aliases: &[&str]) -> Result<Report> {
229 let mut report = Report::default();
230
231 let schemas_path = ["components", "schemas"];
232 let canonical_shape = {
233 let error = get_mapping_path(doc, &schemas_path)
234 .and_then(|m| m.get(Value::from("Error")))
235 .ok_or_else(|| anyhow!("canonical schema `Error` not found in components.schemas"))?;
236 structural_shape(error)
237 };
238
239 let present_aliases: Vec<String> = {
241 let schemas = get_mapping_path(doc, &schemas_path)
242 .ok_or_else(|| anyhow!("components.schemas not a mapping"))?;
243 aliases
244 .iter()
245 .copied()
246 .filter(|a| schemas.contains_key(Value::from(*a)))
247 .map(str::to_owned)
248 .collect()
249 };
250
251 for alias in &present_aliases {
252 let schema = get_mapping_path(doc, &schemas_path)
253 .and_then(|m| m.get(Value::from(alias.as_str())))
254 .expect("alias presence confirmed above");
255 let alias_shape = structural_shape(schema);
256 if alias_shape != canonical_shape {
257 bail!(
258 "alias `{alias}` has drifted from canonical `Error` shape — \
259 either restore structural equality or remove `{alias}` from ERROR_ALIASES.\n\
260 canonical: {:#?}\nalias: {:#?}",
261 canonical_shape,
262 alias_shape
263 );
264 }
265 }
266
267 for alias in &present_aliases {
269 let from = format!("#/components/schemas/{alias}");
270 let to = "#/components/schemas/Error";
271 report.refs_rewritten += rewrite_refs(doc, &from, to);
272 }
273
274 if let Some(schemas) = get_mapping_path_mut(doc, &schemas_path) {
276 for alias in &present_aliases {
277 schemas.remove(Value::from(alias.as_str()));
278 }
279 }
280 report.folded_aliases = present_aliases;
281
282 report.stripped_operations = strip_multipart_only_operations(doc);
284
285 report.request_media_collapsed = collapse_multi_media_request_bodies(doc);
289
290 report.error_responses_stripped = strip_non_success_responses(doc);
293
294 report.null_variants_stripped = strip_null_type_variants(doc);
299
300 strip_closed_objects(doc);
302
303 report.response_enums_stripped = strip_response_enums(doc);
305
306 report.response_required_relaxed = relax_response_required(doc);
308
309 Ok(report)
310}
311
312fn strip_response_enums(doc: &mut Value) -> usize {
324 let enums: Vec<_> = response_schema_paths(doc)
325 .into_iter()
326 .filter(|path| {
327 value_at_path(doc, path).is_some_and(|schema| {
328 schema.get("type").and_then(Value::as_str) == Some("string")
329 && schema.get("enum").is_some()
330 })
331 })
332 .collect();
333 let mut stripped = 0;
334 for path in enums {
335 if value_at_path_mut(doc, &path)
336 .and_then(Value::as_mapping_mut)
337 .and_then(|map| map.remove("enum"))
338 .is_some()
339 {
340 stripped += 1;
341 }
342 }
343 stripped
344}
345
346fn relax_response_required(doc: &mut Value) -> usize {
358 let mut relaxed = 0;
359 for path in response_schema_paths(doc) {
360 let key = schema_key(doc, &path);
361 let Some(map) = value_at_path_mut(doc, &path).and_then(Value::as_mapping_mut) else {
362 continue;
363 };
364 let Some(Value::Sequence(required)) = map.get_mut("required") else {
365 continue;
366 };
367 let before = required.len();
368 required.retain(|property| {
369 property
370 .as_str()
371 .is_some_and(|property| KEEP_REQUIRED.contains(&(key.as_str(), property)))
372 });
373 relaxed += before - required.len();
374 if required.is_empty() {
375 map.remove("required");
376 }
377 }
378 relaxed
379}
380
381fn schema_key(doc: &Value, path: &[String]) -> String {
386 let (head, rest) = match path {
387 [c, s, name, rest @ ..] if c == "components" && s == "schemas" => (name.clone(), rest),
388 [c, r, name, _content, _media, _schema, rest @ ..]
389 if c == "components" && r == "responses" =>
390 {
391 (format!("responses.{name}"), rest)
392 }
393 [p, route, verb, _responses, code, _content, _media, _schema, rest @ ..]
394 if p == "paths" =>
395 {
396 let op = value_at_path(doc, &path[..3])
397 .and_then(|op| op.get("operationId"))
398 .and_then(Value::as_str)
399 .map_or_else(|| format!("{verb} {route}"), str::to_owned);
400 (format!("{op}.{code}"), rest)
401 }
402 _ => (path.join("."), &[][..]),
403 };
404 std::iter::once(head)
405 .chain(rest.iter().filter(|seg| *seg != "properties").cloned())
406 .collect::<Vec<_>>()
407 .join(".")
408}
409
410fn response_schema_paths(doc: &Value) -> BTreeSet<Vec<String>> {
414 let mut visited = BTreeSet::new();
415 let mut schemas = BTreeSet::new();
416 if let Some(paths) = doc.get("paths").and_then(Value::as_mapping) {
417 for (path, item) in paths {
418 let Some(path) = path.as_str() else { continue };
419 for verb in [
420 "get", "put", "post", "delete", "patch", "options", "head", "trace",
421 ] {
422 let Some(responses) = item
423 .get(verb)
424 .and_then(|op| op.get("responses"))
425 .and_then(Value::as_mapping)
426 else {
427 continue;
428 };
429 for code in responses
430 .keys()
431 .filter_map(Value::as_str)
432 .filter(|code| is_success_or_default(code))
433 {
434 let root = ["paths", path, verb, "responses", code]
435 .map(str::to_owned)
436 .to_vec();
437 collect_response_schemas(doc, &root, &mut visited, &mut schemas);
438 }
439 }
440 }
441 }
442 schemas
443}
444
445fn value_at_path<'a>(doc: &'a Value, path: &[String]) -> Option<&'a Value> {
446 path.iter().try_fold(doc, |node, key| match node {
447 Value::Sequence(seq) => seq.get(key.parse::<usize>().ok()?),
448 _ => node.get(key.as_str()),
449 })
450}
451
452fn value_at_path_mut<'a>(doc: &'a mut Value, path: &[String]) -> Option<&'a mut Value> {
453 path.iter().try_fold(doc, |node, key| match node {
454 Value::Sequence(seq) => seq.get_mut(key.parse::<usize>().ok()?),
455 _ => node.get_mut(key.as_str()),
456 })
457}
458
459fn local_ref_path(node: &Value) -> Option<Vec<String>> {
460 Some(
461 node.get("$ref")?
462 .as_str()?
463 .strip_prefix("#/")?
464 .split('/')
465 .map(|key| key.replace("~1", "/").replace("~0", "~"))
466 .collect(),
467 )
468}
469
470fn collect_response_schemas(
471 doc: &Value,
472 path: &[String],
473 visited: &mut BTreeSet<Vec<String>>,
474 schemas: &mut BTreeSet<Vec<String>>,
475) {
476 if !visited.insert(path.to_vec()) {
477 return;
478 }
479 let Some(response) = value_at_path(doc, path) else {
480 return;
481 };
482 if let Some(target) = local_ref_path(response) {
483 collect_response_schemas(doc, &target, visited, schemas);
484 }
485 if let Some(content) = response.get("content").and_then(Value::as_mapping) {
486 for media in content.keys().filter_map(Value::as_str) {
487 let mut schema_path = path.to_vec();
488 schema_path.extend(["content".to_owned(), media.to_owned(), "schema".to_owned()]);
489 collect_schemas(doc, &schema_path, visited, schemas);
490 }
491 }
492}
493
494fn collect_schemas(
495 doc: &Value,
496 path: &[String],
497 visited: &mut BTreeSet<Vec<String>>,
498 schemas: &mut BTreeSet<Vec<String>>,
499) {
500 if !visited.insert(path.to_vec()) {
501 return;
502 }
503 let Some(schema) = value_at_path(doc, path) else {
504 return;
505 };
506 schemas.insert(path.to_vec());
507 if let Some(target) = local_ref_path(schema) {
508 collect_schemas(doc, &target, visited, schemas);
509 }
510 for key in [
511 "allOf",
512 "oneOf",
513 "anyOf",
514 "properties",
515 "additionalProperties",
516 "items",
517 ] {
518 let Some(child) = schema.get(key) else {
519 continue;
520 };
521 let mut child_path = path.to_vec();
522 child_path.push(key.to_owned());
523 match child {
524 Value::Sequence(seq) => {
525 for index in 0..seq.len() {
526 let mut item_path = child_path.clone();
527 item_path.push(index.to_string());
528 collect_schemas(doc, &item_path, visited, schemas);
529 }
530 }
531 Value::Mapping(map) if key == "properties" => {
532 for property in map.keys().filter_map(Value::as_str) {
533 let mut property_path = child_path.clone();
534 property_path.push(property.to_owned());
535 collect_schemas(doc, &property_path, visited, schemas);
536 }
537 }
538 _ => collect_schemas(doc, &child_path, visited, schemas),
539 }
540 }
541}
542
543fn strip_closed_objects(v: &mut Value) {
552 match v {
553 Value::Mapping(map) => {
554 if map.get("additionalProperties") == Some(&Value::Bool(false)) {
555 map.remove("additionalProperties");
556 }
557 for (_, child) in map.iter_mut() {
558 strip_closed_objects(child);
559 }
560 }
561 Value::Sequence(seq) => seq.iter_mut().for_each(strip_closed_objects),
562 _ => {}
563 }
564}
565
566fn strip_null_type_variants(doc: &mut Value) -> usize {
577 fn is_null_type_schema(v: &Value) -> bool {
578 v.as_mapping()
579 .and_then(|m| m.get("type"))
580 .and_then(Value::as_str)
581 == Some("null")
582 }
583 fn walk(v: &mut Value, count: &mut usize) {
584 match v {
585 Value::Mapping(map) => {
586 for key in NULL_VARIANT_SCHEMA_KEYS {
587 let Some(seq) = map.get_mut(*key).and_then(Value::as_sequence_mut) else {
588 continue;
589 };
590 let before = seq.len();
591 seq.retain(|item| !is_null_type_schema(item));
592 *count += before - seq.len();
593 if before > 0 && seq.is_empty() {
594 map.remove(*key);
595 }
596 }
597 for (_, child) in map.iter_mut() {
598 walk(child, count);
599 }
600 }
601 Value::Sequence(seq) => {
602 for child in seq.iter_mut() {
603 walk(child, count);
604 }
605 }
606 _ => {}
607 }
608 }
609 let mut count = 0;
610 walk(doc, &mut count);
611 count
612}
613
614fn strip_multipart_only_operations(doc: &mut Value) -> Vec<String> {
621 let mut stripped = Vec::new();
622 let Some(paths) = doc
623 .as_mapping_mut()
624 .and_then(|m| m.get_mut(Value::from("paths")))
625 .and_then(Value::as_mapping_mut)
626 else {
627 return stripped;
628 };
629
630 const HTTP_VERBS: &[&str] = &["get", "put", "post", "delete", "patch", "options", "head"];
631 for (_path_key, path_item) in paths.iter_mut() {
632 let Some(ops) = path_item.as_mapping_mut() else {
633 continue;
634 };
635 let mut to_remove = Vec::new();
636 for verb in HTTP_VERBS {
637 if let Some(op) = ops.get(Value::from(*verb)) {
638 if operation_is_multipart_only(op) {
639 let op_id = op
640 .get(Value::from("operationId"))
641 .and_then(Value::as_str)
642 .unwrap_or("<anonymous>")
643 .to_string();
644 to_remove.push(*verb);
645 stripped.push(op_id);
646 }
647 }
648 }
649 for verb in to_remove {
650 ops.remove(Value::from(verb));
651 }
652 }
653 stripped
654}
655
656fn collapse_multi_media_request_bodies(doc: &mut Value) -> usize {
667 let mut collapsed = 0usize;
668 let Some(paths) = doc
669 .as_mapping_mut()
670 .and_then(|m| m.get_mut(Value::from("paths")))
671 .and_then(Value::as_mapping_mut)
672 else {
673 return 0;
674 };
675
676 const HTTP_VERBS: &[&str] = &["get", "put", "post", "delete", "patch", "options", "head"];
677 for (_path_key, path_item) in paths.iter_mut() {
678 let Some(ops) = path_item.as_mapping_mut() else {
679 continue;
680 };
681 for verb in HTTP_VERBS {
682 let Some(content) = ops
683 .get_mut(Value::from(*verb))
684 .and_then(Value::as_mapping_mut)
685 .and_then(|op| op.get_mut(Value::from("requestBody")))
686 .and_then(Value::as_mapping_mut)
687 .and_then(|body| body.get_mut(Value::from("content")))
688 .and_then(Value::as_mapping_mut)
689 else {
690 continue;
691 };
692 if content.len() <= 1 {
693 continue;
694 }
695 let keep = if content.contains_key(Value::from("application/json")) {
696 "application/json".to_string()
697 } else {
698 content
699 .keys()
700 .next()
701 .and_then(Value::as_str)
702 .map(str::to_owned)
703 .expect("content has >1 entry")
704 };
705 content.retain(|k, _| k.as_str() == Some(keep.as_str()));
706 collapsed += 1;
707 }
708 }
709 collapsed
710}
711
712fn strip_non_success_responses(doc: &mut Value) -> usize {
725 let mut removed = 0usize;
726 let Some(paths) = doc
727 .as_mapping_mut()
728 .and_then(|m| m.get_mut(Value::from("paths")))
729 .and_then(Value::as_mapping_mut)
730 else {
731 return 0;
732 };
733
734 const HTTP_VERBS: &[&str] = &["get", "put", "post", "delete", "patch", "options", "head"];
735 for (_path_key, path_item) in paths.iter_mut() {
736 let Some(ops) = path_item.as_mapping_mut() else {
737 continue;
738 };
739 for verb in HTTP_VERBS {
740 let Some(op) = ops.get_mut(Value::from(*verb)) else {
741 continue;
742 };
743 let Some(responses) = op
744 .as_mapping_mut()
745 .and_then(|m| m.get_mut(Value::from("responses")))
746 .and_then(Value::as_mapping_mut)
747 else {
748 continue;
749 };
750
751 let to_remove: Vec<Value> = responses
752 .iter()
753 .filter_map(|(k, _)| {
754 let key = k.as_str()?;
755 if is_success_or_default(key) {
756 None
757 } else {
758 Some(k.clone())
759 }
760 })
761 .collect();
762 for k in to_remove {
763 if responses.remove(&k).is_some() {
764 removed += 1;
765 }
766 }
767 }
768 }
769 removed
770}
771
772fn is_success_or_default(code: &str) -> bool {
773 if code == "default" {
774 return true;
775 }
776 match code.chars().next() {
778 Some('2') => true,
779 _ => false,
780 }
781}
782
783fn operation_is_multipart_only(op: &Value) -> bool {
784 let Some(body) = op
785 .as_mapping()
786 .and_then(|m| m.get(Value::from("requestBody")))
787 else {
788 return false;
789 };
790 let Some(content) = body
791 .as_mapping()
792 .and_then(|m| m.get(Value::from("content")))
793 .and_then(Value::as_mapping)
794 else {
795 return false;
796 };
797 let keys: Vec<&str> = content.keys().filter_map(Value::as_str).collect();
798 keys.contains(&"multipart/form-data")
803}
804
805fn structural_shape(v: &Value) -> Value {
808 match v {
809 Value::Mapping(m) => {
810 let mut out = Mapping::new();
811 for (k, val) in m {
812 let keep = match k.as_str() {
813 Some("description") | Some("example") | Some("summary") | Some("default")
814 | Some("deprecated") | Some("title") => false,
815 _ => true,
816 };
817 if keep {
818 out.insert(k.clone(), structural_shape(val));
819 }
820 }
821 Value::Mapping(out)
822 }
823 Value::Sequence(s) => Value::Sequence(s.iter().map(structural_shape).collect()),
824 other => other.clone(),
825 }
826}
827
828fn rewrite_refs(doc: &mut Value, from: &str, to: &str) -> usize {
831 let mut count = 0;
832 rewrite_refs_inner(doc, from, to, &mut count);
833 count
834}
835
836fn rewrite_refs_inner(v: &mut Value, from: &str, to: &str, count: &mut usize) {
837 match v {
838 Value::Mapping(m) => {
839 if let Some(target) = m.get_mut(Value::from("$ref")) {
842 if target.as_str() == Some(from) {
843 *target = Value::from(to);
844 *count += 1;
845 }
846 }
847 for (_, val) in m.iter_mut() {
848 rewrite_refs_inner(val, from, to, count);
849 }
850 }
851 Value::Sequence(s) => {
852 for val in s.iter_mut() {
853 rewrite_refs_inner(val, from, to, count);
854 }
855 }
856 _ => {}
857 }
858}
859
860fn get_mapping_path<'a>(v: &'a Value, path: &[&str]) -> Option<&'a Mapping> {
861 let mut cur = v;
862 for key in path {
863 cur = cur.as_mapping()?.get(Value::from(*key))?;
864 }
865 cur.as_mapping()
866}
867
868fn get_mapping_path_mut<'a>(v: &'a mut Value, path: &[&str]) -> Option<&'a mut Mapping> {
869 let mut cur = v;
870 for key in path {
871 cur = cur.as_mapping_mut()?.get_mut(Value::from(*key))?;
872 }
873 cur.as_mapping_mut()
874}
875
876fn tracing_nothing(_r: &Report) {}
877
878#[cfg(test)]
879mod tests {
880 use super::*;
881
882 const BASE_SPEC: &str = r#"
883openapi: 3.0.0
884info:
885 title: test
886 version: 0.0.0
887paths:
888 /foo:
889 get:
890 responses:
891 '200':
892 description: ok
893 '404':
894 description: not found
895 content:
896 application/json:
897 schema:
898 $ref: '#/components/schemas/WalletBalanceNotFoundError'
899 '503':
900 description: unavailable
901 content:
902 application/json:
903 schema:
904 $ref: '#/components/schemas/Error'
905components:
906 schemas:
907 Error:
908 type: object
909 required:
910 - error
911 - message
912 properties:
913 error:
914 type: string
915 description: Error code
916 message:
917 type: string
918 description: Human message
919 WalletBalanceNotFoundError:
920 type: object
921 required:
922 - error
923 - message
924 properties:
925 error:
926 type: string
927 description: Error code
928 example: "wallet_not_applicable"
929 message:
930 type: string
931 description: Human message
932 example: "not a tempo agent"
933 Untouched:
934 type: object
935"#;
936
937 #[test]
938 fn normalize_folds_alias_refs_and_drops_schema() {
939 let (out, report) =
940 normalize_with_aliases(BASE_SPEC, &["WalletBalanceNotFoundError"]).unwrap();
941 assert_eq!(
942 report.folded_aliases,
943 vec!["WalletBalanceNotFoundError".to_string()]
944 );
945 assert_eq!(report.refs_rewritten, 1);
946
947 assert!(
950 out.contains("Error:\n type: object"),
951 "canonical Error schema missing:\n{out}"
952 );
953 assert!(
954 !out.contains("WalletBalanceNotFoundError"),
955 "alias name fully removed, got:\n{out}"
956 );
957
958 assert!(out.contains("Untouched:"));
960 }
961
962 #[test]
963 fn idempotent_when_no_aliases_present() {
964 let (out1, _) = normalize_spec_with_report(BASE_SPEC).unwrap();
965 let (out2, report2) = normalize_spec_with_report(&out1).unwrap();
966 assert_eq!(out1, out2);
967 assert!(report2.folded_aliases.is_empty());
968 assert_eq!(report2.refs_rewritten, 0);
969 }
970
971 #[test]
972 fn drift_check_rejects_mismatched_alias() {
973 let drifted = BASE_SPEC.replace(
975 "WalletBalanceNotFoundError:\n type: object\n required:\n - error\n - message",
976 "WalletBalanceNotFoundError:\n type: object\n required:\n - error\n - message\n - drift_field",
977 );
978 let err = normalize_with_aliases(&drifted, &["WalletBalanceNotFoundError"]).unwrap_err();
979 let msg = format!("{err:#}");
980 assert!(msg.contains("drifted from canonical"), "got: {msg}");
981 }
982
983 #[test]
984 fn missing_error_schema_is_error() {
985 let bad = BASE_SPEC.replace("Error:", "NotError:");
986 let err = normalize_spec(bad.as_str()).unwrap_err();
987 assert!(format!("{err:#}").contains("canonical schema `Error` not found"));
988 }
989
990 const MULTIPART_SPEC: &str = r#"
991openapi: 3.0.0
992info: { title: test, version: 0.0.0 }
993paths:
994 /upload:
995 post:
996 operationId: uploadThing
997 requestBody:
998 required: true
999 content:
1000 multipart/form-data:
1001 schema:
1002 type: object
1003 required: [file]
1004 properties:
1005 file: { type: string, format: binary }
1006 responses:
1007 '201': { description: ok }
1008 get:
1009 operationId: listThings
1010 responses:
1011 '200': { description: ok }
1012 /json_only:
1013 post:
1014 operationId: postJsonThing
1015 requestBody:
1016 required: true
1017 content:
1018 application/json:
1019 schema: { type: object }
1020 responses:
1021 '201': { description: ok }
1022components:
1023 schemas:
1024 Error:
1025 type: object
1026 required: [error, message]
1027 properties:
1028 error: { type: string }
1029 message: { type: string }
1030"#;
1031
1032 #[test]
1033 fn non_success_responses_are_stripped() {
1034 let (out, report) = normalize_spec_with_report(BASE_SPEC).unwrap();
1035 assert_eq!(report.error_responses_stripped, 2);
1037 assert!(out.contains("'200':"));
1039 assert!(!out.contains("'404':"), "404 not stripped:\n{out}");
1041 assert!(!out.contains("'503':"), "503 not stripped:\n{out}");
1042 }
1043
1044 #[test]
1045 fn multi_media_request_body_collapses_to_json() {
1046 const SPEC: &str = r#"
1047openapi: 3.0.0
1048info: { title: test, version: 0.0.0 }
1049paths:
1050 /oauth2/token:
1051 post:
1052 operationId: oauthToken
1053 requestBody:
1054 required: true
1055 content:
1056 application/json:
1057 schema: { $ref: '#/components/schemas/Tok' }
1058 application/x-www-form-urlencoded:
1059 schema: { $ref: '#/components/schemas/Tok' }
1060 responses:
1061 '200': { description: ok }
1062 /forms:
1063 post:
1064 operationId: formOnly
1065 requestBody:
1066 content:
1067 application/x-www-form-urlencoded:
1068 schema: { type: object }
1069 text/plain:
1070 schema: { type: string }
1071 responses:
1072 '200': { description: ok }
1073components:
1074 schemas:
1075 Error:
1076 type: object
1077 required: [error, message]
1078 properties:
1079 error: { type: string }
1080 message: { type: string }
1081 Tok:
1082 type: object
1083"#;
1084 let (out, report) = normalize_spec_with_report(SPEC).unwrap();
1085 assert_eq!(report.request_media_collapsed, 2);
1087 assert_eq!(
1090 out.matches("application/json:").count(),
1091 1,
1092 "expected oauth body kept as json:\n{out}"
1093 );
1094 assert_eq!(
1097 out.matches("application/x-www-form-urlencoded:").count(),
1098 1,
1099 "expected form-only body kept as form-urlencoded:\n{out}"
1100 );
1101 assert!(
1102 !out.contains("text/plain"),
1103 "lower-priority text/plain variant not collapsed:\n{out}"
1104 );
1105 }
1106
1107 #[test]
1108 fn multipart_only_operation_is_stripped() {
1109 let (out, report) = normalize_spec_with_report(MULTIPART_SPEC).unwrap();
1110 assert_eq!(report.stripped_operations, vec!["uploadThing".to_string()]);
1111 assert!(!out.contains("uploadThing"), "uploadThing not stripped");
1112 assert!(out.contains("listThings"), "sibling GET removed by mistake");
1114 assert!(
1115 out.contains("postJsonThing"),
1116 "JSON-only POST removed by mistake"
1117 );
1118 assert!(out.contains("/upload:"));
1120 }
1121
1122 const NULL_VARIANT_SPEC: &str = r#"
1123openapi: 3.0.0
1124info: { title: test, version: 0.0.0 }
1125paths:
1126 /thing:
1127 get:
1128 responses:
1129 '200':
1130 description: ok
1131 content:
1132 application/json:
1133 schema:
1134 oneOf:
1135 - { type: object, properties: { name: { type: string } } }
1136 - { type: "null" }
1137components:
1138 schemas:
1139 Error:
1140 type: object
1141 required: [error, message]
1142 properties:
1143 error: { type: string }
1144 message: { type: string }
1145 Nullable:
1146 anyOf:
1147 - { type: integer }
1148 - { type: "null", description: "explicit absence" }
1149 AllNull:
1150 oneOf:
1151 - { type: "null" }
1152 - { type: "null" }
1153 Untouched:
1154 oneOf:
1155 - { type: integer }
1156 - { type: string }
1157"#;
1158
1159 #[test]
1160 fn null_type_variants_are_stripped_and_counted() {
1161 let (out, report) = normalize_spec_with_report(NULL_VARIANT_SPEC).unwrap();
1162 assert_eq!(report.null_variants_stripped, 4);
1164
1165 assert!(out.contains("Nullable:"));
1167 assert!(out.contains("Untouched:"));
1168 assert!(
1170 !out.contains("explicit absence"),
1171 "null variant with sibling description not stripped:\n{out}"
1172 );
1173 assert!(
1175 out.contains("Untouched:") && out.matches("type: string").count() >= 2,
1176 "Untouched.oneOf altered:\n{out}"
1177 );
1178 }
1179
1180 #[test]
1181 fn empty_oneof_after_strip_is_removed_outright() {
1182 let (out, _report) = normalize_spec_with_report(NULL_VARIANT_SPEC).unwrap();
1183 let idx = out.find("AllNull:").expect("AllNull schema present");
1187 let tail = &out[idx..];
1188 let block_end = tail[8..].find("\n ").map_or(tail.len(), |n| n + 8);
1190 let block = &tail[..block_end];
1191 assert!(
1192 !block.contains("oneOf"),
1193 "empty oneOf not removed from AllNull:\n{block}"
1194 );
1195 }
1196
1197 #[test]
1198 fn null_strip_is_idempotent() {
1199 let (out1, _) = normalize_spec_with_report(NULL_VARIANT_SPEC).unwrap();
1200 let (out2, report2) = normalize_spec_with_report(&out1).unwrap();
1201 assert_eq!(out1, out2);
1202 assert_eq!(report2.null_variants_stripped, 0);
1203 }
1204
1205 #[test]
1206 fn response_enums_are_opened_but_request_enums_stay_typed() {
1207 const SPEC: &str = r#"
1208openapi: 3.0.0
1209info: { title: test, version: 0.0.0 }
1210paths:
1211 /thing:
1212 post:
1213 parameters:
1214 - { name: filter, in: query, schema: { type: string, enum: [known] } }
1215 requestBody:
1216 content:
1217 application/json:
1218 schema: { $ref: '#/components/schemas/Request' }
1219 responses:
1220 '200':
1221 description: ok
1222 content:
1223 application/json:
1224 schema: { $ref: '#/components/schemas/Response' }
1225 default:
1226 description: fallback
1227 content:
1228 application/json:
1229 schema: { type: string, enum: [fallback] }
1230 '400':
1231 description: bad request
1232 content:
1233 application/json:
1234 schema: { $ref: '#/components/schemas/Error' }
1235components:
1236 schemas:
1237 Error: { type: string, enum: [bad] }
1238 Request:
1239 type: object
1240 properties:
1241 kind: { type: string, enum: [known] }
1242 shared: { $ref: '#/components/schemas/Shared' }
1243 Shared: { type: string, enum: [known] }
1244 Response:
1245 allOf:
1246 - type: object
1247 properties:
1248 status: { type: string, enum: [active] }
1249 nullable_status: { type: string, nullable: true, enum: [active, null] }
1250 shared: { $ref: '#/components/schemas/Shared' }
1251 recursive: { $ref: '#/components/schemas/Response' }
1252 choices:
1253 oneOf:
1254 - { $ref: '#/components/schemas/Choice' }
1255 - { type: "null" }
1256 alternatives:
1257 anyOf:
1258 - { type: string, enum: [known] }
1259 list:
1260 type: array
1261 items: { $ref: '#/components/schemas/Choice' }
1262 map:
1263 type: object
1264 additionalProperties: { $ref: '#/components/schemas/Choice' }
1265 number: { type: integer, enum: [1, 2] }
1266 Choice: { type: string, enum: [known] }
1267"#;
1268 let (out, _) = normalize_spec_with_report(SPEC).unwrap();
1269 let doc: Value = serde_yaml::from_str(&out).unwrap();
1270 let schemas = &doc["components"]["schemas"];
1271 let properties = &schemas["Response"]["allOf"][0]["properties"];
1272 assert!(properties["status"].get("enum").is_none());
1273 assert!(properties["nullable_status"].get("enum").is_none());
1274 assert_eq!(properties["nullable_status"]["nullable"], Value::Bool(true));
1275 assert!(schemas["Request"]["properties"]["kind"]
1276 .get("enum")
1277 .is_some());
1278 assert!(doc["paths"]["/thing"]["post"]["parameters"][0]["schema"]
1279 .get("enum")
1280 .is_some());
1281 assert!(schemas["Shared"].get("enum").is_none());
1282 assert!(schemas["Choice"].get("enum").is_none());
1283 assert!(properties["alternatives"]["anyOf"][0].get("enum").is_none());
1284 assert!(properties["number"].get("enum").is_some());
1285 assert!(schemas["Error"].get("enum").is_some());
1286 assert!(
1287 doc["paths"]["/thing"]["post"]["responses"]["default"]["content"]["application/json"]
1288 ["schema"]
1289 .get("enum")
1290 .is_none()
1291 );
1292 }
1293
1294 #[test]
1295 fn reusable_responses_follow_refs_without_opening_example_enums() {
1296 const SPEC: &str = r#"
1297openapi: 3.0.0
1298info: { title: test, version: 0.0.0 }
1299paths:
1300 /thing:
1301 get:
1302 responses:
1303 '2XX': { $ref: '#/components/responses/Thing' }
1304components:
1305 responses:
1306 Thing:
1307 description: ok
1308 content:
1309 application/json:
1310 schema: { $ref: '#/components/schemas/Thing~1Status~0' }
1311 example: { type: string, enum: [literal_payload] }
1312 schemas:
1313 Error: { type: object }
1314 Thing/Status~: { type: string, enum: [active] }
1315"#;
1316 let (out, report) = normalize_spec_with_report(SPEC).unwrap();
1317 let doc: Value = serde_yaml::from_str(&out).unwrap();
1318 assert!(doc["components"]["schemas"]["Thing/Status~"]
1319 .get("enum")
1320 .is_none());
1321 assert!(
1322 doc["components"]["responses"]["Thing"]["content"]["application/json"]["example"]
1323 .get("enum")
1324 .is_some()
1325 );
1326 assert_eq!(report.response_enums_stripped, 1);
1327 }
1328
1329 #[test]
1330 fn live_spec_response_enum_inventory_is_normalized() {
1331 fn enum_count(node: &Value) -> usize {
1334 match node {
1335 Value::Mapping(map) => {
1336 usize::from(
1337 map.get("type").and_then(Value::as_str) == Some("string")
1338 && map.contains_key("enum"),
1339 ) + map.values().map(enum_count).sum::<usize>()
1340 }
1341 Value::Sequence(seq) => seq.iter().map(enum_count).sum(),
1342 _ => 0,
1343 }
1344 }
1345 fn refs(node: &Value, schemas: &Mapping, found: &mut BTreeSet<String>) {
1346 match node {
1347 Value::Mapping(map) => {
1348 if let Some(name) = map
1349 .get("$ref")
1350 .and_then(Value::as_str)
1351 .and_then(|target| target.strip_prefix("#/components/schemas/"))
1352 {
1353 if found.insert(name.to_owned()) {
1354 refs(&schemas[name], schemas, found);
1355 }
1356 }
1357 for child in map.values() {
1358 refs(child, schemas, found);
1359 }
1360 }
1361 Value::Sequence(seq) => {
1362 for child in seq {
1363 refs(child, schemas, found);
1364 }
1365 }
1366 _ => {}
1367 }
1368 }
1369 let src = include_str!("../../../spec/openapi.yaml");
1370 let doc: Value = serde_yaml::from_str(src).unwrap();
1371 let schemas = doc["components"]["schemas"].as_mapping().unwrap();
1372 let mut response_refs = BTreeSet::new();
1373 let mut request_refs = BTreeSet::new();
1374 let mut inline_response_ops = 0;
1375 let mut inline_request_ops = 0;
1376 let mut inline_response_enums = 0;
1377 for item in doc["paths"].as_mapping().unwrap().values() {
1378 refs(&item["parameters"], schemas, &mut request_refs);
1379 for verb in [
1380 "get", "put", "post", "delete", "patch", "options", "head", "trace",
1381 ] {
1382 let Some(op) = item.get(verb) else { continue };
1383 refs(&op["requestBody"], schemas, &mut request_refs);
1384 refs(&op["parameters"], schemas, &mut request_refs);
1385 inline_request_ops +=
1386 usize::from(enum_count(&op["requestBody"]) + enum_count(&op["parameters"]) > 0);
1387 let mut op_enums = 0;
1388 for (code, response) in op["responses"].as_mapping().unwrap() {
1389 let code = code.as_str().unwrap();
1390 if code == "default" || code.starts_with('2') {
1391 refs(&response["content"], schemas, &mut response_refs);
1392 op_enums += enum_count(&response["content"]);
1393 }
1394 }
1395 inline_response_ops += usize::from(op_enums > 0);
1396 inline_response_enums += op_enums;
1397 }
1398 }
1399 let enum_components: BTreeSet<String> = schemas
1400 .iter()
1401 .filter(|(_, schema)| enum_count(schema) > 0)
1402 .map(|(name, _)| name.as_str().unwrap().to_owned())
1403 .collect();
1404 let response_components: BTreeSet<_> = enum_components
1405 .intersection(&response_refs)
1406 .cloned()
1407 .collect();
1408 let request_components: BTreeSet<_> = enum_components
1409 .intersection(&request_refs)
1410 .cloned()
1411 .collect();
1412 assert_eq!(
1413 response_components.difference(&request_components).count(),
1414 50
1415 );
1416 assert_eq!(
1417 request_components.difference(&response_components).count(),
1418 8
1419 );
1420 assert_eq!(
1421 response_components
1422 .intersection(&request_components)
1423 .map(String::as_str)
1424 .collect::<Vec<_>>(),
1425 ["CompletionCriterionInput"]
1426 );
1427 assert_eq!(
1428 enum_components
1429 .difference(
1430 &response_components
1431 .union(&request_components)
1432 .cloned()
1433 .collect()
1434 )
1435 .map(String::as_str)
1436 .collect::<Vec<_>>(),
1437 ["RateLimitError"]
1438 );
1439 assert_eq!(inline_response_ops, 3);
1440 assert_eq!(inline_request_ops, 10);
1441 let expected = inline_response_enums
1442 + response_components
1443 .iter()
1444 .map(|name| enum_count(&schemas[name.as_str()]))
1445 .sum::<usize>();
1446 let (out, report) = normalize_spec_with_report(src).unwrap();
1447 assert_eq!(report.response_enums_stripped, expected);
1448 let normalized: Value = serde_yaml::from_str(&out).unwrap();
1449 for name in &response_components {
1450 assert_eq!(
1451 enum_count(&normalized["components"]["schemas"][name.as_str()]),
1452 0,
1453 "response component {name}"
1454 );
1455 }
1456 for name in request_components.difference(&response_components) {
1457 assert_eq!(
1458 enum_count(&normalized["components"]["schemas"][name.as_str()]),
1459 enum_count(&schemas[name.as_str()]),
1460 "request-only component {name}"
1461 );
1462 }
1463 let (again, report) = normalize_spec_with_report(&out).unwrap();
1464 assert_eq!(again, out);
1465 assert_eq!(report.response_enums_stripped, 0);
1466 }
1467
1468 #[test]
1469 fn response_required_is_relaxed_except_kept_and_request_fields() {
1470 const SPEC: &str = r#"
1473openapi: 3.0.0
1474info: { title: test, version: 0.0.0 }
1475paths:
1476 /hooks:
1477 put:
1478 requestBody:
1479 content:
1480 application/json:
1481 schema: { $ref: '#/components/schemas/Request' }
1482 responses:
1483 '200':
1484 description: ok
1485 content:
1486 application/json:
1487 schema: { $ref: '#/components/schemas/WebhookConfig' }
1488 /stake/quote:
1489 get:
1490 operationId: getStakeQuote
1491 responses:
1492 '200':
1493 description: ok
1494 content:
1495 application/json:
1496 schema:
1497 type: object
1498 required: [required_amount, currency]
1499 properties:
1500 required_amount: { type: integer }
1501 currency: { type: string }
1502components:
1503 schemas:
1504 Error: { type: object }
1505 Request:
1506 type: object
1507 required: [url]
1508 properties:
1509 url: { type: string }
1510 WebhookConfig:
1511 type: object
1512 required: [url, secret, nested]
1513 properties:
1514 url: { type: string }
1515 secret: { type: string }
1516 nested:
1517 type: object
1518 required: [inner]
1519 properties:
1520 inner: { type: string }
1521"#;
1522 let (out, report) = normalize_spec_with_report(SPEC).unwrap();
1523 let doc: Value = serde_yaml::from_str(&out).unwrap();
1524 let schemas = &doc["components"]["schemas"];
1525 let required = |node: &Value| -> Vec<String> {
1526 node.get("required")
1527 .and_then(Value::as_sequence)
1528 .map(|seq| {
1529 seq.iter()
1530 .filter_map(Value::as_str)
1531 .map(str::to_owned)
1532 .collect()
1533 })
1534 .unwrap_or_default()
1535 };
1536 assert_eq!(required(&schemas["WebhookConfig"]), ["url"]);
1537 assert!(schemas["WebhookConfig"]["properties"]["nested"]
1538 .get("required")
1539 .is_none());
1540 assert_eq!(required(&schemas["Request"]), ["url"]);
1541 let quote = &doc["paths"]["/stake/quote"]["get"]["responses"]["200"]["content"]
1542 ["application/json"]["schema"];
1543 assert_eq!(required(quote), ["required_amount"]);
1544 assert_eq!(report.response_required_relaxed, 4);
1546 let (again, report) = normalize_spec_with_report(&out).unwrap();
1547 assert_eq!(again, out);
1548 assert_eq!(report.response_required_relaxed, 0);
1549 }
1550
1551 #[test]
1552 fn every_kept_required_entry_exists_in_the_vendored_spec() {
1553 let src = include_str!("../../../spec/openapi.yaml");
1556 let doc: Value = serde_yaml::from_str(src).unwrap();
1557 let mut present = BTreeSet::new();
1558 for path in response_schema_paths(&doc) {
1559 let key = schema_key(&doc, &path);
1560 if let Some(required) = value_at_path(&doc, &path)
1561 .and_then(|schema| schema.get("required"))
1562 .and_then(Value::as_sequence)
1563 {
1564 for property in required.iter().filter_map(Value::as_str) {
1565 present.insert((key.clone(), property.to_owned()));
1566 }
1567 }
1568 }
1569 let missing: Vec<_> = KEEP_REQUIRED
1570 .iter()
1571 .filter(|(key, property)| {
1572 !present.contains(&((*key).to_owned(), (*property).to_owned()))
1573 })
1574 .collect();
1575 assert!(
1576 missing.is_empty(),
1577 "stale KEEP_REQUIRED entries: {missing:?}"
1578 );
1579 }
1580
1581 const CLOSED_OBJECT_SPEC: &str = r#"
1582openapi: 3.0.0
1583info:
1584 title: test
1585 version: 0.0.0
1586paths:
1587 /thing:
1588 get:
1589 responses:
1590 '200':
1591 description: ok
1592 content:
1593 application/json:
1594 schema:
1595 type: object
1596 additionalProperties: false
1597 properties:
1598 id:
1599 type: string
1600components:
1601 schemas:
1602 Error:
1603 type: object
1604 additionalProperties: false
1605 properties:
1606 error:
1607 type: string
1608 Open:
1609 type: object
1610 additionalProperties: true
1611"#;
1612
1613 #[test]
1614 fn closed_objects_are_opened_everywhere() {
1615 let (out, _) = normalize_spec_with_report(CLOSED_OBJECT_SPEC).unwrap();
1616 assert!(
1617 !out.contains("additionalProperties: false"),
1618 "closed object survived:\n{out}"
1619 );
1620 assert!(
1621 out.contains("additionalProperties: true"),
1622 "non-false value must be left alone:\n{out}"
1623 );
1624 }
1625}