Skip to main content

rigg_core/
openapi.rs

1//! Minimal OpenAPI 3.x model for validating custom WebApiSkill contracts
2//! (spec §9). Rigg does not validate the full OpenAPI grammar — only what a
3//! skillset needs: the paths, and the `values[].data` property names of the
4//! request and response envelopes.
5
6use std::path::Path;
7
8use serde_json::Value;
9
10#[derive(Debug, Clone)]
11pub struct ApiSpec {
12    /// Path templates, e.g. `/api/enrich`.
13    pub paths: Vec<String>,
14    /// Property names available under request `values[].data`.
15    pub request_data_props: Vec<String>,
16    /// Property names available under response `values[].data`.
17    pub response_data_props: Vec<String>,
18    /// True when the data schemas allow additional properties (open contract).
19    pub open_props: bool,
20}
21
22pub fn load(path: &Path) -> Result<ApiSpec, String> {
23    let text = std::fs::read_to_string(path).map_err(|e| format!("cannot read spec: {e}"))?;
24    let doc: Value =
25        serde_json::from_str(&text).map_err(|e| format!("spec is not valid JSON: {e}"))?;
26    parse(&doc)
27}
28
29pub fn parse(doc: &Value) -> Result<ApiSpec, String> {
30    if doc.get("openapi").and_then(Value::as_str).is_none() {
31        return Err("missing `openapi` version field".to_string());
32    }
33    let Some(paths_obj) = doc.get("paths").and_then(Value::as_object) else {
34        return Err("missing `paths`".to_string());
35    };
36    let paths: Vec<String> = paths_obj.keys().cloned().collect();
37
38    // Find the first POST operation and walk its request/response envelopes.
39    let mut request_data_props = Vec::new();
40    let mut response_data_props = Vec::new();
41    let mut open_props = true;
42    for item in paths_obj.values() {
43        let Some(post) = item.get("post") else {
44            continue;
45        };
46        let request_schema = post
47            .pointer("/requestBody/content/application~1json/schema")
48            .map(|s| resolve_ref(doc, s));
49        let response_schema = post
50            .pointer("/responses/200/content/application~1json/schema")
51            .map(|s| resolve_ref(doc, s));
52        if let Some(schema) = request_schema {
53            let (props, open) = data_props(doc, &schema);
54            request_data_props = props;
55            open_props = open;
56        }
57        if let Some(schema) = response_schema {
58            let (props, open) = data_props(doc, &schema);
59            response_data_props = props;
60            open_props = open_props || open;
61        }
62        break;
63    }
64    Ok(ApiSpec {
65        paths,
66        request_data_props,
67        response_data_props,
68        open_props,
69    })
70}
71
72/// Resolve a local `$ref` (`#/components/...`), or return the schema as-is.
73fn resolve_ref(doc: &Value, schema: &Value) -> Value {
74    if let Some(reference) = schema.get("$ref").and_then(Value::as_str) {
75        if let Some(path) = reference.strip_prefix("#/") {
76            let pointer = format!("/{}", path);
77            if let Some(resolved) = doc.pointer(&pointer) {
78                return resolve_ref(doc, resolved);
79            }
80        }
81    }
82    schema.clone()
83}
84
85/// Extract `values[].data` property names + openness from an envelope schema.
86fn data_props(doc: &Value, envelope: &Value) -> (Vec<String>, bool) {
87    let data = envelope
88        .pointer("/properties/values/items")
89        .map(|s| resolve_ref(doc, s))
90        .and_then(|items| {
91            items
92                .pointer("/properties/data")
93                .map(|d| resolve_ref(doc, d))
94        });
95    let Some(data) = data else {
96        return (Vec::new(), true);
97    };
98    let props: Vec<String> = data
99        .get("properties")
100        .and_then(Value::as_object)
101        .map(|m| m.keys().cloned().collect())
102        .unwrap_or_default();
103    let open = match data.get("additionalProperties") {
104        Some(Value::Bool(false)) => false,
105        Some(_) | None => props.is_empty() || data.get("additionalProperties").is_some(),
106    };
107    (props, open)
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn parses_the_scaffold_spec() {
116        let spec_doc = crate::scaffold::scaffold_api_spec("enrich");
117        let spec = parse(&spec_doc).unwrap();
118        assert_eq!(spec.paths, vec!["/api/enrich"]);
119        assert!(spec.open_props, "scaffold uses additionalProperties: true");
120    }
121
122    #[test]
123    fn closed_contract_props_are_extracted() {
124        let doc = serde_json::json!({
125            "openapi": "3.1.0",
126            "info": {"title": "t", "version": "1"},
127            "paths": {
128                "/api/translate": {
129                    "post": {
130                        "requestBody": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Req"}}}},
131                        "responses": {"200": {"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Res"}}}}}
132                    }
133                }
134            },
135            "components": {"schemas": {
136                "Req": {"type": "object", "properties": {"values": {"type": "array", "items": {
137                    "type": "object",
138                    "properties": {"recordId": {"type": "string"}, "data": {
139                        "type": "object",
140                        "properties": {"text": {"type": "string"}, "language": {"type": "string"}},
141                        "additionalProperties": false
142                    }}
143                }}}},
144                "Res": {"type": "object", "properties": {"values": {"type": "array", "items": {
145                    "type": "object",
146                    "properties": {"recordId": {"type": "string"}, "data": {
147                        "type": "object",
148                        "properties": {"translation": {"type": "string"}},
149                        "additionalProperties": false
150                    }}
151                }}}}
152            }}
153        });
154        let spec = parse(&doc).unwrap();
155        assert_eq!(spec.paths, vec!["/api/translate"]);
156        let mut req = spec.request_data_props.clone();
157        req.sort();
158        assert_eq!(req, vec!["language".to_string(), "text".to_string()]);
159        assert!(!spec.open_props);
160        assert_eq!(spec.response_data_props, vec!["translation"]);
161    }
162
163    #[test]
164    fn rejects_non_openapi() {
165        assert!(parse(&serde_json::json!({"paths": {}})).is_err());
166        assert!(parse(&serde_json::json!({"openapi": "3.1.0"})).is_err());
167    }
168}