Skip to main content

openapi_to_rust/
overlay.rs

1//! OpenAPI Overlay 1.1 preprocessing using RFC 9535 JSONPath.
2//!
3//! Extensions retain their existing project-specific merge rules and are applied
4//! first. Overlay files and actions run in declaration order. `extends` is target
5//! identity metadata, never a request to load another document. Each action
6//! snapshots its target locations and copy source before mutation. Repeated
7//! selections affect a location once; overlapping locations are processed from
8//! deepest to shallowest, with descending array indices for removal. All valid
9//! `1.1.<numeric patch>` versions share the Overlay 1.1 behavior.
10
11use crate::{GeneratorError, merge_schema_extensions};
12use serde_json::{Map, Value};
13use serde_json_path::JsonPath;
14use std::path::{Component, Path, PathBuf};
15
16/// Build the effective document before validation, defaults, or analysis.
17pub fn preprocess_spec(
18    spec: Value,
19    schema_extensions: &[PathBuf],
20    overlays: &[PathBuf],
21) -> Result<Value, GeneratorError> {
22    let mut document = merge_schema_extensions(spec, schema_extensions)?;
23    for path in overlays {
24        let content = std::fs::read_to_string(path).map_err(|error| {
25            overlay_error(&path.display().to_string(), None, &error.to_string())
26        })?;
27        let overlay =
28            crate::spec_source::parse_spec(&content, &path.to_string_lossy()).map_err(|error| {
29                overlay_error(&path.display().to_string(), None, &error.to_string())
30            })?;
31        document = apply_overlay(document, &overlay, &path.display().to_string())?;
32    }
33    Ok(document)
34}
35
36/// Apply one JSON or YAML-decoded Overlay document in memory.
37///
38/// Diagnostics include `source` and the one-based action number. Removing the
39/// document root is an error because it is not contained in a map or array.
40/// An action specifying both update and copy is rejected: the standard makes
41/// each inactive when the other is present. `remove: true` takes precedence.
42pub fn apply_overlay(
43    mut document: Value,
44    overlay: &Value,
45    source: &str,
46) -> Result<Value, GeneratorError> {
47    let run = || -> Result<(), String> {
48        let object = require_object(overlay, "overlay document")?;
49        validate_fields(object, &["overlay", "info", "extends", "actions"])?;
50        let version = require_string(object, "overlay")?;
51        let version_parts: Vec<_> = version.split('.').collect();
52        if !matches!(version_parts.as_slice(), ["1", "1", patch]
53            if !patch.is_empty() && patch.bytes().all(|byte| byte.is_ascii_digit()))
54        {
55            return Err(format!(
56                "unsupported Overlay version {version:?}; expected 1.1.<numeric patch>"
57            ));
58        }
59        let info = require_object(object.get("info").ok_or("missing info")?, "info")?;
60        validate_fields(info, &["title", "version", "description"])?;
61        require_string(info, "title")?;
62        require_string(info, "version")?;
63        optional_string(info, "description")?;
64        optional_string(object, "extends")?;
65        let actions = object
66            .get("actions")
67            .and_then(Value::as_array)
68            .ok_or("actions must be a nonempty array")?;
69        if actions.is_empty() {
70            return Err("actions must be a nonempty array".into());
71        }
72        Ok(())
73    };
74    run().map_err(|error| overlay_error(source, None, &error.to_string()))?;
75    // Document shape was checked above.
76    if let Some(actions) = overlay.get("actions").and_then(Value::as_array) {
77        for (index, action) in actions.iter().enumerate() {
78            apply_action(&mut document, action)
79                .map_err(|error| overlay_error(source, Some(index + 1), &error))?;
80        }
81    }
82    Ok(document)
83}
84
85fn overlay_error(source: &str, action: Option<usize>, message: &str) -> GeneratorError {
86    let location = action.map_or_else(String::new, |index| format!(", action {index}"));
87    GeneratorError::ValidationError(format!("Overlay '{source}'{location}: {message}"))
88}
89
90fn require_object<'a>(value: &'a Value, label: &str) -> Result<&'a Map<String, Value>, String> {
91    value
92        .as_object()
93        .ok_or_else(|| format!("{label} must be an object"))
94}
95
96fn require_string<'a>(object: &'a Map<String, Value>, field: &str) -> Result<&'a str, String> {
97    object
98        .get(field)
99        .and_then(Value::as_str)
100        .ok_or_else(|| format!("{field} must be a string"))
101}
102
103fn optional_string(object: &Map<String, Value>, field: &str) -> Result<(), String> {
104    if object.contains_key(field) {
105        require_string(object, field)?;
106    }
107    Ok(())
108}
109
110fn validate_fields(object: &Map<String, Value>, fields: &[&str]) -> Result<(), String> {
111    for field in object.keys() {
112        if !fields.contains(&field.as_str()) && !field.starts_with("x-") {
113            return Err(format!("unknown Overlay field {field:?}"));
114        }
115    }
116    Ok(())
117}
118
119fn kind(value: &Value) -> u8 {
120    match value {
121        Value::Object(_) => 0,
122        Value::Array(_) => 1,
123        _ => 2,
124    }
125}
126
127#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
128enum LocationElement {
129    Name(String),
130    Index(usize),
131}
132
133#[derive(Clone)]
134struct Location {
135    pointer: String,
136    elements: Vec<LocationElement>,
137}
138
139fn apply_action(document: &mut Value, action: &Value) -> Result<(), String> {
140    let action = require_object(action, "action")?;
141    validate_fields(
142        action,
143        &["target", "description", "update", "copy", "remove"],
144    )?;
145    optional_string(action, "description")?;
146    optional_string(action, "copy")?;
147    let remove = match action.get("remove") {
148        None => false,
149        Some(Value::Bool(value)) => *value,
150        Some(_) => return Err("remove must be a boolean".into()),
151    };
152    let target = require_string(action, "target")?;
153    let query =
154        JsonPath::parse(target).map_err(|error| format!("invalid target {target:?}: {error}"))?;
155    let update = action.get("update");
156    let copy = action.get("copy").and_then(Value::as_str);
157    // Validate the active action before evaluating targets. A valid unmatched
158    // action is a no-op, but an unmatched target cannot hide malformed syntax.
159    let copy_query = if remove {
160        None
161    } else {
162        if update.is_some() && copy.is_some() {
163            return Err(
164                "update and copy cannot both be active in one action; use separate ordered actions"
165                    .into(),
166            );
167        }
168        copy.map(|expression| {
169            JsonPath::parse(expression)
170                .map_err(|error| format!("invalid copy {expression:?}: {error}"))
171        })
172        .transpose()?
173    };
174    let selected = query.query_located(document).dedup();
175    let mut locations: Vec<Location> = selected
176        .locations()
177        .map(|location| Location {
178            pointer: location.to_json_pointer(),
179            elements: location
180                .iter()
181                .map(|element| match element {
182                    serde_json_path::PathElement::Name(name) => {
183                        LocationElement::Name((*name).to_string())
184                    }
185                    serde_json_path::PathElement::Index(index) => LocationElement::Index(*index),
186                })
187                .collect(),
188        })
189        .collect();
190    // A valid unmatched target is a successful no-op, including copy actions.
191    // Copy cardinality is evaluated only when at least one target exists.
192    if locations.is_empty() {
193        return Ok(());
194    }
195    if !remove
196        && (update.is_some() || copy.is_some())
197        && selected
198            .nodes()
199            .map(kind)
200            .collect::<std::collections::BTreeSet<_>>()
201            .len()
202            > 1
203    {
204        return Err(
205            "update/copy targets must be all objects, all arrays, or all primitives".into(),
206        );
207    }
208    locations.sort_by(|left, right| {
209        right
210            .elements
211            .len()
212            .cmp(&left.elements.len())
213            .then_with(|| right.elements.cmp(&left.elements))
214    });
215    if remove {
216        if locations
217            .iter()
218            .any(|location| location.elements.is_empty())
219        {
220            return Err(
221                "cannot remove the root document; it has no containing map or array".into(),
222            );
223        }
224        for location in locations {
225            remove_location(document, &location)?;
226        }
227        return Ok(());
228    }
229    let payload = if let Some(update) = update {
230        update.clone()
231    } else if let (Some(copy), Some(path)) = (copy, copy_query) {
232        path.query(document)
233            .exactly_one()
234            .map_err(|error| format!("copy {copy:?} must select exactly one node: {error}"))?
235            .clone()
236    } else {
237        return Ok(());
238    };
239    for location in locations {
240        let target = document
241            .pointer_mut(&location.pointer)
242            .ok_or_else(|| format!("target {} disappeared during action", location.pointer))?;
243        merge_target(target, &payload, &location.pointer)?;
244    }
245    Ok(())
246}
247
248fn remove_location(document: &mut Value, location: &Location) -> Result<(), String> {
249    let (parent_pointer, _) = location
250        .pointer
251        .rsplit_once('/')
252        .ok_or("missing parent location")?;
253    let parent = document
254        .pointer_mut(parent_pointer)
255        .ok_or("missing target parent")?;
256    match (parent, location.elements.last()) {
257        (Value::Object(object), Some(LocationElement::Name(name))) => {
258            object.remove(name);
259        }
260        (Value::Array(array), Some(LocationElement::Index(index))) if *index < array.len() => {
261            array.remove(*index);
262        }
263        _ => return Err(format!("cannot remove target {}", location.pointer)),
264    }
265    Ok(())
266}
267
268fn merge_target(target: &mut Value, update: &Value, pointer: &str) -> Result<(), String> {
269    match (target, update) {
270        (Value::Object(target), Value::Object(update)) => {
271            for (name, value) in update {
272                if let Some(existing) = target.get_mut(name) {
273                    let child = format!("{pointer}/{}", name.replace('~', "~0").replace('/', "~1"));
274                    // Recursive object properties require matching value kinds.
275                    if kind(existing) != kind(value) {
276                        return Err(format!("incompatible value kinds at {child}"));
277                    }
278                    merge_target(existing, value, &child)?;
279                } else {
280                    target.insert(name.clone(), value.clone());
281                }
282            }
283        }
284        (Value::Array(target), Value::Array(update)) => target.extend(update.iter().cloned()),
285        (Value::Array(target), value) => target.push(value.clone()),
286        (target, update) if kind(target) == 2 && kind(update) == 2 => *target = update.clone(),
287        _ => return Err(format!("incompatible value kinds at {pointer}")),
288    }
289    Ok(())
290}
291
292/// Validate an artifact filename relative to the output directory.
293pub fn validate_artifact_path(path: &Path) -> Result<(), GeneratorError> {
294    if path.as_os_str().is_empty()
295        || path
296            .components()
297            .any(|part| !matches!(part, Component::Normal(_)))
298    {
299        return Err(GeneratorError::ValidationError(format!(
300            "artifact path '{}' must be a nonempty relative filename inside output_dir",
301            path.display()
302        )));
303    }
304    Ok(())
305}
306
307/// Deterministic JSON materialization of the effective document.
308pub fn effective_spec_json(document: &Value) -> Result<String, GeneratorError> {
309    // Rebuild maps recursively to preserve determinism even if a downstream
310    // consumer enables serde_json's preserve_order feature through unification.
311    fn canonical(value: &Value) -> Value {
312        match value {
313            Value::Object(map) => Value::Object(
314                map.iter()
315                    .map(|(key, value)| (key.clone(), canonical(value)))
316                    .collect::<std::collections::BTreeMap<_, _>>()
317                    .into_iter()
318                    .collect(),
319            ),
320            Value::Array(array) => Value::Array(array.iter().map(canonical).collect()),
321            _ => value.clone(),
322        }
323    }
324    Ok(format!(
325        "{}\n",
326        serde_json::to_string_pretty(&canonical(document))?
327    ))
328}
329
330#[cfg(test)]
331mod tests {
332    use super::apply_overlay;
333    use serde_json::json;
334
335    #[test]
336    fn inactive_actions_allow_mixed_target_kinds() {
337        let document = json!([{}, [], null, 1, true, "text"]);
338        let overlay = json!({"overlay": "1.1.0", "info": {"title": "No-op", "version": "1"}, "actions": [
339            {"target": "$[*]"},
340            {"target": "$[*]", "remove": false}
341        ]});
342        match apply_overlay(document.clone(), &overlay, "inactive.overlay.json") {
343            Ok(effective) => assert_eq!(effective, document),
344            Err(error) => panic!("inactive action failed: {error}"),
345        }
346    }
347
348    #[test]
349    fn overlay_one_one_patch_versions_are_ignored() {
350        for version in ["1.1.0", "1.1.1", "1.1.98765432109876543210"] {
351            let overlay = json!({"overlay": version, "info": {"title": "Version", "version": "1"}, "actions": [{"target": "$"}]});
352            assert!(
353                apply_overlay(json!({}), &overlay, "version.overlay.json").is_ok(),
354                "{version}"
355            );
356        }
357        for version in [
358            "1.0.0", "1.2.0", "2.1.0", "1.1", "1.1.", "1.1.a", "1.1.-1", "1.1.0.1", " 1.1.0",
359            "1.1.0 ",
360        ] {
361            let overlay = json!({"overlay": version, "info": {"title": "Version", "version": "1"}, "actions": [{"target": "$"}]});
362            assert!(
363                apply_overlay(json!({}), &overlay, "version.overlay.json").is_err(),
364                "{version}"
365            );
366        }
367    }
368
369    #[test]
370    fn unmatched_targets_do_not_hide_invalid_active_copy_actions() {
371        for (action, expected) in [
372            (
373                json!({"target": "$.missing", "copy": "not-jsonpath"}),
374                "invalid copy",
375            ),
376            (
377                json!({"target": "$.missing", "update": null, "copy": "$.source"}),
378                "update and copy",
379            ),
380        ] {
381            let overlay = json!({"overlay": "1.1.0", "info": {"title": "Validation", "version": "1"}, "actions": [action]});
382            let error = match apply_overlay(json!({}), &overlay, "invalid.overlay.json") {
383                Err(error) => error.to_string(),
384                Ok(_) => panic!("invalid action succeeded"),
385            };
386            assert!(error.contains(expected), "{error}");
387            assert!(error.contains("invalid.overlay.json', action 1"), "{error}");
388        }
389    }
390
391    #[test]
392    fn remove_precedence_and_valid_unmatched_copy_cardinality_are_preserved() {
393        let overlay = json!({"overlay": "1.1.0", "info": {"title": "No-op", "version": "1"}, "actions": [
394            {"target": "$.missing", "copy": "$.missing_source"},
395            {"target": "$.missing", "copy": "$[*]"},
396            {"target": "$.remove", "remove": true, "update": {}, "copy": "not-jsonpath"},
397            {"target": "$.missing", "remove": true, "update": {}, "copy": "not-jsonpath"}
398        ]});
399        match apply_overlay(
400            json!({"remove": 1, "keep": 2}),
401            &overlay,
402            "precedence.overlay.json",
403        ) {
404            Ok(document) => assert_eq!(document, json!({"keep": 2})),
405            Err(error) => panic!("valid no-op or remove action failed: {error}"),
406        }
407    }
408}