symphony_tracker/
graphql_tool.rs1use serde_json::Value;
7
8#[derive(Debug, Clone, serde::Serialize)]
10pub struct GraphqlToolResult {
11 pub success: bool,
12 #[serde(skip_serializing_if = "Option::is_none")]
13 pub data: Option<Value>,
14 #[serde(skip_serializing_if = "Option::is_none")]
15 pub errors: Option<Value>,
16 #[serde(skip_serializing_if = "Option::is_none")]
17 pub error: Option<String>,
18}
19
20pub fn validate_input(input: &Value) -> Result<(String, Value), String> {
24 let (query_str, variables) = if let Some(s) = input.as_str() {
26 (s.to_string(), Value::Null)
28 } else if let Some(obj) = input.as_object() {
29 let query = obj
30 .get("query")
31 .and_then(|q| q.as_str())
32 .ok_or("'query' must be a non-empty string")?;
33
34 if query.trim().is_empty() {
35 return Err("'query' must be a non-empty string".into());
36 }
37
38 let vars = obj.get("variables").cloned().unwrap_or(Value::Null);
39 if !vars.is_null() && !vars.is_object() {
41 return Err("'variables' must be a JSON object when present".into());
42 }
43
44 (query.to_string(), vars)
45 } else {
46 return Err("input must be an object with 'query' field or a raw query string".into());
47 };
48
49 if query_str.trim().is_empty() {
50 return Err("'query' must be a non-empty string".into());
51 }
52
53 if has_multiple_operations(&query_str) {
55 return Err("query must contain exactly one GraphQL operation".into());
56 }
57
58 Ok((query_str, variables))
59}
60
61fn has_multiple_operations(query: &str) -> bool {
65 let mut count = 0;
66 let mut chars = query.chars().peekable();
67 let mut in_string = false;
68 let mut in_comment = false;
69
70 while let Some(ch) = chars.next() {
71 if in_comment {
72 if ch == '\n' {
73 in_comment = false;
74 }
75 continue;
76 }
77 if ch == '#' {
78 in_comment = true;
79 continue;
80 }
81 if ch == '"' {
82 in_string = !in_string;
83 continue;
84 }
85 if in_string {
86 continue;
87 }
88
89 if ch.is_alphabetic() {
91 let mut word = String::new();
92 word.push(ch);
93 while let Some(&next) = chars.peek() {
94 if next.is_alphanumeric() || next == '_' {
95 word.push(next);
96 chars.next();
97 } else {
98 break;
99 }
100 }
101 match word.as_str() {
102 "query" | "mutation" | "subscription" => {
103 count += 1;
104 if count > 1 {
105 return true;
106 }
107 }
108 _ => {}
109 }
110 }
111 }
112
113 false
114}
115
116pub async fn execute_graphql_tool(
120 endpoint: &str,
121 api_key: &str,
122 query: &str,
123 variables: Value,
124) -> GraphqlToolResult {
125 let http = reqwest::Client::new();
126 let body = serde_json::json!({
127 "query": query,
128 "variables": if variables.is_null() { Value::Object(serde_json::Map::new()) } else { variables },
129 });
130
131 let response = match http
132 .post(endpoint)
133 .header("Authorization", api_key)
134 .header("Content-Type", "application/json")
135 .json(&body)
136 .send()
137 .await
138 {
139 Ok(r) => r,
140 Err(e) => {
141 return GraphqlToolResult {
142 success: false,
143 data: None,
144 errors: None,
145 error: Some(format!("transport_failure: {e}")),
146 };
147 }
148 };
149
150 let status = response.status().as_u16();
151 if !(200..300).contains(&status) {
152 let body_text = response.text().await.unwrap_or_else(|_| "<unreadable>".into());
153 return GraphqlToolResult {
154 success: false,
155 data: None,
156 errors: None,
157 error: Some(format!("api_status_{status}: {body_text}")),
158 };
159 }
160
161 let json: Value = match response.json().await {
162 Ok(j) => j,
163 Err(e) => {
164 return GraphqlToolResult {
165 success: false,
166 data: None,
167 errors: None,
168 error: Some(format!("parse_failure: {e}")),
169 };
170 }
171 };
172
173 let has_errors = json
175 .get("errors")
176 .and_then(|e| e.as_array())
177 .is_some_and(|arr| !arr.is_empty());
178
179 if has_errors {
180 GraphqlToolResult {
182 success: false,
183 data: json.get("data").cloned(),
184 errors: json.get("errors").cloned(),
185 error: None,
186 }
187 } else {
188 GraphqlToolResult {
189 success: true,
190 data: json.get("data").cloned(),
191 errors: None,
192 error: None,
193 }
194 }
195}
196
197pub fn tool_spec() -> Value {
199 serde_json::json!({
200 "name": "linear_graphql",
201 "description": "Execute a GraphQL query or mutation against the Linear API.",
202 "inputSchema": {
203 "type": "object",
204 "properties": {
205 "query": {
206 "type": "string",
207 "description": "A single GraphQL query or mutation document"
208 },
209 "variables": {
210 "type": "object",
211 "description": "Optional GraphQL variables"
212 }
213 },
214 "required": ["query"]
215 }
216 })
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 #[test]
224 fn validate_valid_query() {
225 let input = serde_json::json!({
226 "query": "query { viewer { id } }",
227 });
228 let (query, vars) = validate_input(&input).unwrap();
229 assert_eq!(query, "query { viewer { id } }");
230 assert!(vars.is_null());
231 }
232
233 #[test]
234 fn validate_query_with_variables() {
235 let input = serde_json::json!({
236 "query": "query($id: ID!) { issue(id: $id) { title } }",
237 "variables": { "id": "abc-123" }
238 });
239 let (query, vars) = validate_input(&input).unwrap();
240 assert!(query.contains("$id: ID!"));
241 assert!(vars.is_object());
242 assert_eq!(vars["id"], "abc-123");
243 }
244
245 #[test]
246 fn validate_raw_string_input() {
247 let input = Value::String("query { viewer { id } }".into());
248 let (query, _vars) = validate_input(&input).unwrap();
249 assert_eq!(query, "query { viewer { id } }");
250 }
251
252 #[test]
253 fn validate_empty_query_fails() {
254 let input = serde_json::json!({ "query": "" });
255 let err = validate_input(&input).unwrap_err();
256 assert!(err.contains("non-empty"));
257 }
258
259 #[test]
260 fn validate_missing_query_fails() {
261 let input = serde_json::json!({ "variables": {} });
262 let err = validate_input(&input).unwrap_err();
263 assert!(err.contains("non-empty"));
264 }
265
266 #[test]
267 fn validate_variables_must_be_object() {
268 let input = serde_json::json!({
269 "query": "query { viewer { id } }",
270 "variables": [1, 2, 3]
271 });
272 let err = validate_input(&input).unwrap_err();
273 assert!(err.contains("JSON object"));
274 }
275
276 #[test]
277 fn validate_multiple_operations_rejected() {
278 let input = serde_json::json!({
279 "query": "query A { viewer { id } } mutation B { updateIssue { id } }"
280 });
281 let err = validate_input(&input).unwrap_err();
282 assert!(err.contains("exactly one"));
283 }
284
285 #[test]
286 fn validate_single_mutation_accepted() {
287 let input = serde_json::json!({
288 "query": "mutation { updateIssue(id: \"123\", input: { title: \"New\" }) { success } }"
289 });
290 assert!(validate_input(&input).is_ok());
291 }
292
293 #[test]
294 fn has_multiple_operations_detects_two() {
295 assert!(has_multiple_operations(
296 "query A { a } mutation B { b }"
297 ));
298 }
299
300 #[test]
301 fn has_multiple_operations_single_ok() {
302 assert!(!has_multiple_operations("query { viewer { id } }"));
303 }
304
305 #[test]
306 fn has_multiple_operations_ignores_comments() {
307 assert!(!has_multiple_operations(
309 "query { viewer { id } }\n# mutation { x }"
310 ));
311 }
312
313 #[test]
314 fn tool_spec_has_required_fields() {
315 let spec = tool_spec();
316 assert_eq!(spec["name"], "linear_graphql");
317 assert!(spec.get("inputSchema").is_some());
318 }
319
320 #[test]
321 fn graphql_tool_result_serialization() {
322 let result = GraphqlToolResult {
323 success: true,
324 data: Some(serde_json::json!({"viewer": {"id": "user-1"}})),
325 errors: None,
326 error: None,
327 };
328 let json = serde_json::to_value(&result).unwrap();
329 assert_eq!(json["success"], true);
330 assert!(json.get("data").is_some());
331 assert!(json.get("errors").is_none());
333 assert!(json.get("error").is_none());
334 }
335
336 #[test]
337 fn graphql_tool_result_failure() {
338 let result = GraphqlToolResult {
339 success: false,
340 data: Some(serde_json::json!(null)),
341 errors: Some(serde_json::json!([{"message": "Not found"}])),
342 error: None,
343 };
344 let json = serde_json::to_value(&result).unwrap();
345 assert_eq!(json["success"], false);
346 assert!(json.get("errors").is_some());
347 }
348
349 #[tokio::test]
352 #[ignore] async fn real_graphql_tool_valid_query() {
354 let api_key = std::env::var("LINEAR_API_KEY")
355 .expect("LINEAR_API_KEY must be set for real integration tests");
356
357 let result = execute_graphql_tool(
358 "https://api.linear.app/graphql",
359 &api_key,
360 "query { viewer { id name } }",
361 Value::Null,
362 )
363 .await;
364
365 assert!(result.success, "valid query should succeed: {:?}", result);
366 assert!(result.data.is_some(), "data should be present");
367 assert!(result.error.is_none(), "error should be absent on success");
368 }
369
370 #[tokio::test]
371 #[ignore] async fn real_graphql_tool_invalid_auth() {
373 let result = execute_graphql_tool(
374 "https://api.linear.app/graphql",
375 "lin_api_invalid_key_12345",
376 "query { viewer { id } }",
377 Value::Null,
378 )
379 .await;
380
381 assert!(!result.success, "invalid auth should fail");
382 }
383}