Skip to main content

fraiseql_core/federation/
mutation_http_client.rs

1//! HTTP client for executing mutations on remote subgraphs.
2//!
3//! Propagates extended mutations (mutations on entities owned elsewhere) to the
4//! authoritative subgraph via GraphQL HTTP requests.
5
6use std::time::Duration;
7
8use serde_json::Value;
9
10use crate::{
11    error::{FraiseQLError, Result},
12    federation::{metadata_helpers::find_federation_type, types::FederationMetadata},
13};
14
15/// Configuration for HTTP mutation client
16#[derive(Debug, Clone)]
17pub struct HttpMutationConfig {
18    /// Request timeout in milliseconds
19    pub timeout_ms:     u64,
20    /// Maximum number of retries
21    pub max_retries:    u32,
22    /// Delay between retries in milliseconds
23    pub retry_delay_ms: u64,
24}
25
26impl Default for HttpMutationConfig {
27    fn default() -> Self {
28        Self {
29            timeout_ms:     5000,
30            max_retries:    3,
31            retry_delay_ms: 100,
32        }
33    }
34}
35
36/// GraphQL request format
37#[derive(Debug, Clone, serde::Serialize)]
38pub struct GraphQLRequest {
39    /// GraphQL query/mutation string
40    pub query:     String,
41    /// Variables for the query
42    pub variables: Value,
43}
44
45/// GraphQL response format
46#[derive(Debug, Clone, serde::Deserialize)]
47pub struct GraphQLResponse {
48    /// Response data (null if errors occurred)
49    pub data:   Option<Value>,
50    /// GraphQL errors
51    pub errors: Option<Vec<GraphQLError>>,
52}
53
54/// GraphQL error format
55#[derive(Debug, Clone, serde::Deserialize)]
56pub struct GraphQLError {
57    /// Error message
58    pub message: String,
59}
60
61/// HTTP client for executing mutations on remote subgraphs
62pub struct HttpMutationClient {
63    /// HTTP client
64    client: Option<reqwest::Client>,
65    /// Configuration
66    config: HttpMutationConfig,
67}
68
69impl HttpMutationClient {
70    /// Create a new HTTP mutation client
71    pub fn new(config: HttpMutationConfig) -> Self {
72        let client = reqwest::Client::builder()
73            .timeout(Duration::from_millis(config.timeout_ms))
74            .build()
75            .ok();
76
77        Self { client, config }
78    }
79
80    /// Execute a mutation on a remote subgraph
81    pub async fn execute_mutation(
82        &self,
83        subgraph_url: &str,
84        typename: &str,
85        mutation_name: &str,
86        variables: &Value,
87        metadata: &FederationMetadata,
88    ) -> Result<Value> {
89        let client = self.client.as_ref().ok_or_else(|| FraiseQLError::Internal {
90            message: "HTTP client not initialized".to_string(),
91            source:  None,
92        })?;
93
94        // Get entity type for key fields
95        let fed_type = find_federation_type(typename, metadata)?;
96
97        // Build mutation query
98        let query = self.build_mutation_query(typename, mutation_name, variables, fed_type)?;
99
100        // Execute with retry
101        let response = self.execute_with_retry(client, subgraph_url, &query).await?;
102
103        // Parse and return response
104        self.parse_response(response, mutation_name)
105    }
106
107    /// Build a GraphQL mutation query
108    pub fn build_mutation_query(
109        &self,
110        _typename: &str,
111        mutation_name: &str,
112        variables: &Value,
113        fed_type: &crate::federation::types::FederatedType,
114    ) -> Result<GraphQLRequest> {
115        // Get key fields for response projection
116        let key_fields = if let Some(key_directive) = fed_type.keys.first() {
117            key_directive.fields.join(" ")
118        } else {
119            "id".to_string()
120        };
121
122        // Get mutation input fields (excluding external fields)
123        let mut input_fields = Vec::new();
124        if let Some(obj) = variables.as_object() {
125            for key in obj.keys() {
126                if !fed_type.external_fields.contains(key) {
127                    input_fields.push(format!("{}: ${}", key, key));
128                }
129            }
130        }
131
132        // Build variable definitions
133        let var_defs = self.build_variable_definitions(variables)?;
134
135        // Build mutation query
136        let response_fields = format!("__typename {}", key_fields);
137        let input_clause = input_fields.join(", ");
138
139        let query = format!(
140            "mutation({}) {{ {}({}) {{ {} }} }}",
141            var_defs, mutation_name, input_clause, response_fields
142        );
143
144        Ok(GraphQLRequest {
145            query,
146            variables: variables.clone(),
147        })
148    }
149
150    /// Build GraphQL variable definitions from input variables
151    pub fn build_variable_definitions(&self, variables: &Value) -> Result<String> {
152        let mut var_defs = Vec::new();
153
154        if let Some(obj) = variables.as_object() {
155            for key in obj.keys() {
156                // Infer type from value (simplified)
157                let var_type = match &obj[key] {
158                    Value::String(_) => "String!",
159                    Value::Number(_) => "Int!",
160                    Value::Bool(_) => "Boolean!",
161                    Value::Null => "String",
162                    _ => "JSON", // Use generic JSON type for complex types
163                };
164                var_defs.push(format!("${}: {}", key, var_type));
165            }
166        }
167
168        Ok(format!("({})", var_defs.join(", ")))
169    }
170
171    /// Execute request with retry logic
172    async fn execute_with_retry(
173        &self,
174        client: &reqwest::Client,
175        url: &str,
176        request: &GraphQLRequest,
177    ) -> Result<GraphQLResponse> {
178        let mut attempts = 0;
179        let mut last_error = None;
180
181        while attempts < self.config.max_retries {
182            attempts += 1;
183
184            match client.post(url).json(request).send().await {
185                Ok(response) if response.status().is_success() => {
186                    return response.json().await.map_err(|e| FraiseQLError::Internal {
187                        message: format!("Failed to parse GraphQL response: {}", e),
188                        source:  None,
189                    });
190                },
191                Ok(response) => {
192                    last_error = Some(format!("HTTP {}", response.status()));
193                },
194                Err(e) => {
195                    last_error = Some(e.to_string());
196                },
197            }
198
199            if attempts < self.config.max_retries {
200                tokio::time::sleep(Duration::from_millis(
201                    self.config.retry_delay_ms * u64::from(attempts),
202                ))
203                .await;
204            }
205        }
206
207        Err(FraiseQLError::Internal {
208            message: format!(
209                "Mutation request failed after {} attempts: {}",
210                self.config.max_retries,
211                last_error.unwrap_or_else(|| "Unknown error".to_string())
212            ),
213            source:  None,
214        })
215    }
216
217    /// Parse mutation response
218    pub fn parse_response(&self, response: GraphQLResponse, mutation_name: &str) -> Result<Value> {
219        // Check for GraphQL errors
220        if let Some(errors) = response.errors {
221            let error_messages: Vec<String> = errors.iter().map(|e| e.message.clone()).collect();
222            return Err(FraiseQLError::Internal {
223                message: format!(
224                    "GraphQL error in mutation response: {}",
225                    error_messages.join("; ")
226                ),
227                source:  None,
228            });
229        }
230
231        // Extract mutation result from data
232        let data = response.data.ok_or_else(|| FraiseQLError::Internal {
233            message: "No data in mutation response".to_string(),
234            source:  None,
235        })?;
236
237        let result = data.get(mutation_name).cloned().ok_or_else(|| FraiseQLError::Internal {
238            message: format!("No {} field in response data", mutation_name),
239            source:  None,
240        })?;
241
242        Ok(result)
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use serde_json::json;
249
250    use super::*;
251
252    #[test]
253    fn test_http_mutation_client_creation() {
254        let config = HttpMutationConfig::default();
255        let _client = HttpMutationClient::new(config);
256        // Should not panic
257    }
258
259    #[test]
260    fn test_mutation_config_defaults() {
261        let config = HttpMutationConfig::default();
262        assert_eq!(config.timeout_ms, 5000);
263        assert_eq!(config.max_retries, 3);
264        assert_eq!(config.retry_delay_ms, 100);
265    }
266
267    #[test]
268    fn test_mutation_config_custom() {
269        let config = HttpMutationConfig {
270            timeout_ms:     10000,
271            max_retries:    5,
272            retry_delay_ms: 200,
273        };
274        assert_eq!(config.timeout_ms, 10000);
275        assert_eq!(config.max_retries, 5);
276        assert_eq!(config.retry_delay_ms, 200);
277    }
278
279    #[test]
280    fn test_graphql_request_serialization() {
281        let request = GraphQLRequest {
282            query:     "mutation { updateUser(id: $id) { id } }".to_string(),
283            variables: json!({ "id": "123" }),
284        };
285
286        let json = serde_json::to_value(&request).unwrap();
287        assert_eq!(json["query"], "mutation { updateUser(id: $id) { id } }");
288        assert_eq!(json["variables"]["id"], "123");
289    }
290
291    #[test]
292    fn test_graphql_response_parsing_success() {
293        let response_json = json!({
294            "data": {
295                "updateUser": {
296                    "__typename": "User",
297                    "id": "123",
298                    "name": "Alice"
299                }
300            }
301        });
302
303        let response: GraphQLResponse = serde_json::from_value(response_json).unwrap();
304        assert!(response.data.is_some());
305        assert!(response.errors.is_none());
306
307        let data = response.data.unwrap();
308        assert_eq!(data["updateUser"]["id"], "123");
309    }
310
311    #[test]
312    fn test_graphql_response_with_errors() {
313        let response_json = json!({
314            "data": null,
315            "errors": [
316                {
317                    "message": "User not found"
318                }
319            ]
320        });
321
322        let response: GraphQLResponse = serde_json::from_value(response_json).unwrap();
323        assert!(response.data.is_none());
324        assert!(response.errors.is_some());
325        assert_eq!(response.errors.unwrap()[0].message, "User not found");
326    }
327
328    #[test]
329    fn test_variable_definition_building() {
330        let config = HttpMutationConfig::default();
331        let client = HttpMutationClient::new(config);
332
333        let variables = json!({
334            "id": "123",
335            "name": "Alice",
336            "active": true
337        });
338
339        let var_defs = client.build_variable_definitions(&variables).unwrap();
340        assert!(var_defs.contains("$id: String!"));
341        assert!(var_defs.contains("$name: String!"));
342        assert!(var_defs.contains("$active: Boolean!"));
343    }
344
345    #[test]
346    fn test_variable_definition_with_numbers() {
347        let config = HttpMutationConfig::default();
348        let client = HttpMutationClient::new(config);
349
350        let variables = json!({
351            "count": 42,
352            "price": 9.99
353        });
354
355        let var_defs = client.build_variable_definitions(&variables).unwrap();
356        assert!(var_defs.contains("$count: Int!"));
357        assert!(var_defs.contains("$price: Int!"));
358    }
359}