Skip to main content

openapi_clap/
spec.rs

1//! OpenAPI spec → internal IR (intermediate representation)
2//!
3//! Parses a dereferenced OpenAPI JSON into a flat list of `ApiOperation`s
4//! that the CLI builder can consume.
5
6use std::collections::HashMap;
7
8use serde_json::Value;
9
10/// A parsed API operation ready for CLI command generation.
11#[derive(Debug, Clone)]
12#[non_exhaustive]
13pub struct ApiOperation {
14    /// operationId from the spec (e.g. "CreatePod")
15    pub operation_id: String,
16    /// HTTP method (GET, POST, etc.)
17    pub method: String,
18    /// URL path template (e.g. "/pods/{podId}")
19    pub path: String,
20    /// First tag (used as command group)
21    pub group: String,
22    /// Summary text for help
23    pub summary: String,
24    /// Path parameters
25    pub path_params: Vec<Param>,
26    /// Query parameters
27    pub query_params: Vec<Param>,
28    /// Header parameters
29    pub header_params: Vec<Param>,
30    /// Request body JSON schema (if any)
31    pub body_schema: Option<Value>,
32    /// Whether request body is required
33    pub body_required: bool,
34}
35
36/// A single API parameter.
37#[derive(Debug, Clone)]
38#[non_exhaustive]
39pub struct Param {
40    pub name: String,
41    pub description: String,
42    pub required: bool,
43    pub schema: Value,
44}
45
46/// Extract all operations from a dereferenced OpenAPI spec.
47pub fn extract_operations(spec: &Value) -> Vec<ApiOperation> {
48    let mut ops = Vec::new();
49
50    let paths = match spec.get("paths").and_then(|p| p.as_object()) {
51        Some(p) => p,
52        None => return ops,
53    };
54
55    for (path, path_item) in paths {
56        let path_level_params = path_item.get("parameters");
57
58        for method in &[
59            "get", "post", "put", "patch", "delete", "head", "options", "trace",
60        ] {
61            let operation = match path_item.get(*method) {
62                Some(op) => op,
63                None => continue,
64            };
65
66            if let Some(op) = extract_single_operation(path, method, operation, path_level_params) {
67                ops.push(op);
68            }
69        }
70    }
71
72    ops
73}
74
75fn extract_single_operation(
76    path: &str,
77    method: &str,
78    operation: &Value,
79    path_level_params: Option<&Value>,
80) -> Option<ApiOperation> {
81    let operation_id = operation
82        .get("operationId")
83        .and_then(|v| v.as_str())
84        .unwrap_or("");
85
86    if operation_id.is_empty() {
87        return None;
88    }
89
90    let summary = operation
91        .get("summary")
92        .or_else(|| operation.get("description"))
93        .and_then(|v| v.as_str())
94        .unwrap_or("")
95        .to_string();
96
97    let group = operation
98        .get("tags")
99        .and_then(|v| v.as_array())
100        .and_then(|arr| arr.first())
101        .and_then(|v| v.as_str())
102        .unwrap_or("other")
103        .to_string();
104
105    let (mut path_params, query_params, header_params) =
106        collect_params(path_level_params, operation.get("parameters"));
107
108    // Sort path params by position in path template, others by name
109    path_params.sort_by_cached_key(|p| path.find(&format!("{{{}}}", p.name)).unwrap_or(usize::MAX));
110
111    let (body_schema, body_required) = extract_body(operation);
112
113    Some(ApiOperation {
114        operation_id: operation_id.to_string(),
115        method: method.to_uppercase(),
116        path: path.to_string(),
117        summary,
118        group,
119        path_params,
120        query_params,
121        header_params,
122        body_schema,
123        body_required,
124    })
125}
126
127/// Merge path-level + operation-level parameters, split by location.
128/// Operation-level overrides path-level per OpenAPI spec.
129fn collect_params(
130    path_level: Option<&Value>,
131    operation_level: Option<&Value>,
132) -> (Vec<Param>, Vec<Param>, Vec<Param>) {
133    let mut param_map: HashMap<(String, String), Param> = HashMap::new();
134
135    for source in [path_level, operation_level].iter().flatten() {
136        if let Some(params) = source.as_array() {
137            for param in params {
138                if let Some((p, location)) = parse_param(param) {
139                    param_map.insert((p.name.clone(), location), p);
140                }
141            }
142        }
143    }
144
145    let mut path_params = Vec::new();
146    let mut query_params = Vec::new();
147    let mut header_params = Vec::new();
148
149    for ((_, location), p) in param_map {
150        match location.as_str() {
151            "path" => path_params.push(p),
152            "query" => query_params.push(p),
153            "header" => header_params.push(p),
154            _ => {}
155        }
156    }
157
158    query_params.sort_by(|a, b| a.name.cmp(&b.name));
159    header_params.sort_by(|a, b| a.name.cmp(&b.name));
160
161    (path_params, query_params, header_params)
162}
163
164fn extract_body(operation: &Value) -> (Option<Value>, bool) {
165    let request_body = operation.get("requestBody");
166    let body_required = request_body
167        .and_then(|rb| rb.get("required"))
168        .and_then(|v| v.as_bool())
169        .unwrap_or(false);
170    let body_schema = request_body
171        .and_then(|rb| rb.get("content"))
172        .and_then(|c| c.get("application/json"))
173        .and_then(|ct| ct.get("schema"))
174        .cloned();
175
176    (body_schema, body_required)
177}
178
179/// Check if a JSON schema describes a boolean type.
180pub fn is_bool_schema(schema: &Value) -> bool {
181    schema.get("type").and_then(|v| v.as_str()) == Some("boolean")
182}
183
184/// Parse a single parameter from its JSON representation.
185fn parse_param(param: &Value) -> Option<(Param, String)> {
186    let name = param.get("name")?.as_str()?.to_string();
187    let location = param.get("in")?.as_str()?.to_string();
188    let description = param
189        .get("description")
190        .and_then(|v| v.as_str())
191        .unwrap_or("")
192        .to_string();
193    let required = param
194        .get("required")
195        .and_then(|v| v.as_bool())
196        .unwrap_or(false);
197    let schema = param
198        .get("schema")
199        .cloned()
200        .unwrap_or(serde_json::json!({"type": "string"}));
201
202    Some((
203        Param {
204            name,
205            description,
206            required,
207            schema,
208        },
209        location,
210    ))
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use serde_json::json;
217
218    #[test]
219    fn extract_operations_valid_spec_with_get_and_post() {
220        let spec = json!({
221            "openapi": "3.0.0",
222            "paths": {
223                "/pods/{podId}": {
224                    "get": {
225                        "operationId": "GetPod",
226                        "summary": "Get a pod",
227                        "tags": ["Pods"],
228                        "parameters": [
229                            {
230                                "name": "podId",
231                                "in": "path",
232                                "required": true,
233                                "description": "Pod identifier",
234                                "schema": { "type": "string" }
235                            },
236                            {
237                                "name": "verbose",
238                                "in": "query",
239                                "required": false,
240                                "description": "Verbose output",
241                                "schema": { "type": "boolean" }
242                            },
243                            {
244                                "name": "X-Request-Id",
245                                "in": "header",
246                                "required": false,
247                                "description": "Request tracking ID",
248                                "schema": { "type": "string" }
249                            }
250                        ]
251                    },
252                    "post": {
253                        "operationId": "CreatePod",
254                        "summary": "Create a pod",
255                        "tags": ["Pods"],
256                        "requestBody": {
257                            "required": true,
258                            "content": {
259                                "application/json": {
260                                    "schema": {
261                                        "type": "object",
262                                        "properties": {
263                                            "name": { "type": "string" }
264                                        }
265                                    }
266                                }
267                            }
268                        }
269                    }
270                }
271            }
272        });
273
274        let ops = extract_operations(&spec);
275        assert_eq!(ops.len(), 2);
276
277        let get_op = ops.iter().find(|o| o.operation_id == "GetPod").unwrap();
278        assert_eq!(get_op.method, "GET");
279        assert_eq!(get_op.path, "/pods/{podId}");
280        assert_eq!(get_op.group, "Pods");
281        assert_eq!(get_op.summary, "Get a pod");
282        assert_eq!(get_op.path_params.len(), 1);
283        assert_eq!(get_op.path_params[0].name, "podId");
284        assert!(get_op.path_params[0].required);
285        assert_eq!(get_op.path_params[0].description, "Pod identifier");
286        assert_eq!(get_op.query_params.len(), 1);
287        assert_eq!(get_op.query_params[0].name, "verbose");
288        assert!(!get_op.query_params[0].required);
289        assert_eq!(get_op.header_params.len(), 1);
290        assert_eq!(get_op.header_params[0].name, "X-Request-Id");
291        assert!(get_op.body_schema.is_none());
292        assert!(!get_op.body_required);
293
294        let post_op = ops.iter().find(|o| o.operation_id == "CreatePod").unwrap();
295        assert_eq!(post_op.method, "POST");
296        assert_eq!(post_op.path, "/pods/{podId}");
297        assert_eq!(post_op.group, "Pods");
298        assert!(post_op.body_schema.is_some());
299        assert!(post_op.body_required);
300        assert!(post_op.path_params.is_empty());
301        assert!(post_op.query_params.is_empty());
302    }
303
304    #[test]
305    fn extract_operations_skips_operations_without_operation_id() {
306        let spec = json!({
307            "openapi": "3.0.0",
308            "paths": {
309                "/health": {
310                    "get": {
311                        "summary": "Health check"
312                    }
313                },
314                "/pods": {
315                    "get": {
316                        "operationId": "ListPods",
317                        "summary": "List pods",
318                        "tags": ["Pods"]
319                    }
320                }
321            }
322        });
323
324        let ops = extract_operations(&spec);
325        assert_eq!(ops.len(), 1);
326        assert_eq!(ops[0].operation_id, "ListPods");
327    }
328
329    #[test]
330    fn extract_operations_returns_empty_for_empty_paths() {
331        let spec = json!({
332            "openapi": "3.0.0",
333            "paths": {}
334        });
335
336        let ops = extract_operations(&spec);
337        assert!(ops.is_empty());
338    }
339
340    #[test]
341    fn extract_operations_returns_empty_when_no_paths_key() {
342        let spec = json!({
343            "openapi": "3.0.0"
344        });
345
346        let ops = extract_operations(&spec);
347        assert!(ops.is_empty());
348    }
349
350    #[test]
351    fn extract_operations_merges_path_and_operation_params_with_override() {
352        let spec = json!({
353            "openapi": "3.0.0",
354            "paths": {
355                "/items/{itemId}": {
356                    "parameters": [
357                        {
358                            "name": "itemId",
359                            "in": "path",
360                            "required": true,
361                            "description": "Path-level description",
362                            "schema": { "type": "string" }
363                        },
364                        {
365                            "name": "shared",
366                            "in": "query",
367                            "required": false,
368                            "description": "Path-level shared param",
369                            "schema": { "type": "string" }
370                        }
371                    ],
372                    "get": {
373                        "operationId": "GetItem",
374                        "tags": ["Items"],
375                        "parameters": [
376                            {
377                                "name": "shared",
378                                "in": "query",
379                                "required": true,
380                                "description": "Operation-level override",
381                                "schema": { "type": "integer" }
382                            }
383                        ]
384                    }
385                }
386            }
387        });
388
389        let ops = extract_operations(&spec);
390        assert_eq!(ops.len(), 1);
391
392        let op = &ops[0];
393        // Path param from path-level should be present
394        assert_eq!(op.path_params.len(), 1);
395        assert_eq!(op.path_params[0].name, "itemId");
396        assert_eq!(op.path_params[0].description, "Path-level description");
397
398        // Query param "shared" should be overridden by operation-level
399        assert_eq!(op.query_params.len(), 1);
400        assert_eq!(op.query_params[0].name, "shared");
401        assert_eq!(op.query_params[0].description, "Operation-level override");
402        assert!(op.query_params[0].required);
403        assert_eq!(op.query_params[0].schema, json!({ "type": "integer" }));
404    }
405
406    #[test]
407    fn extract_operations_uses_description_when_no_summary() {
408        let spec = json!({
409            "openapi": "3.0.0",
410            "paths": {
411                "/pods": {
412                    "get": {
413                        "operationId": "ListPods",
414                        "description": "Fallback description",
415                        "tags": ["Pods"]
416                    }
417                }
418            }
419        });
420
421        let ops = extract_operations(&spec);
422        assert_eq!(ops[0].summary, "Fallback description");
423    }
424
425    #[test]
426    fn extract_operations_defaults_group_to_other() {
427        let spec = json!({
428            "openapi": "3.0.0",
429            "paths": {
430                "/untagged": {
431                    "get": {
432                        "operationId": "UntaggedOp",
433                        "summary": "No tags"
434                    }
435                }
436            }
437        });
438
439        let ops = extract_operations(&spec);
440        assert_eq!(ops[0].group, "other");
441    }
442
443    #[test]
444    fn is_bool_schema_returns_true_for_boolean() {
445        let schema = json!({ "type": "boolean" });
446        assert!(is_bool_schema(&schema));
447    }
448
449    #[test]
450    fn is_bool_schema_returns_false_for_string() {
451        let schema = json!({ "type": "string" });
452        assert!(!is_bool_schema(&schema));
453    }
454
455    #[test]
456    fn is_bool_schema_returns_false_for_no_type() {
457        let schema = json!({});
458        assert!(!is_bool_schema(&schema));
459    }
460}