1use 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
14pub 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
104pub 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
123pub 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
201pub 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 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}