1use thiserror::Error;
17
18#[derive(Debug, Error)]
20pub enum OverlayError {
21 #[error("the OpenAPI document is not valid YAML or JSON: {0}")]
22 Document(#[source] serde_yaml_ng::Error),
23 #[error("the Overlay document is not valid YAML or JSON: {0}")]
24 Syntax(#[source] serde_yaml_ng::Error),
25 #[error("the Overlay is not a valid Overlay 1.1 document: {0}")]
26 Invalid(#[source] roas_overlay::validation::Error),
27 #[error("the Overlay does not apply: {0}")]
28 Apply(#[source] roas_overlay::apply::ApplyError),
29}
30
31pub fn parse(document: &str) -> Result<serde_json::Value, OverlayError> {
34 serde_yaml_ng::from_str(document).map_err(OverlayError::Document)
35}
36
37pub fn apply(mut doc: serde_json::Value, overlay: &str) -> Result<serde_json::Value, OverlayError> {
51 use roas_overlay::apply::Apply as _;
52 use roas_overlay::validation::Validate as _;
53
54 if overlay.trim().is_empty() {
55 return Ok(doc);
56 }
57 let overlay: roas_overlay::v1_1::Overlay =
58 serde_yaml_ng::from_str(overlay).map_err(OverlayError::Syntax)?;
59 #[expect(
60 clippy::default_trait_access,
61 reason = "the option set is an `enumset::EnumSet`, and naming it here \
62 would mean taking `enumset` as a direct dependency for one word"
63 )]
64 overlay
65 .validate(Default::default())
66 .map_err(OverlayError::Invalid)?;
67 overlay
68 .apply(
69 &mut doc,
70 roas_overlay::apply::ApplyOptions::ErrorOnZeroMatch.into(),
71 )
72 .map_err(OverlayError::Apply)?;
73 Ok(doc)
74}
75
76#[cfg(test)]
77#[expect(
78 clippy::expect_used,
79 clippy::indexing_slicing,
80 reason = "a test that cannot build its fixture should fail loudly and name it"
81)]
82mod tests {
83 use super::*;
84
85 const DOC: &str = r#"{"openapi":"3.0.3","info":{"title":"t","version":"1"},"paths":{}}"#;
86
87 fn document() -> serde_json::Value {
89 parse(DOC).expect("the document parses")
90 }
91
92 #[test]
93 fn an_empty_overlay_leaves_the_document_alone() {
94 let out = apply(document(), " \n").expect("an empty layer is no layer");
95 assert_eq!(out["openapi"], "3.0.3");
96 }
97
98 #[test]
99 fn an_action_that_matches_nothing_is_an_error_not_a_no_op() {
100 let overlay = r#"
101overlay: 1.1.0
102info: { title: t, version: "1" }
103actions:
104 - target: $.components.schemas.Nothing
105 remove: true
106"#;
107 let error = apply(document(), overlay).expect_err("zero matches must fail");
108 assert!(matches!(error, OverlayError::Apply(_)), "{error}");
109 }
110
111 #[test]
112 fn an_action_that_matches_is_applied() {
113 let overlay = r#"
114overlay: 1.1.0
115info: { title: t, version: "1" }
116actions:
117 - target: $.info
118 update: { title: patched }
119"#;
120 let out = apply(document(), overlay).expect("the action matches");
121 assert_eq!(out["info"]["title"], "patched");
122 }
123
124 #[test]
128 fn layers_apply_in_the_order_they_are_given() {
129 let layer = |title: &str| {
130 format!(
131 "overlay: 1.1.0\n\
132 info: {{ title: t, version: \"1\" }}\n\
133 actions:\n\
134 \x20 - target: $.info\n\
135 \x20 update: {{ title: {title} }}\n"
136 )
137 };
138 let layered = |first: &str, second: &str| {
139 let doc = apply(document(), &layer(first)).expect("the first layer applies");
140 apply(doc, &layer(second)).expect("the second layer applies")["info"]["title"]
141 .as_str()
142 .expect("a title")
143 .to_owned()
144 };
145 assert_eq!(layered("first", "second"), "second");
146 assert_eq!(layered("second", "first"), "first");
147 }
148}