Skip to main content

skiff_cli/graphql/
extract.rs

1//! Introspection schema → `CommandDef` list.
2
3use 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
10/// Unwrap NON_NULL/LIST wrappers. Returns (named_type, is_non_null, is_list).
11pub 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
34/// Reconstruct GraphQL type notation, e.g. `[String!]!`.
35pub 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
54/// Map GraphQL type → (CLI ParamType, required, choices).
55pub 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, // String, ID, custom scalars
90    };
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        // Enable coerce_value for explicit true/false GraphQL booleans.
122        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
166/// Convert introspection `__schema` into CLI commands.
167pub 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}