skiff_cli/openapi/
extract.rs1use 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}