Skip to main content

skiff_cli/openapi/
extract.rs

1//! Extract CLI commands from an OpenAPI document.
2
3use std::collections::{HashMap, HashSet};
4
5use serde_json::Value;
6
7use crate::coerce::to_kebab;
8use crate::model::{schema_type_to_python, CommandDef, ParamDef, ParamLocation, ParamType};
9
10const HTTP_METHODS: &[&str] = &["get", "post", "put", "delete", "patch"];
11
12pub fn extract_openapi_commands(spec: &Value) -> Vec<CommandDef> {
13    let mut commands = Vec::new();
14    let mut seen_names: HashMap<String, u32> = HashMap::new();
15
16    let Some(paths) = spec.get("paths").and_then(|p| p.as_object()) else {
17        return commands;
18    };
19
20    for (path, methods) in paths {
21        let Some(methods) = methods.as_object() else {
22            continue;
23        };
24        for (method, details) in methods {
25            if !HTTP_METHODS.contains(&method.as_str()) {
26                continue;
27            }
28            let Some(details) = details.as_object() else {
29                continue;
30            };
31
32            let mut name = if let Some(op_id) = details.get("operationId").and_then(|v| v.as_str())
33            {
34                to_kebab(op_id)
35            } else {
36                let slug = path
37                    .trim_matches('/')
38                    .replace('/', "-")
39                    .replace(['{', '}'], "");
40                if slug.is_empty() {
41                    method.clone()
42                } else {
43                    format!("{method}-{slug}")
44                }
45            };
46
47            if seen_names.contains_key(&name) {
48                name = format!("{name}-{method}");
49            }
50            seen_names.insert(name.clone(), 1);
51
52            let desc = details
53                .get("summary")
54                .and_then(|v| v.as_str())
55                .or_else(|| details.get("description").and_then(|v| v.as_str()))
56                .map(|s| s.to_string())
57                .unwrap_or_else(|| format!("{} {path}", method.to_uppercase()));
58
59            let mut params = Vec::new();
60
61            if let Some(param_list) = details.get("parameters").and_then(|v| v.as_array()) {
62                for param in param_list {
63                    let Some(pname) = param.get("name").and_then(|v| v.as_str()) else {
64                        continue;
65                    };
66                    let schema = param
67                        .get("schema")
68                        .cloned()
69                        .unwrap_or_else(|| Value::Object(Default::default()));
70                    let (py_type, suffix) = schema_type_to_python(&schema);
71                    let location = match param.get("in").and_then(|v| v.as_str()).unwrap_or("query")
72                    {
73                        "path" => ParamLocation::Path,
74                        "header" => ParamLocation::Header,
75                        "body" => ParamLocation::Body,
76                        _ => ParamLocation::Query,
77                    };
78                    let description = format!(
79                        "{}{suffix}",
80                        param
81                            .get("description")
82                            .and_then(|v| v.as_str())
83                            .unwrap_or(pname)
84                    );
85                    let choices = enum_choices(&schema);
86                    params.push(ParamDef {
87                        name: to_kebab(pname),
88                        original_name: pname.to_string(),
89                        python_type: py_type,
90                        required: param
91                            .get("required")
92                            .and_then(|v| v.as_bool())
93                            .unwrap_or(false),
94                        description,
95                        choices,
96                        location,
97                        schema,
98                    });
99                }
100            }
101
102            let rb_content = details
103                .get("requestBody")
104                .and_then(|v| v.get("content"))
105                .cloned()
106                .unwrap_or_else(|| Value::Object(Default::default()));
107
108            let multipart_schema = rb_content
109                .get("multipart/form-data")
110                .and_then(|v| v.get("schema"))
111                .cloned()
112                .unwrap_or_else(|| Value::Object(Default::default()));
113            let json_schema = rb_content
114                .get("application/json")
115                .and_then(|v| v.get("schema"))
116                .cloned()
117                .unwrap_or_else(|| Value::Object(Default::default()));
118
119            let mp_props = multipart_schema
120                .get("properties")
121                .and_then(|v| v.as_object())
122                .cloned()
123                .unwrap_or_default();
124            let has_binary = mp_props
125                .values()
126                .any(|p| p.get("format").and_then(|v| v.as_str()) == Some("binary"));
127
128            let (rb_schema, cmd_content_type) = if has_binary {
129                (multipart_schema, Some("multipart/form-data".to_string()))
130            } else if json_schema.get("properties").is_some() {
131                (json_schema, None)
132            } else if !mp_props.is_empty() {
133                (multipart_schema, Some("multipart/form-data".to_string()))
134            } else {
135                (Value::Object(Default::default()), None)
136            };
137
138            let required_fields: HashSet<String> = rb_schema
139                .get("required")
140                .and_then(|v| v.as_array())
141                .map(|arr| {
142                    arr.iter()
143                        .filter_map(|x| x.as_str().map(str::to_string))
144                        .collect()
145                })
146                .unwrap_or_default();
147            let properties = rb_schema
148                .get("properties")
149                .and_then(|v| v.as_object())
150                .cloned()
151                .unwrap_or_default();
152            let has_body = !properties.is_empty();
153
154            for (prop_name, prop_schema) in properties {
155                let is_binary = cmd_content_type.as_deref() == Some("multipart/form-data")
156                    && prop_schema.get("format").and_then(|v| v.as_str()) == Some("binary");
157                let (loc, py_type, suffix) = if is_binary {
158                    (ParamLocation::File, ParamType::String, " (file path)")
159                } else {
160                    let (t, s) = schema_type_to_python(&prop_schema);
161                    (ParamLocation::Body, t, s)
162                };
163                let description = format!(
164                    "{}{suffix}",
165                    prop_schema
166                        .get("description")
167                        .and_then(|v| v.as_str())
168                        .unwrap_or(&prop_name)
169                );
170                params.push(ParamDef {
171                    name: to_kebab(&prop_name),
172                    original_name: prop_name.clone(),
173                    python_type: py_type,
174                    required: required_fields.contains(&prop_name),
175                    description,
176                    choices: enum_choices(&prop_schema),
177                    location: loc,
178                    schema: prop_schema,
179                });
180            }
181
182            commands.push(CommandDef {
183                name,
184                description: desc,
185                params,
186                has_body,
187                method: Some(method.clone()),
188                path: Some(path.clone()),
189                content_type: cmd_content_type,
190                ..Default::default()
191            });
192        }
193    }
194
195    commands
196}
197
198fn enum_choices(schema: &Value) -> Option<Vec<String>> {
199    schema.get("enum").and_then(|v| v.as_array()).map(|arr| {
200        arr.iter()
201            .filter_map(|x| x.as_str().map(str::to_string))
202            .collect()
203    })
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    fn petstore() -> Value {
211        let raw = include_str!("../../tests/fixtures/petstore.json");
212        serde_json::from_str(raw).unwrap()
213    }
214
215    #[test]
216    fn extracts_petstore_commands() {
217        let cmds = extract_openapi_commands(&petstore());
218        assert_eq!(cmds.len(), 5);
219        let names: HashSet<_> = cmds.iter().map(|c| c.name.as_str()).collect();
220        assert!(names.contains("list-pets"));
221        assert!(names.contains("create-pet"));
222        assert!(names.contains("get-pet"));
223        assert!(names.contains("delete-pet"));
224        assert!(names.contains("update-pet"));
225    }
226}