Skip to main content

romm_cli/tui/
openapi.rs

1//! Parse a subset of OpenAPI 3.x JSON into a flat endpoint list for the TUI expert browser.
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)]
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    #[allow(dead_code)]
262    pub fn get_by_tag(&self, tag: &str) -> Vec<&ApiEndpoint> {
263        self.endpoints
264            .iter()
265            .filter(|ep| ep.tags.contains(&tag.to_string()))
266            .collect()
267    }
268
269    #[allow(dead_code)]
270    pub fn get_by_path_prefix(&self, prefix: &str) -> Vec<&ApiEndpoint> {
271        self.endpoints
272            .iter()
273            .filter(|ep| ep.path.starts_with(prefix))
274            .collect()
275    }
276
277    #[allow(dead_code)]
278    pub fn search(&self, query: &str) -> Vec<&ApiEndpoint> {
279        let query_lower = query.to_lowercase();
280        self.endpoints
281            .iter()
282            .filter(|ep| {
283                ep.path.to_lowercase().contains(&query_lower)
284                    || ep.method.to_lowercase().contains(&query_lower)
285                    || ep
286                        .summary
287                        .as_ref()
288                        .map(|s| s.to_lowercase().contains(&query_lower))
289                        .unwrap_or(false)
290                    || ep
291                        .description
292                        .as_ref()
293                        .map(|s| s.to_lowercase().contains(&query_lower))
294                        .unwrap_or(false)
295            })
296            .collect()
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303
304    #[test]
305    fn skips_path_item_parameters_key_as_operation() {
306        let json = r#"{
307            "openapi": "3.0.0",
308            "paths": {
309                "/api/foo/{id}": {
310                    "parameters": [
311                        {
312                            "name": "id",
313                            "in": "path",
314                            "required": true,
315                            "schema": { "type": "string" }
316                        }
317                    ],
318                    "summary": "path summary",
319                    "get": {
320                        "summary": "get foo",
321                        "responses": { "200": { "description": "ok" } }
322                    }
323                }
324            }
325        }"#;
326
327        let reg = EndpointRegistry::from_openapi_json(json).expect("parse");
328        assert_eq!(reg.endpoints.len(), 1);
329        let ep = &reg.endpoints[0];
330        assert_eq!(ep.method, "GET");
331        assert_eq!(ep.path, "/api/foo/{id}");
332        assert_eq!(ep.path_params.len(), 1);
333        assert_eq!(ep.path_params[0].name, "id");
334    }
335
336    #[test]
337    fn operation_parameters_override_path_level_same_name() {
338        let json = r#"{
339            "openapi": "3.0.0",
340            "paths": {
341                "/x": {
342                    "parameters": [
343                        {
344                            "name": "q",
345                            "in": "query",
346                            "required": false,
347                            "schema": { "type": "string", "default": "base" }
348                        }
349                    ],
350                    "get": {
351                        "parameters": [
352                            {
353                                "name": "q",
354                                "in": "query",
355                                "required": true,
356                                "schema": { "type": "string", "default": "op" }
357                            }
358                        ],
359                        "responses": { "200": { "description": "ok" } }
360                    }
361                }
362            }
363        }"#;
364
365        let reg = EndpointRegistry::from_openapi_json(json).expect("parse");
366        assert_eq!(reg.endpoints[0].query_params.len(), 1);
367        assert_eq!(
368            reg.endpoints[0].query_params[0].default.as_deref(),
369            Some("op")
370        );
371        assert!(reg.endpoints[0].query_params[0].required);
372    }
373
374    #[test]
375    fn is_openapi_operation_method_cases() {
376        assert!(is_openapi_operation_method("get"));
377        assert!(is_openapi_operation_method("GET"));
378        assert!(!is_openapi_operation_method("parameters"));
379        assert!(!is_openapi_operation_method("summary"));
380    }
381
382    #[test]
383    fn resolve_path_template_substitutes_and_encodes() {
384        let p = resolve_path_template("/api/roms/{id}/files", &[("id".into(), "42".into())])
385            .expect("ok");
386        assert_eq!(p, "/api/roms/42/files");
387    }
388
389    #[test]
390    fn resolve_path_template_errors_on_missing_placeholder() {
391        let e = resolve_path_template("/api/{x}", &[]).unwrap_err();
392        assert!(e.to_string().contains("unresolved"));
393    }
394}