1use std::collections::{HashMap, HashSet};
4
5use serde_json::{json, Map, Value};
6
7use crate::coerce::to_kebab;
8use crate::model::{CommandDef, ParamDef, ParamLocation, ParamType};
9
10pub fn unwrap_type(type_ref: &Value) -> (Value, bool, bool) {
12 let mut is_non_null = false;
13 let mut is_list = false;
14 let mut t = type_ref.clone();
15 loop {
16 let kind = t.get("kind").and_then(|k| k.as_str()).unwrap_or("");
17 match kind {
18 "NON_NULL" => {
19 is_non_null = true;
20 t = t.get("ofType").cloned().unwrap_or(Value::Null);
21 }
22 "LIST" => {
23 is_list = true;
24 t = t.get("ofType").cloned().unwrap_or(Value::Null);
25 }
26 _ => return (t, is_non_null, is_list),
27 }
28 if t.is_null() {
29 return (type_ref.clone(), is_non_null, is_list);
30 }
31 }
32}
33
34pub fn graphql_type_string(type_ref: &Value) -> String {
36 let kind = type_ref.get("kind").and_then(|k| k.as_str()).unwrap_or("");
37 match kind {
38 "NON_NULL" => {
39 let inner = graphql_type_string(type_ref.get("ofType").unwrap_or(&Value::Null));
40 format!("{inner}!")
41 }
42 "LIST" => {
43 let inner = graphql_type_string(type_ref.get("ofType").unwrap_or(&Value::Null));
44 format!("[{inner}]")
45 }
46 _ => type_ref
47 .get("name")
48 .and_then(|n| n.as_str())
49 .unwrap_or("String")
50 .to_string(),
51 }
52}
53
54pub fn graphql_type_to_python(
56 type_ref: &Value,
57 types_by_name: &HashMap<String, Value>,
58) -> (ParamType, bool, Option<Vec<String>>) {
59 let (named, is_non_null, is_list) = unwrap_type(type_ref);
60 let type_name = named.get("name").and_then(|n| n.as_str()).unwrap_or("");
61 let type_kind = named.get("kind").and_then(|k| k.as_str()).unwrap_or("");
62
63 if is_list {
64 return (ParamType::String, is_non_null, None);
65 }
66
67 if type_kind == "ENUM" {
68 let choices = types_by_name
69 .get(type_name)
70 .and_then(|t| t.get("enumValues"))
71 .and_then(|v| v.as_array())
72 .map(|arr| {
73 arr.iter()
74 .filter_map(|ev| ev.get("name").and_then(|n| n.as_str()).map(str::to_string))
75 .collect::<Vec<_>>()
76 })
77 .filter(|c| !c.is_empty());
78 return (ParamType::String, is_non_null, choices);
79 }
80
81 if type_kind == "INPUT_OBJECT" {
82 return (ParamType::String, is_non_null, None);
83 }
84
85 let py_type = match type_name {
86 "Int" => ParamType::Integer,
87 "Float" => ParamType::Float,
88 "Boolean" => ParamType::Boolean,
89 _ => ParamType::String, };
91 (py_type, is_non_null, None)
92}
93
94fn build_graphql_param(arg: &Value, types_by_name: &HashMap<String, Value>) -> ParamDef {
95 let type_ref = arg.get("type").unwrap_or(&Value::Null);
96 let (py_type, required, choices) = graphql_type_to_python(type_ref, types_by_name);
97 let gql_type_str = graphql_type_string(type_ref);
98 let (named_t, _, is_list) = unwrap_type(type_ref);
99
100 let mut param_schema = Map::new();
101 param_schema.insert("graphql_type".into(), Value::String(gql_type_str));
102
103 if is_list {
104 param_schema.insert("type".into(), Value::String("array".into()));
105 let item_type_name = named_t
106 .get("name")
107 .and_then(|n| n.as_str())
108 .unwrap_or("String");
109 let item_json = match item_type_name {
110 "Int" => "integer",
111 "Float" => "number",
112 "Boolean" => "boolean",
113 _ => "string",
114 };
115 param_schema.insert("items".into(), json!({ "type": item_json }));
116 } else if named_t.get("kind").and_then(|k| k.as_str()) == Some("INPUT_OBJECT") {
117 param_schema.insert("type".into(), Value::String("object".into()));
118 } else if named_t.get("kind").and_then(|k| k.as_str()) == Some("ENUM") {
119 param_schema.insert("type".into(), Value::String("string".into()));
120 } else if named_t.get("name").and_then(|n| n.as_str()) == Some("Boolean") {
121 param_schema.insert("type".into(), Value::String("boolean".into()));
123 } else if named_t.get("name").and_then(|n| n.as_str()) == Some("Int") {
124 param_schema.insert("type".into(), Value::String("integer".into()));
125 } else if named_t.get("name").and_then(|n| n.as_str()) == Some("Float") {
126 param_schema.insert("type".into(), Value::String("number".into()));
127 }
128
129 let arg_name = arg.get("name").and_then(|n| n.as_str()).unwrap_or("arg");
130 let mut arg_desc = arg
131 .get("description")
132 .and_then(|d| d.as_str())
133 .unwrap_or(arg_name)
134 .to_string();
135 if is_list {
136 arg_desc.push_str(" (JSON array)");
137 } else if named_t.get("kind").and_then(|k| k.as_str()) == Some("INPUT_OBJECT") {
138 arg_desc.push_str(" (JSON object)");
139 }
140
141 ParamDef {
142 name: to_kebab(arg_name),
143 original_name: arg_name.to_string(),
144 python_type: py_type,
145 required,
146 description: arg_desc,
147 choices,
148 location: ParamLocation::GraphqlArg,
149 schema: Value::Object(param_schema),
150 }
151}
152
153fn detect_field_collisions(query_fields: &[Value], mutation_fields: &[Value]) -> HashSet<String> {
154 let mut all_names = HashSet::new();
155 let mut collisions = HashSet::new();
156 for f in query_fields.iter().chain(mutation_fields.iter()) {
157 if let Some(n) = f.get("name").and_then(|n| n.as_str()) {
158 if !all_names.insert(n.to_string()) {
159 collisions.insert(n.to_string());
160 }
161 }
162 }
163 collisions
164}
165
166pub fn extract_graphql_commands(schema: &Value) -> Vec<CommandDef> {
168 let types_by_name: HashMap<String, Value> = schema
169 .get("types")
170 .and_then(|t| t.as_array())
171 .map(|arr| {
172 arr.iter()
173 .filter_map(|t| {
174 let name = t.get("name").and_then(|n| n.as_str())?;
175 Some((name.to_string(), t.clone()))
176 })
177 .collect()
178 })
179 .unwrap_or_default();
180
181 let query_type_name = schema.pointer("/queryType/name").and_then(|n| n.as_str());
182 let mutation_type_name = schema
183 .pointer("/mutationType/name")
184 .and_then(|n| n.as_str());
185
186 let query_fields: Vec<Value> = query_type_name
187 .and_then(|n| types_by_name.get(n))
188 .and_then(|t| t.get("fields"))
189 .and_then(|f| f.as_array())
190 .cloned()
191 .unwrap_or_default();
192 let mutation_fields: Vec<Value> = mutation_type_name
193 .and_then(|n| types_by_name.get(n))
194 .and_then(|t| t.get("fields"))
195 .and_then(|f| f.as_array())
196 .cloned()
197 .unwrap_or_default();
198
199 let collisions = detect_field_collisions(&query_fields, &mutation_fields);
200 let mut commands = Vec::new();
201 let mut seen_names = HashSet::new();
202
203 for (op_type, fields) in [("query", &query_fields), ("mutation", &mutation_fields)] {
204 for field_def in fields {
205 let field_name = match field_def.get("name").and_then(|n| n.as_str()) {
206 Some(n) if !n.starts_with("__") => n,
207 _ => continue,
208 };
209
210 let mut cli_name = to_kebab(field_name);
211 if collisions.contains(field_name) {
212 cli_name = format!("{op_type}-{cli_name}");
213 }
214 if seen_names.contains(&cli_name) {
215 cli_name = format!("{op_type}-{cli_name}");
216 }
217 seen_names.insert(cli_name.clone());
218
219 let desc = field_def
220 .get("description")
221 .and_then(|d| d.as_str())
222 .map(str::to_string)
223 .unwrap_or_else(|| format!("{op_type} {field_name}"));
224
225 let params: Vec<ParamDef> = field_def
226 .get("args")
227 .and_then(|a| a.as_array())
228 .map(|arr| {
229 arr.iter()
230 .map(|arg| build_graphql_param(arg, &types_by_name))
231 .collect()
232 })
233 .unwrap_or_default();
234
235 let has_body = !params.is_empty();
236 commands.push(CommandDef {
237 name: cli_name,
238 description: desc,
239 params,
240 has_body,
241 graphql_operation_type: Some(op_type.into()),
242 graphql_field_name: Some(field_name.into()),
243 graphql_return_type: field_def.get("type").cloned(),
244 ..Default::default()
245 });
246 }
247 }
248
249 commands
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use serde_json::json;
256
257 fn sample_schema() -> Value {
258 json!({
259 "queryType": {"name": "Query"},
260 "mutationType": {"name": "Mutation"},
261 "types": [
262 {
263 "kind": "OBJECT",
264 "name": "Query",
265 "fields": [
266 {
267 "name": "users",
268 "description": "List all users",
269 "args": [],
270 "type": {
271 "kind": "NON_NULL",
272 "ofType": {
273 "kind": "LIST",
274 "ofType": {
275 "kind": "NON_NULL",
276 "ofType": {"kind": "OBJECT", "name": "User"}
277 }
278 }
279 }
280 },
281 {
282 "name": "user",
283 "description": "Get a user by ID",
284 "args": [{
285 "name": "id",
286 "description": "User ID",
287 "type": {
288 "kind": "NON_NULL",
289 "ofType": {"kind": "SCALAR", "name": "ID"}
290 }
291 }],
292 "type": {"kind": "OBJECT", "name": "User"}
293 },
294 {
295 "name": "usersByIds",
296 "description": "Get users by a list of IDs",
297 "args": [{
298 "name": "ids",
299 "description": "User IDs",
300 "type": {
301 "kind": "NON_NULL",
302 "ofType": {
303 "kind": "LIST",
304 "ofType": {
305 "kind": "NON_NULL",
306 "ofType": {"kind": "SCALAR", "name": "ID"}
307 }
308 }
309 }
310 }],
311 "type": {
312 "kind": "NON_NULL",
313 "ofType": {
314 "kind": "LIST",
315 "ofType": {
316 "kind": "NON_NULL",
317 "ofType": {"kind": "OBJECT", "name": "User"}
318 }
319 }
320 }
321 }
322 ]
323 },
324 {
325 "kind": "OBJECT",
326 "name": "Mutation",
327 "fields": [
328 {
329 "name": "createUser",
330 "description": "Create a new user",
331 "args": [
332 {
333 "name": "name",
334 "description": "User name",
335 "type": {
336 "kind": "NON_NULL",
337 "ofType": {"kind": "SCALAR", "name": "String"}
338 }
339 },
340 {
341 "name": "email",
342 "description": "User email",
343 "type": {
344 "kind": "NON_NULL",
345 "ofType": {"kind": "SCALAR", "name": "String"}
346 }
347 },
348 {
349 "name": "age",
350 "description": "User age",
351 "type": {"kind": "SCALAR", "name": "Int"}
352 }
353 ],
354 "type": {"kind": "OBJECT", "name": "User"}
355 },
356 {
357 "name": "deleteUser",
358 "description": "Delete a user by ID",
359 "args": [{
360 "name": "id",
361 "description": "User ID",
362 "type": {
363 "kind": "NON_NULL",
364 "ofType": {"kind": "SCALAR", "name": "ID"}
365 }
366 }],
367 "type": {"kind": "SCALAR", "name": "Boolean"}
368 }
369 ]
370 },
371 {
372 "kind": "OBJECT",
373 "name": "User",
374 "fields": [
375 {"name": "id", "args": [], "type": {"kind": "NON_NULL", "ofType": {"kind": "SCALAR", "name": "ID"}}},
376 {"name": "name", "args": [], "type": {"kind": "NON_NULL", "ofType": {"kind": "SCALAR", "name": "String"}}},
377 {"name": "email", "args": [], "type": {"kind": "SCALAR", "name": "String"}},
378 {"name": "age", "args": [], "type": {"kind": "SCALAR", "name": "Int"}},
379 {"name": "status", "args": [], "type": {"kind": "ENUM", "name": "Status"}},
380 {"name": "address", "args": [], "type": {"kind": "OBJECT", "name": "Address"}},
381 {"name": "node", "args": [], "type": {"kind": "INTERFACE", "name": "Node"}}
382 ]
383 },
384 {
385 "kind": "OBJECT",
386 "name": "Address",
387 "fields": [
388 {"name": "city", "args": [], "type": {"kind": "SCALAR", "name": "String"}},
389 {"name": "country", "args": [], "type": {"kind": "SCALAR", "name": "String"}}
390 ]
391 },
392 {
393 "kind": "INTERFACE",
394 "name": "Node",
395 "fields": [
396 {"name": "id", "args": [], "type": {"kind": "NON_NULL", "ofType": {"kind": "SCALAR", "name": "ID"}}}
397 ]
398 },
399 {
400 "kind": "ENUM",
401 "name": "Status",
402 "enumValues": [
403 {"name": "ACTIVE"},
404 {"name": "INACTIVE"},
405 {"name": "BANNED"}
406 ]
407 },
408 {"kind": "SCALAR", "name": "ID"},
409 {"kind": "SCALAR", "name": "String"},
410 {"kind": "SCALAR", "name": "Int"},
411 {"kind": "SCALAR", "name": "Float"},
412 {"kind": "SCALAR", "name": "Boolean"},
413 {
414 "kind": "INPUT_OBJECT",
415 "name": "UserInput",
416 "inputFields": []
417 }
418 ]
419 })
420 }
421
422 #[test]
423 fn type_string_basics() {
424 assert_eq!(
425 graphql_type_string(&json!({"kind": "SCALAR", "name": "String"})),
426 "String"
427 );
428 assert_eq!(
429 graphql_type_string(&json!({
430 "kind": "NON_NULL",
431 "ofType": {"kind": "SCALAR", "name": "String"}
432 })),
433 "String!"
434 );
435 assert_eq!(
436 graphql_type_string(&json!({
437 "kind": "LIST",
438 "ofType": {"kind": "SCALAR", "name": "Int"}
439 })),
440 "[Int]"
441 );
442 assert_eq!(
443 graphql_type_string(&json!({
444 "kind": "NON_NULL",
445 "ofType": {
446 "kind": "LIST",
447 "ofType": {
448 "kind": "NON_NULL",
449 "ofType": {"kind": "SCALAR", "name": "String"}
450 }
451 }
452 })),
453 "[String!]!"
454 );
455 }
456
457 #[test]
458 fn type_mapping() {
459 let types = HashMap::new();
460 let (t, req, _) =
461 graphql_type_to_python(&json!({"kind": "SCALAR", "name": "String"}), &types);
462 assert_eq!(t, ParamType::String);
463 assert!(!req);
464
465 let (t, req, _) = graphql_type_to_python(
466 &json!({
467 "kind": "NON_NULL",
468 "ofType": {"kind": "SCALAR", "name": "String"}
469 }),
470 &types,
471 );
472 assert_eq!(t, ParamType::String);
473 assert!(req);
474
475 let (t, _, _) =
476 graphql_type_to_python(&json!({"kind": "SCALAR", "name": "Boolean"}), &types);
477 assert_eq!(t, ParamType::Boolean);
478
479 let (t, req, _) = graphql_type_to_python(
480 &json!({
481 "kind": "NON_NULL",
482 "ofType": {
483 "kind": "LIST",
484 "ofType": {"kind": "SCALAR", "name": "ID"}
485 }
486 }),
487 &types,
488 );
489 assert_eq!(t, ParamType::String);
490 assert!(req);
491 }
492
493 #[test]
494 fn enum_choices() {
495 let schema = sample_schema();
496 let types: HashMap<_, _> = schema["types"]
497 .as_array()
498 .unwrap()
499 .iter()
500 .filter_map(|t| Some((t.get("name")?.as_str()?.to_string(), t.clone())))
501 .collect();
502 let (_, _, choices) =
503 graphql_type_to_python(&json!({"kind": "ENUM", "name": "Status"}), &types);
504 assert_eq!(
505 choices.unwrap(),
506 vec!["ACTIVE".to_string(), "INACTIVE".into(), "BANNED".into()]
507 );
508 }
509
510 #[test]
511 fn extract_commands() {
512 let cmds = extract_graphql_commands(&sample_schema());
513 let names: Vec<_> = cmds.iter().map(|c| c.name.as_str()).collect();
514 assert!(names.contains(&"users"));
515 assert!(names.contains(&"user"));
516 assert!(names.contains(&"create-user"));
517 assert!(names.contains(&"delete-user"));
518 assert!(names.contains(&"users-by-ids"));
519
520 let user = cmds.iter().find(|c| c.name == "user").unwrap();
521 assert_eq!(user.graphql_operation_type.as_deref(), Some("query"));
522 assert_eq!(user.params.len(), 1);
523 assert!(user.params[0].required);
524
525 let create = cmds.iter().find(|c| c.name == "create-user").unwrap();
526 assert_eq!(create.graphql_operation_type.as_deref(), Some("mutation"));
527 assert_eq!(create.params.len(), 3);
528
529 let by_ids = cmds.iter().find(|c| c.name == "users-by-ids").unwrap();
530 assert_eq!(
531 by_ids.params[0].schema.get("type").and_then(|t| t.as_str()),
532 Some("array")
533 );
534 }
535
536 #[test]
537 fn collision_prefixes() {
538 let schema = json!({
539 "queryType": {"name": "Query"},
540 "mutationType": {"name": "Mutation"},
541 "types": [
542 {
543 "kind": "OBJECT",
544 "name": "Query",
545 "fields": [{
546 "name": "item",
547 "args": [],
548 "type": {"kind": "SCALAR", "name": "String"}
549 }]
550 },
551 {
552 "kind": "OBJECT",
553 "name": "Mutation",
554 "fields": [{
555 "name": "item",
556 "args": [],
557 "type": {"kind": "SCALAR", "name": "String"}
558 }]
559 },
560 {"kind": "SCALAR", "name": "String"}
561 ]
562 });
563 let cmds = extract_graphql_commands(&schema);
564 let names: Vec<_> = cmds.iter().map(|c| c.name.as_str()).collect();
565 assert!(names.contains(&"query-item"));
566 assert!(names.contains(&"mutation-item"));
567 }
568}