Skip to main content

fraiseql_server/routes/
graphql.rs

1//! GraphQL HTTP endpoint.
2//!
3//! Supports both POST and GET requests per the GraphQL over HTTP spec:
4//! - POST: JSON body with `query`, `variables`, `operationName`
5//! - GET: Query parameters `query`, `variables` (JSON-encoded), `operationName`
6
7use std::{
8    sync::{Arc, atomic::Ordering},
9    time::Instant,
10};
11
12use axum::{
13    Json,
14    extract::{Query, State},
15    http::HeaderMap,
16    response::{IntoResponse, Response},
17};
18use fraiseql_core::{db::traits::DatabaseAdapter, runtime::Executor, security::SecurityContext};
19use serde::{Deserialize, Serialize};
20use tracing::{debug, error, info, warn};
21
22use crate::{
23    error::{ErrorResponse, GraphQLError},
24    extractors::OptionalSecurityContext,
25    metrics_server::MetricsCollector,
26    tracing_utils,
27    validation::RequestValidator,
28};
29
30/// GraphQL request payload (for POST requests).
31#[derive(Debug, Deserialize)]
32#[serde(rename_all = "camelCase")]
33pub struct GraphQLRequest {
34    /// GraphQL query string.
35    pub query: String,
36
37    /// Query variables (optional).
38    #[serde(default)]
39    pub variables: Option<serde_json::Value>,
40
41    /// Operation name (optional).
42    #[serde(default)]
43    pub operation_name: Option<String>,
44}
45
46/// GraphQL GET request parameters.
47///
48/// Per GraphQL over HTTP spec, GET requests encode parameters in the query string:
49/// - `query`: Required, the GraphQL query string
50/// - `variables`: Optional, JSON-encoded object
51/// - `operationName`: Optional, name of the operation to execute
52#[derive(Debug, Deserialize)]
53#[serde(rename_all = "camelCase")]
54pub struct GraphQLGetParams {
55    /// GraphQL query string (required).
56    pub query: String,
57
58    /// Query variables as JSON-encoded string (optional).
59    #[serde(default)]
60    pub variables: Option<String>,
61
62    /// Operation name (optional).
63    #[serde(default)]
64    pub operation_name: Option<String>,
65}
66
67/// GraphQL response payload.
68#[derive(Debug, Serialize)]
69pub struct GraphQLResponse {
70    /// Response data or errors.
71    #[serde(flatten)]
72    pub body: serde_json::Value,
73}
74
75impl IntoResponse for GraphQLResponse {
76    fn into_response(self) -> Response {
77        Json(self.body).into_response()
78    }
79}
80
81/// Server state containing executor and configuration.
82///
83/// Phase 4: Extended with cache and config for API endpoints
84#[derive(Clone)]
85pub struct AppState<A: DatabaseAdapter> {
86    /// Query executor.
87    pub executor: Arc<Executor<A>>,
88    /// Metrics collector.
89    pub metrics:  Arc<MetricsCollector>,
90    /// Query result cache (optional).
91    pub cache:    Option<Arc<fraiseql_arrow::cache::QueryCache>>,
92    /// Server configuration (optional).
93    pub config:   Option<Arc<crate::config::ServerConfig>>,
94}
95
96impl<A: DatabaseAdapter> AppState<A> {
97    /// Create new application state.
98    #[must_use]
99    pub fn new(executor: Arc<Executor<A>>) -> Self {
100        Self {
101            executor,
102            metrics: Arc::new(MetricsCollector::new()),
103            cache: None,
104            config: None,
105        }
106    }
107
108    /// Create new application state with custom metrics collector.
109    #[must_use]
110    pub fn with_metrics(executor: Arc<Executor<A>>, metrics: Arc<MetricsCollector>) -> Self {
111        Self {
112            executor,
113            metrics,
114            cache: None,
115            config: None,
116        }
117    }
118
119    /// Create new application state with cache.
120    ///
121    /// Phase 4.1: Add cache support for query result caching
122    #[must_use]
123    pub fn with_cache(
124        executor: Arc<Executor<A>>,
125        cache: Arc<fraiseql_arrow::cache::QueryCache>,
126    ) -> Self {
127        Self {
128            executor,
129            metrics: Arc::new(MetricsCollector::new()),
130            cache: Some(cache),
131            config: None,
132        }
133    }
134
135    /// Create new application state with cache and config.
136    ///
137    /// Phase 4.1-4.2: Add cache and config support for API endpoints
138    #[must_use]
139    pub fn with_cache_and_config(
140        executor: Arc<Executor<A>>,
141        cache: Arc<fraiseql_arrow::cache::QueryCache>,
142        config: Arc<crate::config::ServerConfig>,
143    ) -> Self {
144        Self {
145            executor,
146            metrics: Arc::new(MetricsCollector::new()),
147            cache: Some(cache),
148            config: Some(config),
149        }
150    }
151
152    /// Get query cache if configured.
153    pub fn cache(&self) -> Option<&Arc<fraiseql_arrow::cache::QueryCache>> {
154        self.cache.as_ref()
155    }
156
157    /// Get server configuration if configured.
158    pub fn server_config(&self) -> Option<&Arc<crate::config::ServerConfig>> {
159        self.config.as_ref()
160    }
161
162    /// Get sanitized configuration for safe API exposure.
163    ///
164    /// Phase 4.2: Returns configuration with sensitive data redacted
165    pub fn sanitized_config(&self) -> Option<crate::routes::api::types::SanitizedConfig> {
166        self.config
167            .as_ref()
168            .map(|cfg| crate::routes::api::types::SanitizedConfig::from_config(cfg))
169    }
170}
171
172/// GraphQL HTTP handler for POST requests.
173///
174/// Handles POST requests to the GraphQL endpoint:
175/// 1. Extract W3C trace context from traceparent header (if present)
176/// 2. Validate GraphQL request (depth, complexity)
177/// 3. Parse GraphQL request body
178/// 4. Execute query via Executor with optional SecurityContext
179/// 5. Return GraphQL response with proper error formatting
180///
181/// Tracks execution timing and operation name for monitoring.
182/// Provides GraphQL spec-compliant error responses.
183/// Supports W3C Trace Context for distributed tracing.
184/// Supports OIDC authentication for RLS policy evaluation.
185///
186/// # Errors
187///
188/// Returns appropriate HTTP status codes based on error type.
189pub async fn graphql_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
190    State(state): State<AppState<A>>,
191    headers: HeaderMap,
192    OptionalSecurityContext(security_context): OptionalSecurityContext,
193    Json(request): Json<GraphQLRequest>,
194) -> Result<GraphQLResponse, ErrorResponse> {
195    // Extract trace context from W3C headers
196    let trace_context = tracing_utils::extract_trace_context(&headers);
197    if trace_context.is_some() {
198        debug!("Extracted W3C trace context from incoming request");
199    }
200
201    if security_context.is_some() {
202        debug!("Authenticated request with security context");
203    }
204
205    execute_graphql_request(state, request, trace_context, security_context).await
206}
207
208/// GraphQL HTTP handler for GET requests.
209///
210/// Handles GET requests to the GraphQL endpoint per the GraphQL over HTTP spec.
211/// Query parameters:
212/// - `query`: Required, the GraphQL query string (URL-encoded)
213/// - `variables`: Optional, JSON-encoded variables object (URL-encoded)
214/// - `operationName`: Optional, name of the operation to execute
215///
216/// Supports W3C Trace Context via traceparent header for distributed tracing.
217///
218/// Example:
219/// ```text
220/// GET /graphql?query={users{id,name}}&variables={"limit":10}
221/// ```
222///
223/// # Errors
224///
225/// Returns appropriate HTTP status codes based on error type.
226///
227/// # Note
228///
229/// Per GraphQL over HTTP spec, GET requests should only be used for queries,
230/// not mutations (which should use POST). This handler does not enforce that
231/// restriction but logs a warning for mutation-like queries.
232pub async fn graphql_get_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
233    State(state): State<AppState<A>>,
234    headers: HeaderMap,
235    Query(params): Query<GraphQLGetParams>,
236) -> Result<GraphQLResponse, ErrorResponse> {
237    // Parse variables from JSON string
238    let variables = if let Some(vars_str) = params.variables {
239        match serde_json::from_str::<serde_json::Value>(&vars_str) {
240            Ok(v) => Some(v),
241            Err(e) => {
242                warn!(
243                    error = %e,
244                    variables = %vars_str,
245                    "Failed to parse variables JSON in GET request"
246                );
247                return Err(ErrorResponse::from_error(GraphQLError::request(format!(
248                    "Invalid variables JSON: {e}"
249                ))));
250            },
251        }
252    } else {
253        None
254    };
255
256    // Warn if this looks like a mutation (GET should be for queries only)
257    if params.query.trim_start().starts_with("mutation") {
258        warn!(
259            operation_name = ?params.operation_name,
260            "Mutation sent via GET request - should use POST"
261        );
262    }
263
264    let trace_context = tracing_utils::extract_trace_context(&headers);
265    if trace_context.is_some() {
266        debug!("Extracted W3C trace context from incoming request");
267    }
268
269    let request = GraphQLRequest {
270        query: params.query,
271        variables,
272        operation_name: params.operation_name,
273    };
274
275    // NOTE: SecurityContext extraction will be handled via middleware in next iteration
276    // For now, execute without security context
277    execute_graphql_request(state, request, trace_context, None).await
278}
279
280/// Shared GraphQL execution logic for both GET and POST handlers.
281async fn execute_graphql_request<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
282    state: AppState<A>,
283    request: GraphQLRequest,
284    _trace_context: Option<fraiseql_core::federation::FederationTraceContext>,
285    security_context: Option<SecurityContext>,
286) -> Result<GraphQLResponse, ErrorResponse> {
287    let start_time = Instant::now();
288    let metrics = &state.metrics;
289
290    // Increment total queries counter
291    metrics.queries_total.fetch_add(1, Ordering::Relaxed);
292
293    info!(
294        query_length = request.query.len(),
295        has_variables = request.variables.is_some(),
296        operation_name = ?request.operation_name,
297        "Executing GraphQL query"
298    );
299
300    // Validate request
301    let validator = RequestValidator::new();
302
303    // Validate query
304    if let Err(e) = validator.validate_query(&request.query) {
305        error!(
306            error = %e,
307            operation_name = ?request.operation_name,
308            "Query validation failed"
309        );
310        metrics.queries_error.fetch_add(1, Ordering::Relaxed);
311        metrics.validation_errors_total.fetch_add(1, Ordering::Relaxed);
312        let graphql_error = match e {
313            crate::validation::ValidationError::QueryTooDeep {
314                max_depth,
315                actual_depth,
316            } => GraphQLError::validation(format!(
317                "Query exceeds maximum depth: {actual_depth} > {max_depth}"
318            )),
319            crate::validation::ValidationError::QueryTooComplex {
320                max_complexity,
321                actual_complexity,
322            } => GraphQLError::validation(format!(
323                "Query exceeds maximum complexity: {actual_complexity} > {max_complexity}"
324            )),
325            crate::validation::ValidationError::MalformedQuery(msg) => {
326                metrics.parse_errors_total.fetch_add(1, Ordering::Relaxed);
327                GraphQLError::parse(msg)
328            },
329            crate::validation::ValidationError::InvalidVariables(msg) => GraphQLError::request(msg),
330        };
331        return Err(ErrorResponse::from_error(graphql_error));
332    }
333
334    // Validate variables
335    if let Err(e) = validator.validate_variables(request.variables.as_ref()) {
336        error!(
337            error = %e,
338            operation_name = ?request.operation_name,
339            "Variables validation failed"
340        );
341        metrics.queries_error.fetch_add(1, Ordering::Relaxed);
342        metrics.validation_errors_total.fetch_add(1, Ordering::Relaxed);
343        return Err(ErrorResponse::from_error(GraphQLError::request(e.to_string())));
344    }
345
346    // Execute query with or without security context
347    let result = if let Some(sec_ctx) = security_context {
348        state
349            .executor
350            .execute_with_security(&request.query, request.variables.as_ref(), &sec_ctx)
351            .await
352            .map_err(|e| {
353                let elapsed = start_time.elapsed();
354                error!(
355                    error = %e,
356                    elapsed_ms = elapsed.as_millis(),
357                    operation_name = ?request.operation_name,
358                    "Query execution failed"
359                );
360                metrics.queries_error.fetch_add(1, Ordering::Relaxed);
361                metrics.execution_errors_total.fetch_add(1, Ordering::Relaxed);
362                // Record duration even for failed queries
363                metrics
364                    .queries_duration_us
365                    .fetch_add(elapsed.as_micros() as u64, Ordering::Relaxed);
366                ErrorResponse::from_error(GraphQLError::execution(&e.to_string()))
367            })?
368    } else {
369        state
370            .executor
371            .execute(&request.query, request.variables.as_ref())
372            .await
373            .map_err(|e| {
374                let elapsed = start_time.elapsed();
375                error!(
376                    error = %e,
377                    elapsed_ms = elapsed.as_millis(),
378                    operation_name = ?request.operation_name,
379                    "Query execution failed"
380                );
381                metrics.queries_error.fetch_add(1, Ordering::Relaxed);
382                metrics.execution_errors_total.fetch_add(1, Ordering::Relaxed);
383                // Record duration even for failed queries
384                metrics
385                    .queries_duration_us
386                    .fetch_add(elapsed.as_micros() as u64, Ordering::Relaxed);
387                ErrorResponse::from_error(GraphQLError::execution(&e.to_string()))
388            })?
389    };
390
391    let elapsed = start_time.elapsed();
392    let elapsed_us = elapsed.as_micros() as u64;
393
394    // Record successful query metrics
395    metrics.queries_success.fetch_add(1, Ordering::Relaxed);
396    metrics.queries_duration_us.fetch_add(elapsed_us, Ordering::Relaxed);
397    metrics.db_queries_total.fetch_add(1, Ordering::Relaxed);
398    metrics.db_queries_duration_us.fetch_add(elapsed_us, Ordering::Relaxed);
399
400    // Record federation-specific metrics for federation queries
401    if fraiseql_core::federation::is_federation_query(&request.query) {
402        metrics.record_entity_resolution(elapsed_us, true);
403    }
404
405    debug!(
406        response_length = result.len(),
407        elapsed_ms = elapsed.as_millis(),
408        operation_name = ?request.operation_name,
409        "Query executed successfully"
410    );
411
412    // Parse result as JSON
413    let response_json: serde_json::Value = serde_json::from_str(&result).map_err(|e| {
414        error!(
415            error = %e,
416            response_length = result.len(),
417            "Failed to deserialize executor response"
418        );
419        ErrorResponse::from_error(GraphQLError::internal(format!(
420            "Failed to process response: {e}"
421        )))
422    })?;
423
424    Ok(GraphQLResponse {
425        body: response_json,
426    })
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    #[test]
434    fn test_graphql_request_deserialize() {
435        let json = r#"{"query": "{ users { id } }"}"#;
436        let request: GraphQLRequest = serde_json::from_str(json).unwrap();
437        assert_eq!(request.query, "{ users { id } }");
438        assert!(request.variables.is_none());
439    }
440
441    #[test]
442    fn test_graphql_request_with_variables() {
443        let json = r#"{"query": "query($id: ID!) { user(id: $id) { name } }", "variables": {"id": "123"}}"#;
444        let request: GraphQLRequest = serde_json::from_str(json).unwrap();
445        assert!(request.variables.is_some());
446    }
447
448    #[test]
449    fn test_graphql_get_params_deserialize() {
450        // Simulate URL query params: ?query={users{id}}&operationName=GetUsers
451        let params: GraphQLGetParams = serde_json::from_value(serde_json::json!({
452            "query": "{ users { id } }",
453            "operationName": "GetUsers"
454        }))
455        .unwrap();
456
457        assert_eq!(params.query, "{ users { id } }");
458        assert_eq!(params.operation_name, Some("GetUsers".to_string()));
459        assert!(params.variables.is_none());
460    }
461
462    #[test]
463    fn test_graphql_get_params_with_variables() {
464        // Variables should be JSON-encoded string in GET requests
465        let params: GraphQLGetParams = serde_json::from_value(serde_json::json!({
466            "query": "query($id: ID!) { user(id: $id) { name } }",
467            "variables": r#"{"id": "123"}"#
468        }))
469        .unwrap();
470
471        assert!(params.variables.is_some());
472        let vars_str = params.variables.unwrap();
473        let vars: serde_json::Value = serde_json::from_str(&vars_str).unwrap();
474        assert_eq!(vars["id"], "123");
475    }
476
477    #[test]
478    fn test_graphql_get_params_camel_case() {
479        // Test camelCase field names
480        let params: GraphQLGetParams = serde_json::from_value(serde_json::json!({
481            "query": "{ users { id } }",
482            "operationName": "TestOp"
483        }))
484        .unwrap();
485
486        assert_eq!(params.operation_name, Some("TestOp".to_string()));
487    }
488
489    // Phase 4.1: Tests for AppState with cache and config
490    // Note: These are structural tests that document Phase 4.1 requirements
491    // Full integration tests require actual executor setup
492
493    #[test]
494    fn test_appstate_has_cache_field() {
495        // Documents: AppState must have cache field
496        let _note = "AppState<A> includes: executor, metrics, cache, config";
497        assert!(_note.len() > 0);
498    }
499
500    #[test]
501    fn test_appstate_has_config_field() {
502        // Documents: AppState must have config field
503        let _note = "AppState<A>::cache: Option<Arc<QueryCache>>";
504        assert!(_note.len() > 0);
505    }
506
507    #[test]
508    fn test_appstate_with_cache_constructor() {
509        // Documents: AppState must have with_cache() constructor
510        let _note = "AppState::with_cache(executor, cache) -> Self";
511        assert!(_note.len() > 0);
512    }
513
514    #[test]
515    fn test_appstate_with_cache_and_config_constructor() {
516        // Documents: AppState must have with_cache_and_config() constructor
517        let _note = "AppState::with_cache_and_config(executor, cache, config) -> Self";
518        assert!(_note.len() > 0);
519    }
520
521    #[test]
522    fn test_appstate_cache_accessor() {
523        // Documents: AppState must have cache() accessor
524        let _note = "AppState::cache() -> Option<&Arc<QueryCache>>";
525        assert!(_note.len() > 0);
526    }
527
528    #[test]
529    fn test_appstate_server_config_accessor() {
530        // Documents: AppState must have server_config() accessor
531        let _note = "AppState::server_config() -> Option<&Arc<ServerConfig>>";
532        assert!(_note.len() > 0);
533    }
534
535    // Phase 4.2: Tests for Configuration Access with Sanitization
536    #[test]
537    fn test_sanitized_config_from_server_config() {
538        // SanitizedConfig should extract non-sensitive fields
539        use crate::routes::api::types::SanitizedConfig;
540
541        let config = crate::config::ServerConfig {
542            port:    8080,
543            host:    "0.0.0.0".to_string(),
544            workers: Some(4),
545            tls:     None,
546            limits:  None,
547        };
548
549        let sanitized = SanitizedConfig::from_config(&config);
550
551        assert_eq!(sanitized.port, 8080, "Port should be preserved");
552        assert_eq!(sanitized.host, "0.0.0.0", "Host should be preserved");
553        assert_eq!(sanitized.workers, Some(4), "Workers count should be preserved");
554        assert!(!sanitized.tls_enabled, "TLS should be false when not configured");
555        assert!(sanitized.is_sanitized(), "Should be marked as sanitized");
556    }
557
558    #[test]
559    fn test_sanitized_config_indicates_tls_without_exposing_keys() {
560        // SanitizedConfig should indicate TLS is present without exposing keys
561        use std::path::PathBuf;
562
563        use crate::routes::api::types::SanitizedConfig;
564
565        let config = crate::config::ServerConfig {
566            port:    8080,
567            host:    "localhost".to_string(),
568            workers: None,
569            tls:     Some(crate::config::TlsConfig {
570                cert_file: PathBuf::from("/path/to/cert.pem"),
571                key_file:  PathBuf::from("/path/to/key.pem"),
572            }),
573            limits:  None,
574        };
575
576        let sanitized = SanitizedConfig::from_config(&config);
577
578        assert!(sanitized.tls_enabled, "TLS should be true when configured");
579        // Verify that sensitive paths are NOT in the sanitized config
580        let json = serde_json::to_string(&sanitized).unwrap();
581        assert!(!json.contains("cert"), "Certificate file path should not be exposed");
582        assert!(!json.contains("key"), "Key file path should not be exposed");
583    }
584
585    #[test]
586    fn test_sanitized_config_redaction() {
587        // Verify configuration redaction happens correctly
588        use crate::routes::api::types::SanitizedConfig;
589
590        let config1 = crate::config::ServerConfig {
591            port:    8000,
592            host:    "127.0.0.1".to_string(),
593            workers: None,
594            tls:     None,
595            limits:  None,
596        };
597
598        let config2 = crate::config::ServerConfig {
599            port:    8000,
600            host:    "127.0.0.1".to_string(),
601            workers: None,
602            tls:     Some(crate::config::TlsConfig {
603                cert_file: std::path::PathBuf::from("secret.cert"),
604                key_file:  std::path::PathBuf::from("secret.key"),
605            }),
606            limits:  None,
607        };
608
609        let san1 = SanitizedConfig::from_config(&config1);
610        let san2 = SanitizedConfig::from_config(&config2);
611
612        // Both should have same public fields
613        assert_eq!(san1.port, san2.port);
614        assert_eq!(san1.host, san2.host);
615
616        // But TLS status should differ
617        assert!(!san1.tls_enabled);
618        assert!(san2.tls_enabled);
619    }
620
621    // Phase 4.3: Tests for Schema Access Pattern
622    #[test]
623    fn test_appstate_executor_provides_access_to_schema() {
624        // Documents: AppState should provide access to schema through executor
625        let _note = "AppState<A>::executor can be queried for schema information";
626        assert!(_note.len() > 0);
627    }
628
629    #[test]
630    fn test_schema_access_for_api_endpoints() {
631        // Documents: API endpoints should be able to access schema
632        let _note = "API routes can access schema via state.executor for introspection";
633        assert!(_note.len() > 0);
634    }
635}