Skip to main content

spikard_graphql/
executor.rs

1//! GraphQL executor for executing queries and mutations.
2//!
3//! This module provides a thin wrapper around `async-graphql` execution that
4//! produces JSON payloads suitable for Spikard's HTTP handlers.
5
6use async_graphql::{ObjectType, Request, Response, Schema, SubscriptionType, Variables};
7use serde_json::Value;
8use std::sync::Arc;
9
10use crate::error::GraphQLError;
11
12/// Generic GraphQL executor that wraps an `async-graphql` schema.
13///
14/// # Type Parameters
15///
16/// - `Query`: The root Query type
17/// - `Mutation`: The root Mutation type
18/// - `Subscription`: The root Subscription type
19pub struct GraphQLExecutor<Query, Mutation, Subscription> {
20    /// The underlying GraphQL schema, wrapped in Arc for thread safety.
21    schema: Arc<Schema<Query, Mutation, Subscription>>,
22}
23
24impl<Query, Mutation, Subscription> std::fmt::Debug for GraphQLExecutor<Query, Mutation, Subscription> {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        f.debug_struct("GraphQLExecutor")
27            .field("schema", &"<async_graphql::Schema>")
28            .finish()
29    }
30}
31
32impl<Query, Mutation, Subscription> GraphQLExecutor<Query, Mutation, Subscription>
33where
34    Query: ObjectType + Send + Sync + 'static,
35    Mutation: ObjectType + Send + Sync + 'static,
36    Subscription: SubscriptionType + Send + Sync + 'static,
37{
38    /// Create a new GraphQL executor from an `async-graphql` schema.
39    #[must_use]
40    pub fn new(schema: Schema<Query, Mutation, Subscription>) -> Self {
41        Self {
42            schema: Arc::new(schema),
43        }
44    }
45
46    /// Execute a GraphQL query or mutation and return GraphQL-spec JSON.
47    ///
48    /// # Errors
49    ///
50    /// Returns:
51    /// - `GraphQLError::ValidationError` for empty query strings
52    /// - `GraphQLError::ComplexityLimitExceeded` when limits are hit
53    /// - `GraphQLError::DepthLimitExceeded` when limits are hit
54    /// - `GraphQLError::SerializationError` when response JSON conversion fails
55    pub async fn execute(
56        &self,
57        query: &str,
58        variables: Option<&Value>,
59        operation_name: Option<&str>,
60    ) -> Result<Value, GraphQLError> {
61        if query.trim().is_empty() {
62            return Err(GraphQLError::ValidationError(
63                "Query string cannot be empty".to_string(),
64            ));
65        }
66
67        let mut request = Request::new(query);
68        if let Some(vars) = variables {
69            request = request.variables(Variables::from_json(vars.clone()));
70        }
71        if let Some(name) = operation_name {
72            request = request.operation_name(name);
73        }
74
75        let response = self.schema.execute(request).await;
76
77        if let Some(limit_error) = map_query_limit_error(&response) {
78            return Err(limit_error);
79        }
80
81        serde_json::to_value(response)
82            .map_err(|error| GraphQLError::SerializationError(format!("Failed to serialize GraphQL response: {error}")))
83    }
84
85    /// Get a reference to the underlying schema.
86    #[must_use]
87    pub const fn schema_ref(&self) -> &Arc<Schema<Query, Mutation, Subscription>> {
88        &self.schema
89    }
90
91    /// Clone the Arc to the schema for sharing across async tasks.
92    #[must_use]
93    pub fn clone_schema(&self) -> Arc<Schema<Query, Mutation, Subscription>> {
94        Arc::clone(&self.schema)
95    }
96}
97
98fn map_query_limit_error(response: &Response) -> Option<GraphQLError> {
99    for error in &response.errors {
100        let message = error.message.to_ascii_lowercase();
101        if message.contains("complexity") {
102            return Some(GraphQLError::ComplexityLimitExceeded);
103        }
104        if message.contains("depth") {
105            return Some(GraphQLError::DepthLimitExceeded);
106        }
107
108        if let Some(extensions) = &error.extensions {
109            let extension_text = format!("{extensions:?}").to_ascii_lowercase();
110            if extension_text.contains("complexity") {
111                return Some(GraphQLError::ComplexityLimitExceeded);
112            }
113            if extension_text.contains("depth") {
114                return Some(GraphQLError::DepthLimitExceeded);
115            }
116        }
117    }
118
119    None
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use async_graphql::{EmptyMutation, EmptySubscription, Object};
126
127    #[derive(Default)]
128    struct TestQuery;
129
130    #[Object]
131    impl TestQuery {
132        async fn hello(&self) -> &'static str {
133            "world"
134        }
135
136        async fn greet(&self, name: String) -> String {
137            format!("Hello, {name}")
138        }
139    }
140
141    fn make_executor() -> GraphQLExecutor<TestQuery, EmptyMutation, EmptySubscription> {
142        let schema = Schema::build(TestQuery, EmptyMutation, EmptySubscription).finish();
143        GraphQLExecutor::new(schema)
144    }
145
146    #[tokio::test]
147    async fn test_executor_creation() {
148        let executor = make_executor();
149        let _ = executor.schema_ref();
150        let _ = executor.clone_schema();
151    }
152
153    #[tokio::test]
154    async fn test_execute_with_empty_query_fails() {
155        let executor = make_executor();
156        let result = executor.execute("", None, None).await;
157        assert!(result.is_err());
158    }
159
160    #[tokio::test]
161    async fn test_execute_with_variables() {
162        let executor = make_executor();
163        let variables = serde_json::json!({ "name": "Alice" });
164        let result = executor
165            .execute("query($name: String!) { greet(name: $name) }", Some(&variables), None)
166            .await
167            .expect("query should execute");
168
169        assert_eq!(result["data"]["greet"], "Hello, Alice");
170    }
171
172    #[tokio::test]
173    async fn test_execute_with_operation_name() {
174        let executor = make_executor();
175        let result = executor
176            .execute(
177                "query First { hello } query Second { greet(name: \"Bob\") }",
178                None,
179                Some("First"),
180            )
181            .await
182            .expect("query with operation name should execute");
183
184        assert_eq!(result["data"]["hello"], "world");
185    }
186}