Skip to main content

typed_openapi/
overlay.rs

1//! Applying the adopter's corrections to the vendor's document.
2//!
3//! *Requires the `document` feature.*
4//!
5//! [`parse`] reads a document into the value a correction edits, and [`apply`]
6//! lays one Overlay over it. A chain of the two is how a document with several
7//! layers of correction is assembled, and everything downstream sees a single
8//! corrected document and never learns that an Overlay existed.
9//!
10//! Layers are ordinary Overlay documents in an order the caller chose. This
11//! module knows nothing about what any of them is *for*: whether a layer
12//! repairs the vendor's mistakes, sharpens a type, or marks an operation for a
13//! command line is a convention an adoption keeps, not a thing the library
14//! can see.
15
16use thiserror::Error;
17
18/// The Overlay, or the document it is applied to, is not usable.
19#[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
31/// Read a document — YAML or JSON, as the vendor ships it — into the value a
32/// correction is applied to.
33pub fn parse(document: &str) -> Result<serde_json::Value, OverlayError> {
34    serde_yaml_ng::from_str(document).map_err(OverlayError::Document)
35}
36
37/// Lay one Overlay over the document as it stands, and hand back the result.
38///
39/// `overlay` is a file's contents, YAML or JSON. An empty one leaves the
40/// document alone, so a layer an adoption has not written yet costs nothing.
41///
42/// Taking and returning the document by value is what lets layers chain: the
43/// second Overlay corrects what the first produced, which is what makes the
44/// order of a list of layers meaningful.
45///
46/// The Overlay is applied with `ErrorOnZeroMatch`, which is what makes a
47/// correction a check as well as an edit: an action whose JSONPath no longer
48/// matches — because the vendor renamed or retyped the thing it corrects — is
49/// an error here rather than a silent no-op.
50pub 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    /// The document as it stands, before any layer.
88    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    /// Layers are applied in the order they are handed over, so a later one
125    /// corrects the document an earlier one produced. Nothing else about an
126    /// ordered list of Overlays is worth saying: this is what the order means.
127    #[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}