Skip to main content

romm_cli/
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 percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
7use serde_json::Value;
8
9/// Percent-encode set for OpenAPI path parameter values (conservative).
10const PATH_PARAM_ENCODE: &AsciiSet = &CONTROLS
11    .add(b' ')
12    .add(b'"')
13    .add(b'#')
14    .add(b'<')
15    .add(b'>')
16    .add(b'`')
17    .add(b'?')
18    .add(b'{')
19    .add(b'}')
20    .add(b'/')
21    .add(b'%');
22
23/// Replace `{name}` segments in an OpenAPI path template using percent-encoded values.
24/// Returns an error if any `{...}` placeholder remains after substitution.
25pub fn resolve_path_template(template: &str, values: &[(String, String)]) -> Result<String> {
26    let mut out = template.to_string();
27    for (name, raw) in values {
28        let token = format!("{{{}}}", name);
29        if out.contains(&token) {
30            let encoded = utf8_percent_encode(raw, PATH_PARAM_ENCODE).to_string();
31            out = out.replace(&token, &encoded);
32        }
33    }
34    if out.contains('{') {
35        return Err(anyhow!(
36            "unresolved path placeholders in {:?} (fill all path parameters)",
37            template
38        ));
39    }
40    Ok(out)
41}
42
43/// Lowercase OpenAPI path-item operation keys we treat as HTTP methods.
44const OPENAPI_OPERATION_METHODS: &[&str] = &[
45    "get", "post", "put", "delete", "patch", "options", "head", "trace",
46];
47
48/// Returns true if `key` is an OpenAPI operation method (e.g. `get`, `post`), not `parameters` or `summary`.
49pub fn is_openapi_operation_method(key: &str) -> bool {
50    OPENAPI_OPERATION_METHODS
51        .iter()
52        .any(|m| m.eq_ignore_ascii_case(key))
53}
54
55#[derive(Debug, Clone)]
56pub struct ApiParameter {
57    pub name: String,
58    pub param_type: String,
59    pub required: bool,
60    pub default: Option<String>,
61    #[allow(dead_code)]
62    pub description: Option<String>,
63}
64
65#[derive(Debug, Clone)]
66pub struct ApiEndpoint {
67    pub method: String,
68    pub path: String,
69    pub summary: Option<String>,
70    #[allow(dead_code)]
71    pub description: Option<String>,
72    pub query_params: Vec<ApiParameter>,
73    pub path_params: Vec<ApiParameter>,
74    pub has_body: bool,
75    pub tags: Vec<String>,
76}
77
78#[derive(Debug, Clone, Default)]
79pub struct EndpointRegistry {
80    pub endpoints: Vec<ApiEndpoint>,
81}
82
83/// Split OpenAPI `parameters` array into query vs path params. Skips `header`, `cookie`, etc.
84fn parse_parameters_array(params: &[Value]) -> (Vec<ApiParameter>, Vec<ApiParameter>) {
85    let mut query_params = Vec::new();
86    let mut path_params = Vec::new();
87
88    for param in params {
89        let Some(param_obj) = param.as_object() else {
90            continue;
91        };
92        // $ref not resolved — skip
93        if param_obj.contains_key("$ref") {
94            continue;
95        }
96
97        let name = param_obj
98            .get("name")
99            .and_then(|v| v.as_str())
100            .map(|s| s.to_string())
101            .unwrap_or_default();
102
103        let param_in = param_obj
104            .get("in")
105            .and_then(|v| v.as_str())
106            .unwrap_or("query");
107
108        let required = param_obj
109            .get("required")
110            .and_then(|v| v.as_bool())
111            .unwrap_or(false);
112
113        let schema = param_obj.get("schema");
114        let param_type = schema
115            .and_then(|s| s.get("type"))
116            .and_then(|v| v.as_str())
117            .map(|s| s.to_string())
118            .unwrap_or_else(|| "string".to_string());
119
120        let default = schema.and_then(|s| s.get("default")).and_then(|v| {
121            if v.is_string() {
122                v.as_str().map(|s| s.to_string())
123            } else {
124                Some(v.to_string())
125            }
126        });
127
128        let description = param_obj
129            .get("description")
130            .and_then(|v| v.as_str())
131            .map(|s| s.to_string());
132
133        let api_param = ApiParameter {
134            name,
135            param_type,
136            required,
137            default,
138            description,
139        };
140
141        match param_in {
142            "query" => query_params.push(api_param),
143            "path" => path_params.push(api_param),
144            _ => {}
145        }
146    }
147
148    (query_params, path_params)
149}
150
151/// Path-item parameters first; operation parameters with the same name replace query/path entries respectively.
152fn merge_parameter_lists(
153    path_query: Vec<ApiParameter>,
154    path_path: Vec<ApiParameter>,
155    op_query: Vec<ApiParameter>,
156    op_path: Vec<ApiParameter>,
157) -> (Vec<ApiParameter>, Vec<ApiParameter>) {
158    let mut query = path_query;
159    let mut path = path_path;
160
161    for p in op_query {
162        query.retain(|x| x.name != p.name);
163        query.push(p);
164    }
165    for p in op_path {
166        path.retain(|x| x.name != p.name);
167        path.push(p);
168    }
169
170    (query, path)
171}
172
173impl EndpointRegistry {
174    pub fn from_openapi_json(json_str: &str) -> Result<Self> {
175        let value: Value = serde_json::from_str(json_str)
176            .map_err(|e| anyhow!("Failed to parse OpenAPI JSON: {}", e))?;
177
178        let paths = value
179            .get("paths")
180            .and_then(|v| v.as_object())
181            .ok_or_else(|| anyhow!("OpenAPI JSON missing 'paths' object"))?;
182
183        let mut endpoints = Vec::new();
184
185        for (path, path_item) in paths {
186            let path_item = path_item
187                .as_object()
188                .ok_or_else(|| anyhow!("Invalid path definition for {}", path))?;
189
190            let path_level = path_item
191                .get("parameters")
192                .and_then(|v| v.as_array())
193                .map(|a| a.as_slice())
194                .unwrap_or(&[]);
195            let (path_q, path_p) = parse_parameters_array(path_level);
196
197            for (method_key, operation) in path_item {
198                if !is_openapi_operation_method(method_key) {
199                    continue;
200                }
201
202                let operation = operation
203                    .as_object()
204                    .ok_or_else(|| anyhow!("Invalid operation for {} {}", method_key, path))?;
205
206                let summary = operation
207                    .get("summary")
208                    .and_then(|v| v.as_str())
209                    .map(|s| s.to_string());
210                let description = operation
211                    .get("description")
212                    .and_then(|v| v.as_str())
213                    .map(|s| s.to_string());
214
215                let tags = operation
216                    .get("tags")
217                    .and_then(|v| v.as_array())
218                    .map(|arr| {
219                        arr.iter()
220                            .filter_map(|v| v.as_str())
221                            .map(|s| s.to_string())
222                            .collect()
223                    })
224                    .unwrap_or_default();
225
226                let op_level = operation
227                    .get("parameters")
228                    .and_then(|v| v.as_array())
229                    .map(|a| a.as_slice())
230                    .unwrap_or(&[]);
231                let (op_q, op_p) = parse_parameters_array(op_level);
232                let (query_params, path_params) =
233                    merge_parameter_lists(path_q.clone(), path_p.clone(), op_q, op_p);
234
235                let has_body = operation.get("requestBody").is_some();
236
237                endpoints.push(ApiEndpoint {
238                    method: method_key.to_uppercase(),
239                    path: path.clone(),
240                    summary,
241                    description,
242                    query_params,
243                    path_params,
244                    has_body,
245                    tags,
246                });
247            }
248        }
249
250        endpoints.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.method.cmp(&b.method)));
251
252        Ok(EndpointRegistry { endpoints })
253    }
254
255    pub fn from_file(path: &str) -> Result<Self> {
256        let content = std::fs::read_to_string(path)
257            .map_err(|e| anyhow!("Failed to read OpenAPI file {}: {}", path, e))?;
258        Self::from_openapi_json(&content)
259    }
260
261    pub fn has_endpoint(&self, method: &str, path: &str) -> bool {
262        self.endpoints
263            .iter()
264            .any(|ep| ep.method.eq_ignore_ascii_case(method) && ep.path == path)
265    }
266
267    #[allow(dead_code)]
268    pub fn get_by_tag(&self, tag: &str) -> Vec<&ApiEndpoint> {
269        self.endpoints
270            .iter()
271            .filter(|ep| ep.tags.contains(&tag.to_string()))
272            .collect()
273    }
274
275    #[allow(dead_code)]
276    pub fn get_by_path_prefix(&self, prefix: &str) -> Vec<&ApiEndpoint> {
277        self.endpoints
278            .iter()
279            .filter(|ep| ep.path.starts_with(prefix))
280            .collect()
281    }
282
283    #[allow(dead_code)]
284    pub fn search(&self, query: &str) -> Vec<&ApiEndpoint> {
285        let query_lower = query.to_lowercase();
286        self.endpoints
287            .iter()
288            .filter(|ep| {
289                ep.path.to_lowercase().contains(&query_lower)
290                    || ep.method.to_lowercase().contains(&query_lower)
291                    || ep
292                        .summary
293                        .as_ref()
294                        .map(|s| s.to_lowercase().contains(&query_lower))
295                        .unwrap_or(false)
296                    || ep
297                        .description
298                        .as_ref()
299                        .map(|s| s.to_lowercase().contains(&query_lower))
300                        .unwrap_or(false)
301            })
302            .collect()
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    #[test]
311    fn skips_path_item_parameters_key_as_operation() {
312        let json = r#"{
313            "openapi": "3.0.0",
314            "paths": {
315                "/api/foo/{id}": {
316                    "parameters": [
317                        {
318                            "name": "id",
319                            "in": "path",
320                            "required": true,
321                            "schema": { "type": "string" }
322                        }
323                    ],
324                    "summary": "path summary",
325                    "get": {
326                        "summary": "get foo",
327                        "responses": { "200": { "description": "ok" } }
328                    }
329                }
330            }
331        }"#;
332
333        let reg = EndpointRegistry::from_openapi_json(json).expect("parse");
334        assert_eq!(reg.endpoints.len(), 1);
335        let ep = &reg.endpoints[0];
336        assert_eq!(ep.method, "GET");
337        assert_eq!(ep.path, "/api/foo/{id}");
338        assert_eq!(ep.path_params.len(), 1);
339        assert_eq!(ep.path_params[0].name, "id");
340    }
341
342    #[test]
343    fn operation_parameters_override_path_level_same_name() {
344        let json = r#"{
345            "openapi": "3.0.0",
346            "paths": {
347                "/x": {
348                    "parameters": [
349                        {
350                            "name": "q",
351                            "in": "query",
352                            "required": false,
353                            "schema": { "type": "string", "default": "base" }
354                        }
355                    ],
356                    "get": {
357                        "parameters": [
358                            {
359                                "name": "q",
360                                "in": "query",
361                                "required": true,
362                                "schema": { "type": "string", "default": "op" }
363                            }
364                        ],
365                        "responses": { "200": { "description": "ok" } }
366                    }
367                }
368            }
369        }"#;
370
371        let reg = EndpointRegistry::from_openapi_json(json).expect("parse");
372        assert_eq!(reg.endpoints[0].query_params.len(), 1);
373        assert_eq!(
374            reg.endpoints[0].query_params[0].default.as_deref(),
375            Some("op")
376        );
377        assert!(reg.endpoints[0].query_params[0].required);
378    }
379
380    #[test]
381    fn has_endpoint_matches_exact_path_and_case_insensitive_method() {
382        let json = r#"{
383            "openapi": "3.0.0",
384            "paths": {
385                "/api/devices": {
386                    "get": { "responses": { "200": { "description": "ok" } } }
387                },
388                "/api/sync/devices/{device_id}/push-pull": {
389                    "post": { "responses": { "200": { "description": "ok" } } }
390                }
391            }
392        }"#;
393
394        let reg = EndpointRegistry::from_openapi_json(json).expect("parse");
395        assert!(reg.has_endpoint("GET", "/api/devices"));
396        assert!(reg.has_endpoint("post", "/api/sync/devices/{device_id}/push-pull"));
397        assert!(!reg.has_endpoint("POST", "/api/devices"));
398        assert!(!reg.has_endpoint("POST", "/api/sync/devices/1/push-pull"));
399    }
400
401    #[test]
402    fn is_openapi_operation_method_cases() {
403        assert!(is_openapi_operation_method("get"));
404        assert!(is_openapi_operation_method("GET"));
405        assert!(!is_openapi_operation_method("parameters"));
406        assert!(!is_openapi_operation_method("summary"));
407    }
408
409    #[test]
410    fn resolve_path_template_substitutes_and_encodes() {
411        let p = resolve_path_template("/api/roms/{id}/files", &[("id".into(), "42".into())])
412            .expect("ok");
413        assert_eq!(p, "/api/roms/42/files");
414    }
415
416    #[test]
417    fn resolve_path_template_errors_on_missing_placeholder() {
418        let e = resolve_path_template("/api/{x}", &[]).unwrap_err();
419        assert!(e.to_string().contains("unresolved"));
420    }
421}