Skip to main content

romm_api/
openapi.rs

1//! Parse a subset of OpenAPI 3.x JSON into a flat endpoint list.
2//!
3//! Inline `parameters` only; `$ref` on parameters is not resolved.
4
5use anyhow::{anyhow, Result};
6use serde_json::Value;
7
8/// Lowercase OpenAPI path-item operation keys we treat as HTTP methods.
9const OPENAPI_OPERATION_METHODS: &[&str] = &[
10    "get", "post", "put", "delete", "patch", "options", "head", "trace",
11];
12
13/// Returns true if `key` is an OpenAPI operation method (e.g. `get`, `post`), not `parameters` or `summary`.
14pub fn is_openapi_operation_method(key: &str) -> bool {
15    OPENAPI_OPERATION_METHODS
16        .iter()
17        .any(|m| m.eq_ignore_ascii_case(key))
18}
19
20#[derive(Debug, Clone)]
21pub struct ApiParameter {
22    pub name: String,
23    pub param_type: String,
24    pub required: bool,
25    pub default: Option<String>,
26    #[allow(dead_code)]
27    pub description: Option<String>,
28}
29
30#[derive(Debug, Clone)]
31pub struct ApiEndpoint {
32    pub method: String,
33    pub path: String,
34    pub summary: Option<String>,
35    #[allow(dead_code)]
36    pub description: Option<String>,
37    pub query_params: Vec<ApiParameter>,
38    pub path_params: Vec<ApiParameter>,
39    pub has_body: bool,
40    pub tags: Vec<String>,
41}
42
43#[derive(Debug, Clone, Default)]
44pub struct EndpointRegistry {
45    pub endpoints: Vec<ApiEndpoint>,
46}
47
48/// Split OpenAPI `parameters` array into query vs path params. Skips `header`, `cookie`, etc.
49fn parse_parameters_array(params: &[Value]) -> (Vec<ApiParameter>, Vec<ApiParameter>) {
50    let mut query_params = Vec::new();
51    let mut path_params = Vec::new();
52
53    for param in params {
54        let Some(param_obj) = param.as_object() else {
55            continue;
56        };
57        // $ref not resolved — skip
58        if param_obj.contains_key("$ref") {
59            continue;
60        }
61
62        let name = param_obj
63            .get("name")
64            .and_then(|v| v.as_str())
65            .map(|s| s.to_string())
66            .unwrap_or_default();
67
68        let param_in = param_obj
69            .get("in")
70            .and_then(|v| v.as_str())
71            .unwrap_or("query");
72
73        let required = param_obj
74            .get("required")
75            .and_then(|v| v.as_bool())
76            .unwrap_or(false);
77
78        let schema = param_obj.get("schema");
79        let param_type = schema
80            .and_then(|s| s.get("type"))
81            .and_then(|v| v.as_str())
82            .map(|s| s.to_string())
83            .unwrap_or_else(|| "string".to_string());
84
85        let default = schema.and_then(|s| s.get("default")).and_then(|v| {
86            if v.is_string() {
87                v.as_str().map(|s| s.to_string())
88            } else {
89                Some(v.to_string())
90            }
91        });
92
93        let description = param_obj
94            .get("description")
95            .and_then(|v| v.as_str())
96            .map(|s| s.to_string());
97
98        let api_param = ApiParameter {
99            name,
100            param_type,
101            required,
102            default,
103            description,
104        };
105
106        match param_in {
107            "query" => query_params.push(api_param),
108            "path" => path_params.push(api_param),
109            _ => {}
110        }
111    }
112
113    (query_params, path_params)
114}
115
116/// Path-item parameters first; operation parameters with the same name replace query/path entries respectively.
117fn merge_parameter_lists(
118    path_query: Vec<ApiParameter>,
119    path_path: Vec<ApiParameter>,
120    op_query: Vec<ApiParameter>,
121    op_path: Vec<ApiParameter>,
122) -> (Vec<ApiParameter>, Vec<ApiParameter>) {
123    let mut query = path_query;
124    let mut path = path_path;
125
126    for p in op_query {
127        query.retain(|x| x.name != p.name);
128        query.push(p);
129    }
130    for p in op_path {
131        path.retain(|x| x.name != p.name);
132        path.push(p);
133    }
134
135    (query, path)
136}
137
138impl EndpointRegistry {
139    pub fn from_openapi_json(json_str: &str) -> Result<Self> {
140        let value: Value = serde_json::from_str(json_str)
141            .map_err(|e| anyhow!("Failed to parse OpenAPI JSON: {}", e))?;
142
143        let paths = value
144            .get("paths")
145            .and_then(|v| v.as_object())
146            .ok_or_else(|| anyhow!("OpenAPI JSON missing 'paths' object"))?;
147
148        let mut endpoints = Vec::new();
149
150        for (path, path_item) in paths {
151            let path_item = path_item
152                .as_object()
153                .ok_or_else(|| anyhow!("Invalid path definition for {}", path))?;
154
155            let path_level = path_item
156                .get("parameters")
157                .and_then(|v| v.as_array())
158                .map(|a| a.as_slice())
159                .unwrap_or(&[]);
160            let (path_q, path_p) = parse_parameters_array(path_level);
161
162            for (method_key, operation) in path_item {
163                if !is_openapi_operation_method(method_key) {
164                    continue;
165                }
166
167                let operation = operation
168                    .as_object()
169                    .ok_or_else(|| anyhow!("Invalid operation for {} {}", method_key, path))?;
170
171                let summary = operation
172                    .get("summary")
173                    .and_then(|v| v.as_str())
174                    .map(|s| s.to_string());
175                let description = operation
176                    .get("description")
177                    .and_then(|v| v.as_str())
178                    .map(|s| s.to_string());
179
180                let tags = operation
181                    .get("tags")
182                    .and_then(|v| v.as_array())
183                    .map(|arr| {
184                        arr.iter()
185                            .filter_map(|v| v.as_str())
186                            .map(|s| s.to_string())
187                            .collect()
188                    })
189                    .unwrap_or_default();
190
191                let op_level = operation
192                    .get("parameters")
193                    .and_then(|v| v.as_array())
194                    .map(|a| a.as_slice())
195                    .unwrap_or(&[]);
196                let (op_q, op_p) = parse_parameters_array(op_level);
197                let (query_params, path_params) =
198                    merge_parameter_lists(path_q.clone(), path_p.clone(), op_q, op_p);
199
200                let has_body = operation.get("requestBody").is_some();
201
202                endpoints.push(ApiEndpoint {
203                    method: method_key.to_uppercase(),
204                    path: path.clone(),
205                    summary,
206                    description,
207                    query_params,
208                    path_params,
209                    has_body,
210                    tags,
211                });
212            }
213        }
214
215        endpoints.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.method.cmp(&b.method)));
216
217        Ok(EndpointRegistry { endpoints })
218    }
219
220    pub fn from_file(path: &str) -> Result<Self> {
221        let content = std::fs::read_to_string(path)
222            .map_err(|e| anyhow!("Failed to read OpenAPI file {}: {}", path, e))?;
223        Self::from_openapi_json(&content)
224    }
225
226    pub fn has_endpoint(&self, method: &str, path: &str) -> bool {
227        self.endpoints
228            .iter()
229            .any(|ep| ep.method.eq_ignore_ascii_case(method) && ep.path == path)
230    }
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn skips_path_item_parameters_key_as_operation() {
239        let json = r#"{
240            "openapi": "3.0.0",
241            "paths": {
242                "/api/foo/{id}": {
243                    "parameters": [
244                        {
245                            "name": "id",
246                            "in": "path",
247                            "required": true,
248                            "schema": { "type": "string" }
249                        }
250                    ],
251                    "summary": "path summary",
252                    "get": {
253                        "summary": "get foo",
254                        "responses": { "200": { "description": "ok" } }
255                    }
256                }
257            }
258        }"#;
259
260        let reg = EndpointRegistry::from_openapi_json(json).expect("parse");
261        assert_eq!(reg.endpoints.len(), 1);
262        let ep = &reg.endpoints[0];
263        assert_eq!(ep.method, "GET");
264        assert_eq!(ep.path, "/api/foo/{id}");
265        assert_eq!(ep.path_params.len(), 1);
266        assert_eq!(ep.path_params[0].name, "id");
267    }
268
269    #[test]
270    fn operation_parameters_override_path_level_same_name() {
271        let json = r#"{
272            "openapi": "3.0.0",
273            "paths": {
274                "/x": {
275                    "parameters": [
276                        {
277                            "name": "q",
278                            "in": "query",
279                            "required": false,
280                            "schema": { "type": "string", "default": "base" }
281                        }
282                    ],
283                    "get": {
284                        "parameters": [
285                            {
286                                "name": "q",
287                                "in": "query",
288                                "required": true,
289                                "schema": { "type": "string", "default": "op" }
290                            }
291                        ],
292                        "responses": { "200": { "description": "ok" } }
293                    }
294                }
295            }
296        }"#;
297
298        let reg = EndpointRegistry::from_openapi_json(json).expect("parse");
299        assert_eq!(reg.endpoints[0].query_params.len(), 1);
300        assert_eq!(
301            reg.endpoints[0].query_params[0].default.as_deref(),
302            Some("op")
303        );
304        assert!(reg.endpoints[0].query_params[0].required);
305    }
306
307    #[test]
308    fn has_endpoint_matches_exact_path_and_case_insensitive_method() {
309        let json = r#"{
310            "openapi": "3.0.0",
311            "paths": {
312                "/api/devices": {
313                    "get": { "responses": { "200": { "description": "ok" } } }
314                },
315                "/api/sync/devices/{device_id}/push-pull": {
316                    "post": { "responses": { "200": { "description": "ok" } } }
317                }
318            }
319        }"#;
320
321        let reg = EndpointRegistry::from_openapi_json(json).expect("parse");
322        assert!(reg.has_endpoint("GET", "/api/devices"));
323        assert!(reg.has_endpoint("post", "/api/sync/devices/{device_id}/push-pull"));
324        assert!(!reg.has_endpoint("POST", "/api/devices"));
325        assert!(!reg.has_endpoint("POST", "/api/sync/devices/1/push-pull"));
326    }
327
328    #[test]
329    fn is_openapi_operation_method_cases() {
330        assert!(is_openapi_operation_method("get"));
331        assert!(is_openapi_operation_method("GET"));
332        assert!(!is_openapi_operation_method("parameters"));
333        assert!(!is_openapi_operation_method("summary"));
334    }
335}