Skip to main content

skiff_cli/graphql/
execute.rs

1//! Build GraphQL documents and execute against an endpoint.
2
3use std::collections::{BTreeMap, HashMap, HashSet};
4
5use serde_json::{json, Map, Value};
6
7use crate::cli::dynamic::ParsedToolArgs;
8use crate::coerce::coerce_value;
9use crate::error::{Error, Result};
10use crate::graphql::extract::unwrap_type;
11use crate::model::{CommandDef, ParamLocation};
12use crate::output::{output_result, OutputOptions};
13
14/// Auto-generate a selection set (depth 2 = scalars + one nested object level).
15/// INTERFACE/UNION fields emit `__typename` (+ interface scalar fields when present).
16pub fn build_selection_set(
17    type_ref: &Value,
18    types_by_name: &HashMap<String, Value>,
19    depth: i32,
20    seen: &mut HashSet<String>,
21) -> String {
22    let (named, _, _) = unwrap_type(type_ref);
23    let type_name = named.get("name").and_then(|n| n.as_str()).unwrap_or("");
24    let type_kind = named.get("kind").and_then(|k| k.as_str()).unwrap_or("");
25
26    if matches!(type_kind, "SCALAR" | "ENUM") {
27        return String::new();
28    }
29
30    if type_name.is_empty() || seen.contains(type_name) || depth <= 0 {
31        return String::new();
32    }
33
34    if matches!(type_kind, "INTERFACE" | "UNION") {
35        seen.insert(type_name.to_string());
36        let mut parts = vec!["__typename".to_string()];
37        if type_kind == "INTERFACE" {
38            if let Some(type_def) = types_by_name.get(type_name) {
39                if let Some(fields) = type_def.get("fields").and_then(|f| f.as_array()) {
40                    for f in fields {
41                        let fname = f.get("name").and_then(|n| n.as_str()).unwrap_or("");
42                        let (f_named, _, _) = unwrap_type(f.get("type").unwrap_or(&Value::Null));
43                        let f_kind = f_named.get("kind").and_then(|k| k.as_str()).unwrap_or("");
44                        if matches!(f_kind, "SCALAR" | "ENUM") && !fname.is_empty() {
45                            parts.push(fname.to_string());
46                        }
47                    }
48                }
49            }
50        }
51        return format!("{{ {} }}", parts.join(" "));
52    }
53
54    let Some(type_def) = types_by_name.get(type_name) else {
55        return String::new();
56    };
57    let Some(fields) = type_def.get("fields").and_then(|f| f.as_array()) else {
58        return String::new();
59    };
60    if fields.is_empty() {
61        return String::new();
62    }
63
64    seen.insert(type_name.to_string());
65    let mut parts = Vec::new();
66    for f in fields {
67        let fname = f.get("name").and_then(|n| n.as_str()).unwrap_or("");
68        if fname.is_empty() {
69            continue;
70        }
71        let f_type = f.get("type").unwrap_or(&Value::Null);
72        let (f_named, _, _) = unwrap_type(f_type);
73        let f_kind = f_named.get("kind").and_then(|k| k.as_str()).unwrap_or("");
74        if matches!(f_kind, "SCALAR" | "ENUM") {
75            parts.push(fname.to_string());
76        } else if matches!(f_kind, "OBJECT" | "INTERFACE" | "UNION") && depth > 1 {
77            let nested = build_selection_set(f_type, types_by_name, depth - 1, seen);
78            if !nested.is_empty() {
79                parts.push(format!("{fname} {nested}"));
80            }
81        }
82    }
83    if parts.is_empty() {
84        return String::new();
85    }
86    format!("{{ {} }}", parts.join(" "))
87}
88
89fn types_index(schema: &Value) -> HashMap<String, Value> {
90    schema
91        .get("types")
92        .and_then(|t| t.as_array())
93        .map(|arr| {
94            arr.iter()
95                .filter_map(|t| {
96                    let name = t.get("name").and_then(|n| n.as_str())?;
97                    Some((name.to_string(), t.clone()))
98                })
99                .collect()
100        })
101        .unwrap_or_default()
102}
103
104/// After flags + stdin merge, ensure NON_NULL GraphQL args are present.
105pub fn validate_required_graphql_args(
106    cmd: &CommandDef,
107    variables: &Map<String, Value>,
108) -> Result<()> {
109    for p in &cmd.params {
110        if !p.required || p.location != ParamLocation::GraphqlArg {
111            continue;
112        }
113        if !variables.contains_key(&p.original_name) {
114            return Err(Error::usage(format!(
115                "missing required GraphQL argument: --{}",
116                p.name
117            )));
118        }
119    }
120    Ok(())
121}
122
123/// Build document + variables from parsed CLI args.
124pub fn build_graphql_document(
125    cmd: &CommandDef,
126    values: &BTreeMap<String, Value>,
127    stdin_vars: Option<Map<String, Value>>,
128    schema: &Value,
129    fields_override: Option<&str>,
130) -> Result<(String, Map<String, Value>, String)> {
131    let types_by_name = types_index(schema);
132
133    let mut variables = Map::new();
134    if let Some(stdin) = stdin_vars {
135        variables = stdin;
136    } else {
137        for p in &cmd.params {
138            if let Some(val) = values.get(&p.original_name) {
139                let coerced =
140                    coerce_value(Some(val.clone()), &p.schema).unwrap_or_else(|| val.clone());
141                variables.insert(p.original_name.clone(), coerced);
142            }
143        }
144    }
145
146    validate_required_graphql_args(cmd, &variables)?;
147
148    let mut var_decls = Vec::new();
149    for p in &cmd.params {
150        if variables.contains_key(&p.original_name) {
151            let gql_type = p
152                .schema
153                .get("graphql_type")
154                .and_then(|t| t.as_str())
155                .unwrap_or("String");
156            var_decls.push(format!("${}: {gql_type}", p.original_name));
157        }
158    }
159
160    let selection = if let Some(fields) = fields_override {
161        format!("{{ {fields} }}")
162    } else if let Some(ret) = &cmd.graphql_return_type {
163        let mut seen = HashSet::new();
164        build_selection_set(ret, &types_by_name, 2, &mut seen)
165    } else {
166        String::new()
167    };
168
169    let mut field_args = Vec::new();
170    for p in &cmd.params {
171        if variables.contains_key(&p.original_name) {
172            field_args.push(format!("{}: ${}", p.original_name, p.original_name));
173        }
174    }
175
176    let field_name = cmd
177        .graphql_field_name
178        .clone()
179        .unwrap_or_else(|| cmd.name.clone());
180    let args_str = if field_args.is_empty() {
181        String::new()
182    } else {
183        format!("({})", field_args.join(", "))
184    };
185    let op_type = cmd.graphql_operation_type.as_deref().unwrap_or("query");
186    let var_decls_str = if var_decls.is_empty() {
187        String::new()
188    } else {
189        format!("({})", var_decls.join(", "))
190    };
191
192    let selection_part = if selection.is_empty() {
193        String::new()
194    } else {
195        format!(" {selection}")
196    };
197    let document = format!("{op_type}{var_decls_str} {{ {field_name}{args_str}{selection_part} }}");
198    Ok((document, variables, field_name))
199}
200
201/// Execute a GraphQL operation and print the result.
202pub fn execute_graphql(
203    parsed: &ParsedToolArgs,
204    url: &str,
205    schema: &Value,
206    auth_headers: &[(String, String)],
207    fields_override: Option<&str>,
208    output: &OutputOptions,
209) -> Result<()> {
210    let stdin_vars = if parsed.stdin {
211        let v = crate::cli::dynamic::read_stdin_json("GraphQL variables")?;
212        Some(
213            v.as_object()
214                .cloned()
215                .ok_or_else(|| Error::usage("--stdin for GraphQL expects a JSON object"))?,
216        )
217    } else {
218        None
219    };
220
221    let (document, variables, field_name) = build_graphql_document(
222        &parsed.command,
223        &parsed.values,
224        stdin_vars,
225        schema,
226        fields_override,
227    )?;
228
229    let client = reqwest::blocking::Client::builder()
230        .timeout(std::time::Duration::from_secs(60))
231        .build()
232        .map_err(|e| Error::runtime(e.to_string()))?;
233
234    let mut req = client.post(url).header("Content-Type", "application/json");
235    for (k, v) in auth_headers {
236        req = req.header(k, v);
237    }
238
239    let body = json!({
240        "query": document,
241        "variables": if variables.is_empty() { Value::Null } else { Value::Object(variables) },
242    });
243    let resp = req
244        .json(&body)
245        .send()
246        .map_err(|e| Error::runtime(format!("GraphQL request failed: {e}")))?;
247    if !resp.status().is_success() {
248        return Err(Error::runtime(format!(
249            "Error {}: {}",
250            resp.status().as_u16(),
251            resp.text().unwrap_or_default()
252        )));
253    }
254
255    let result: Value = resp
256        .json()
257        .map_err(|e| Error::runtime(format!("invalid JSON from GraphQL: {e}")))?;
258
259    if result.get("errors").is_some() {
260        if result.get("data").is_none() || result.get("data").map(|d| d.is_null()).unwrap_or(false)
261        {
262            let msgs = result["errors"]
263                .as_array()
264                .map(|arr| {
265                    arr.iter()
266                        .filter_map(|e| e.get("message").and_then(|m| m.as_str()))
267                        .collect::<Vec<_>>()
268                        .join("; ")
269                })
270                .unwrap_or_default();
271            return Err(Error::runtime(format!("GraphQL error: {msgs}")));
272        }
273        // Partial errors — include full envelope.
274        output_result(result, output).map_err(Error::from)?;
275        return Ok(());
276    }
277
278    let data = result.get("data").cloned().unwrap_or(Value::Null);
279    let field_data = data.get(&field_name).cloned().unwrap_or(data);
280    output_result(field_data, output).map_err(Error::from)?;
281    Ok(())
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use crate::graphql::extract::extract_graphql_commands;
288    use serde_json::json;
289
290    fn schema() -> Value {
291        json!({
292            "queryType": {"name": "Query"},
293            "mutationType": {"name": "Mutation"},
294            "types": [
295                {
296                    "kind": "OBJECT",
297                    "name": "Query",
298                    "fields": [{
299                        "name": "user",
300                        "args": [{
301                            "name": "id",
302                            "type": {
303                                "kind": "NON_NULL",
304                                "ofType": {"kind": "SCALAR", "name": "ID"}
305                            }
306                        }],
307                        "type": {"kind": "OBJECT", "name": "User"}
308                    }]
309                },
310                {
311                    "kind": "OBJECT",
312                    "name": "Mutation",
313                    "fields": []
314                },
315                {
316                    "kind": "OBJECT",
317                    "name": "User",
318                    "fields": [
319                        {"name": "id", "args": [], "type": {"kind": "SCALAR", "name": "ID"}},
320                        {"name": "name", "args": [], "type": {"kind": "SCALAR", "name": "String"}},
321                        {"name": "address", "args": [], "type": {"kind": "OBJECT", "name": "Address"}},
322                        {"name": "node", "args": [], "type": {"kind": "INTERFACE", "name": "Node"}}
323                    ]
324                },
325                {
326                    "kind": "OBJECT",
327                    "name": "Address",
328                    "fields": [
329                        {"name": "city", "args": [], "type": {"kind": "SCALAR", "name": "String"}}
330                    ]
331                },
332                {
333                    "kind": "INTERFACE",
334                    "name": "Node",
335                    "fields": [
336                        {"name": "id", "args": [], "type": {"kind": "SCALAR", "name": "ID"}}
337                    ]
338                },
339                {"kind": "SCALAR", "name": "ID"},
340                {"kind": "SCALAR", "name": "String"}
341            ]
342        })
343    }
344
345    #[test]
346    fn selection_depth_2_includes_nested() {
347        let types = types_index(&schema());
348        let mut seen = HashSet::new();
349        let sel = build_selection_set(
350            &json!({"kind": "OBJECT", "name": "User"}),
351            &types,
352            2,
353            &mut seen,
354        );
355        assert!(sel.contains("id"));
356        assert!(sel.contains("name"));
357        assert!(sel.contains("address"));
358        assert!(sel.contains("city"));
359        assert!(sel.contains("__typename"));
360        assert!(sel.contains("node"));
361    }
362
363    #[test]
364    fn selection_depth_1_no_nested() {
365        let types = types_index(&schema());
366        let mut seen = HashSet::new();
367        let sel = build_selection_set(
368            &json!({"kind": "OBJECT", "name": "User"}),
369            &types,
370            1,
371            &mut seen,
372        );
373        assert!(sel.contains("id"));
374        assert!(!sel.contains("city"));
375    }
376
377    #[test]
378    fn scalar_selection_empty() {
379        let types = types_index(&schema());
380        let mut seen = HashSet::new();
381        let sel = build_selection_set(
382            &json!({"kind": "SCALAR", "name": "String"}),
383            &types,
384            2,
385            &mut seen,
386        );
387        assert!(sel.is_empty());
388    }
389
390    #[test]
391    fn document_and_required() {
392        let cmds = extract_graphql_commands(&schema());
393        let cmd = cmds.iter().find(|c| c.name == "user").unwrap();
394        let mut values = BTreeMap::new();
395        values.insert("id".into(), json!("1"));
396        let (doc, vars, field) =
397            build_graphql_document(cmd, &values, None, &schema(), None).unwrap();
398        assert_eq!(field, "user");
399        assert!(doc.starts_with("query("));
400        assert!(doc.contains("$id: ID!"));
401        assert_eq!(vars.get("id"), Some(&json!("1")));
402
403        let empty = BTreeMap::new();
404        let err = build_graphql_document(cmd, &empty, None, &schema(), None).unwrap_err();
405        assert!(err.to_string().contains("missing required"));
406    }
407
408    #[test]
409    fn fields_override() {
410        let cmds = extract_graphql_commands(&schema());
411        let cmd = cmds.iter().find(|c| c.name == "user").unwrap();
412        let mut values = BTreeMap::new();
413        values.insert("id".into(), json!("1"));
414        let (doc, _, _) =
415            build_graphql_document(cmd, &values, None, &schema(), Some("id name")).unwrap();
416        assert!(doc.contains("{ id name }"));
417    }
418}