Skip to main content

fraiseql_server/routes/api/
admin.rs

1//! Admin API endpoints.
2//!
3//! Provides endpoints for:
4//! - Hot-reloading schema without restart
5//! - Invalidating cache by scope (all, entity type, or pattern)
6//! - Inspecting runtime configuration (sanitized)
7
8use std::{collections::HashMap, fs};
9
10use axum::{Json, extract::State};
11use fraiseql_core::{db::traits::DatabaseAdapter, schema::CompiledSchema};
12use serde::{Deserialize, Serialize};
13use tracing::{error, info};
14
15use crate::routes::{
16    api::types::{ApiError, ApiResponse},
17    graphql::AppState,
18};
19
20/// Request to reload schema from file.
21#[derive(Debug, Deserialize, Serialize)]
22pub struct ReloadSchemaRequest {
23    /// Path to compiled schema file
24    pub schema_path:   String,
25    /// If true, only validate the schema without applying changes
26    pub validate_only: bool,
27}
28
29/// Response after schema reload attempt.
30#[derive(Debug, Serialize)]
31pub struct ReloadSchemaResponse {
32    /// Whether the operation succeeded
33    pub success: bool,
34    /// Human-readable message about the result
35    pub message: String,
36}
37
38/// Request to clear cache entries.
39#[derive(Debug, Deserialize, Serialize)]
40pub struct CacheClearRequest {
41    /// Scope for clearing: "all", "entity", or "pattern"
42    pub scope:       String,
43    /// Entity type (required if scope is "entity")
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub entity_type: Option<String>,
46    /// Pattern (required if scope is "pattern")
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub pattern:     Option<String>,
49}
50
51/// Response after cache clear operation.
52#[derive(Debug, Serialize)]
53pub struct CacheClearResponse {
54    /// Whether the operation succeeded
55    pub success:         bool,
56    /// Number of entries cleared
57    pub entries_cleared: usize,
58    /// Human-readable message about the result
59    pub message:         String,
60}
61
62/// Response containing runtime configuration (sanitized).
63#[derive(Debug, Serialize)]
64pub struct AdminConfigResponse {
65    /// Server version
66    pub version: String,
67    /// Runtime configuration (secrets redacted)
68    pub config:  HashMap<String, String>,
69}
70
71/// Reload schema from file.
72///
73/// Supports validation-only mode via `validate_only` flag.
74/// When applied, the schema is atomically swapped without stopping execution.
75///
76/// # Errors
77///
78/// Returns `ApiError` with a validation error if `schema_path` is empty.
79/// Returns `ApiError` with a parse error if the schema file cannot be read or parsed.
80///
81/// Requires admin token authentication.
82pub async fn reload_schema_handler<A: DatabaseAdapter>(
83    State(state): State<AppState<A>>,
84    Json(req): Json<ReloadSchemaRequest>,
85) -> Result<Json<ApiResponse<ReloadSchemaResponse>>, ApiError> {
86    let _ = &state; // used conditionally by #[cfg(feature = "arrow")]
87    if req.schema_path.is_empty() {
88        return Err(ApiError::validation_error("schema_path cannot be empty"));
89    }
90
91    // Step 1: Load schema from file
92    let schema_json = fs::read_to_string(&req.schema_path)
93        .map_err(|e| ApiError::parse_error(format!("Failed to read schema file: {}", e)))?;
94
95    // Step 2: Validate schema structure
96    let _validated_schema = CompiledSchema::from_json(&schema_json)
97        .map_err(|e| ApiError::parse_error(format!("Invalid schema JSON: {}", e)))?;
98
99    if req.validate_only {
100        info!(
101            operation = "admin.reload_schema",
102            schema_path = %req.schema_path,
103            validate_only = true,
104            success = true,
105            "Admin: schema validation requested"
106        );
107        let response = ReloadSchemaResponse {
108            success: true,
109            message: "Schema validated successfully (not applied)".to_string(),
110        };
111        Ok(Json(ApiResponse {
112            status: "success".to_string(),
113            data:   response,
114        }))
115    } else {
116        // Step 3: Atomically swap the executor with the new schema
117        let start = std::time::Instant::now();
118        let schema_path = std::path::Path::new(&req.schema_path);
119
120        match state.reload_schema(schema_path).await {
121            Ok(()) => {
122                let duration_ms = start.elapsed().as_millis();
123                state
124                    .metrics
125                    .schema_reloads_total
126                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
127                info!(
128                    operation = "admin.reload_schema",
129                    schema_path = %req.schema_path,
130                    duration_ms,
131                    "Schema reloaded successfully"
132                );
133
134                let response = ReloadSchemaResponse {
135                    success: true,
136                    message: format!("Schema reloaded from {} in {duration_ms}ms", req.schema_path),
137                };
138                Ok(Json(ApiResponse {
139                    status: "success".to_string(),
140                    data:   response,
141                }))
142            },
143            Err(e) => {
144                state
145                    .metrics
146                    .schema_reload_errors_total
147                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
148                error!(
149                    operation = "admin.reload_schema",
150                    schema_path = %req.schema_path,
151                    error = %e,
152                    "Schema reload failed"
153                );
154                Err(ApiError::internal_error(format!("Schema reload failed: {e}")))
155            },
156        }
157    }
158}
159
160/// Cache statistics response.
161#[derive(Debug, Serialize)]
162pub struct CacheStatsResponse {
163    /// Number of entries currently in cache
164    pub entries_count: usize,
165    /// Whether cache is enabled
166    pub cache_enabled: bool,
167    /// Cache TTL in seconds
168    pub ttl_secs:      u64,
169    /// Human-readable message
170    pub message:       String,
171}
172
173/// Clear cache entries by scope.
174///
175/// Supports three clearing scopes:
176/// - **all**: Clear all cache entries
177/// - **entity**: Clear entries for a specific entity type
178/// - **pattern**: Clear entries matching a glob pattern
179///
180/// # Errors
181///
182/// Returns `ApiError` with an internal error if the cache feature is not enabled.
183/// Returns `ApiError` with a validation error if required parameters are missing or scope is
184/// invalid.
185///
186/// Requires admin token authentication.
187pub async fn cache_clear_handler<A: DatabaseAdapter>(
188    State(state): State<AppState<A>>,
189    Json(req): Json<CacheClearRequest>,
190) -> Result<Json<ApiResponse<CacheClearResponse>>, ApiError> {
191    // Cache operations require the `arrow` feature.
192    #[cfg(not(feature = "arrow"))]
193    {
194        let _ = (state, req);
195        Err(ApiError::internal_error("Cache not configured"))
196    }
197
198    #[cfg(feature = "arrow")]
199    // Validate scope and required parameters
200    match req.scope.as_str() {
201        "all" => {
202            if let Some(cache) = state.cache() {
203                let entries_before = cache.len();
204                cache.clear();
205                info!(
206                    operation = "admin.cache_clear",
207                    scope = "all",
208                    entries_cleared = entries_before,
209                    success = true,
210                    "Admin: cache cleared (all entries)"
211                );
212                let response = CacheClearResponse {
213                    success:         true,
214                    entries_cleared: entries_before,
215                    message:         format!("Cleared {} cache entries", entries_before),
216                };
217                Ok(Json(ApiResponse {
218                    status: "success".to_string(),
219                    data:   response,
220                }))
221            } else {
222                Err(ApiError::internal_error("Cache not configured"))
223            }
224        },
225        "entity" => {
226            if req.entity_type.is_none() {
227                return Err(ApiError::validation_error(
228                    "entity_type is required when scope is 'entity'",
229                ));
230            }
231
232            if let Some(cache) = state.cache() {
233                let entity_type = req.entity_type.as_ref().ok_or_else(|| {
234                    ApiError::internal_error(
235                        "entity_type was None after validation — this is a bug",
236                    )
237                })?;
238                // Convert entity type to view name pattern (e.g., User → v_user)
239                let view_name = format!("v_{}", entity_type.to_lowercase());
240                let entries_cleared = cache.invalidate_views(&[&view_name]);
241                info!(
242                    operation = "admin.cache_clear",
243                    scope = "entity",
244                    entity_type = %entity_type,
245                    entries_cleared,
246                    success = true,
247                    "Admin: cache cleared for entity"
248                );
249                let response = CacheClearResponse {
250                    success: true,
251                    entries_cleared,
252                    message: format!(
253                        "Cleared {} cache entries for entity type '{}'",
254                        entries_cleared, entity_type
255                    ),
256                };
257                Ok(Json(ApiResponse {
258                    status: "success".to_string(),
259                    data:   response,
260                }))
261            } else {
262                Err(ApiError::internal_error("Cache not configured"))
263            }
264        },
265        "pattern" => {
266            if req.pattern.is_none() {
267                return Err(ApiError::validation_error(
268                    "pattern is required when scope is 'pattern'",
269                ));
270            }
271
272            if let Some(cache) = state.cache() {
273                let pattern = req.pattern.as_ref().ok_or_else(|| {
274                    ApiError::internal_error("pattern was None after validation — this is a bug")
275                })?;
276                let entries_cleared = cache.invalidate_pattern(pattern);
277                info!(
278                    operation = "admin.cache_clear",
279                    scope = "pattern",
280                    %pattern,
281                    entries_cleared,
282                    success = true,
283                    "Admin: cache cleared by pattern"
284                );
285                let response = CacheClearResponse {
286                    success: true,
287                    entries_cleared,
288                    message: format!(
289                        "Cleared {} cache entries matching pattern '{}'",
290                        entries_cleared, pattern
291                    ),
292                };
293                Ok(Json(ApiResponse {
294                    status: "success".to_string(),
295                    data:   response,
296                }))
297            } else {
298                Err(ApiError::internal_error("Cache not configured"))
299            }
300        },
301        _ => Err(ApiError::validation_error("scope must be 'all', 'entity', or 'pattern'")),
302    }
303}
304
305/// Get cache statistics.
306///
307/// Returns current cache metrics including entry count, enabled status, and TTL.
308///
309/// # Errors
310///
311/// This handler currently always succeeds; it is infallible.
312///
313/// Requires admin token authentication.
314pub async fn cache_stats_handler<A: DatabaseAdapter>(
315    State(state): State<AppState<A>>,
316) -> Result<Json<ApiResponse<CacheStatsResponse>>, ApiError> {
317    #[cfg(feature = "arrow")]
318    if let Some(cache) = state.cache() {
319        let response = CacheStatsResponse {
320            entries_count: cache.len(),
321            cache_enabled: true,
322            ttl_secs:      60, // Default TTL from QueryCache::new(60)
323            message:       format!("Cache contains {} entries with 60-second TTL", cache.len()),
324        };
325        return Ok(Json(ApiResponse {
326            status: "success".to_string(),
327            data:   response,
328        }));
329    }
330    {
331        let _ = state;
332        let response = CacheStatsResponse {
333            entries_count: 0,
334            cache_enabled: false,
335            ttl_secs:      0,
336            message:       "Cache is not configured".to_string(),
337        };
338        Ok(Json(ApiResponse {
339            status: "success".to_string(),
340            data:   response,
341        }))
342    }
343}
344
345/// Get sanitized runtime configuration.
346///
347/// Returns server version and runtime configuration with secrets redacted.
348/// Configuration includes database settings, cache settings, etc.
349/// but excludes API keys, passwords, and other sensitive data.
350///
351/// # Errors
352///
353/// This handler currently always succeeds; it is infallible.
354///
355/// Requires admin token authentication.
356// Reason: `cache_enabled = "false"` appears in both the else-branch and the
357// `#[cfg(not(feature = "arrow"))]` inner path. Clippy sees them as shared code, but
358// extracting it would break the `#[cfg]` conditional logic that sets a different value
359// when `arrow` is enabled.
360#[allow(clippy::branches_sharing_code)] // Reason: branches are logically distinct; extracting shared code would obscure intent
361pub async fn config_handler<A: DatabaseAdapter>(
362    State(state): State<AppState<A>>,
363) -> Result<Json<ApiResponse<AdminConfigResponse>>, ApiError> {
364    let mut config = HashMap::new();
365
366    // Get actual server configuration
367    if let Some(server_config) = state.server_config() {
368        // Safe configuration values - no secrets
369        config.insert("port".to_string(), server_config.port.to_string());
370        config.insert("host".to_string(), server_config.host.clone());
371
372        if let Some(workers) = server_config.workers {
373            config.insert("workers".to_string(), workers.to_string());
374        }
375
376        // TLS status (boolean only, paths are redacted)
377        config.insert("tls_enabled".to_string(), server_config.tls.is_some().to_string());
378
379        // Request limits
380        if let Some(limits) = &server_config.limits {
381            config.insert("max_request_size".to_string(), limits.max_request_size.clone());
382            config.insert("request_timeout".to_string(), limits.request_timeout.clone());
383            config.insert(
384                "max_concurrent_requests".to_string(),
385                limits.max_concurrent_requests.to_string(),
386            );
387            config.insert("max_queue_depth".to_string(), limits.max_queue_depth.to_string());
388        }
389
390        // Cache status (requires arrow feature)
391        #[cfg(feature = "arrow")]
392        config.insert("cache_enabled".to_string(), state.cache().is_some().to_string());
393        #[cfg(not(feature = "arrow"))]
394        config.insert("cache_enabled".to_string(), "false".to_string());
395    } else {
396        // Minimal configuration if not available
397        config.insert("cache_enabled".to_string(), "false".to_string());
398    }
399
400    let response = AdminConfigResponse {
401        version: env!("CARGO_PKG_VERSION").to_string(),
402        config,
403    };
404
405    Ok(Json(ApiResponse {
406        status: "success".to_string(),
407        data:   response,
408    }))
409}
410
411/// Request body for `POST /api/v1/admin/explain`.
412#[derive(Debug, Deserialize, Serialize)]
413pub struct ExplainRequest {
414    /// Name of the regular query to explain (e.g., `"users"`).
415    pub query: String,
416
417    /// GraphQL-style variable filters passed as a JSON object.
418    ///
419    /// Each key-value pair becomes an equality condition in the WHERE clause.
420    /// Example: `{"status": "active"}` → `WHERE data->>'status' = 'active'`.
421    #[serde(skip_serializing_if = "Option::is_none")]
422    pub variables: Option<serde_json::Value>,
423
424    /// Optional row limit to pass to the query.
425    #[serde(skip_serializing_if = "Option::is_none")]
426    pub limit: Option<u32>,
427
428    /// Optional row offset to pass to the query.
429    #[serde(skip_serializing_if = "Option::is_none")]
430    pub offset: Option<u32>,
431}
432
433/// Return the pre-built Grafana dashboard JSON for FraiseQL metrics.
434///
435/// The dashboard JSON is embedded at compile time from
436/// `deploy/grafana/fraiseql-dashboard.json`.  Operators can import it into
437/// Grafana with a single `curl` command (see `deploy/grafana/README.md`).
438///
439/// # Errors
440///
441/// This handler is infallible — the embedded JSON is validated at compile time
442/// by the `test_grafana_dashboard_is_valid_json` unit test.
443///
444/// Requires admin token authentication.
445pub async fn grafana_dashboard_handler<A: DatabaseAdapter>(
446    State(_state): State<AppState<A>>,
447) -> impl axum::response::IntoResponse {
448    const DASHBOARD_JSON: &str =
449        include_str!("../../../resources/fraiseql-dashboard.json");
450
451    (
452        axum::http::StatusCode::OK,
453        [(axum::http::header::CONTENT_TYPE, "application/json")],
454        DASHBOARD_JSON,
455    )
456}
457
458/// Run `EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)` for a named query.
459///
460/// Accepts a query name and optional variable filters, then executes
461/// `EXPLAIN ANALYZE` against the backing PostgreSQL view using the exact
462/// same parameterized SQL that a live query would use.
463///
464/// # Errors
465///
466/// * `400 Bad Request` — empty query name, unknown query, or mutation given
467/// * `500 Internal Server Error` — database execution failure
468///
469/// Requires admin token authentication.
470pub async fn explain_handler<A: DatabaseAdapter + 'static>(
471    State(state): State<AppState<A>>,
472    Json(req): Json<ExplainRequest>,
473) -> Result<Json<ApiResponse<fraiseql_core::runtime::ExplainResult>>, ApiError> {
474    if req.query.is_empty() {
475        return Err(ApiError::validation_error("query cannot be empty"));
476    }
477
478    state
479        .executor()
480        .explain(&req.query, req.variables.as_ref(), req.limit, req.offset)
481        .await
482        .map(ApiResponse::success)
483        .map_err(|e| match e {
484            fraiseql_core::error::FraiseQLError::Validation { message, .. } => {
485                ApiError::validation_error(message)
486            },
487            fraiseql_core::error::FraiseQLError::Unsupported { message } => {
488                ApiError::validation_error(format!("Unsupported: {message}"))
489            },
490            other => ApiError::internal_error(other.to_string()),
491        })
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    #[test]
499    fn test_grafana_dashboard_is_valid_json() {
500        let parsed: serde_json::Value = serde_json::from_str(include_str!(
501            "../../../resources/fraiseql-dashboard.json"
502        ))
503        .expect("fraiseql-dashboard.json must be valid JSON");
504
505        assert_eq!(parsed["title"], "FraiseQL Performance");
506        assert_eq!(parsed["uid"], "fraiseql-perf-v1");
507        assert!(
508            parsed["panels"].as_array().map_or(0, |p| p.len()) >= 10,
509            "dashboard should have at least 10 panels"
510        );
511    }
512
513    #[test]
514    fn test_reload_schema_request_empty_path() {
515        let request = ReloadSchemaRequest {
516            schema_path:   String::new(),
517            validate_only: false,
518        };
519
520        assert!(request.schema_path.is_empty());
521    }
522
523    #[test]
524    fn test_reload_schema_request_with_path() {
525        let request = ReloadSchemaRequest {
526            schema_path:   "/path/to/schema.json".to_string(),
527            validate_only: false,
528        };
529
530        assert!(!request.schema_path.is_empty());
531    }
532
533    #[test]
534    fn test_cache_clear_scope_validation() {
535        let valid_scopes = vec!["all", "entity", "pattern"];
536
537        for scope in valid_scopes {
538            let request = CacheClearRequest {
539                scope:       scope.to_string(),
540                entity_type: None,
541                pattern:     None,
542            };
543            assert_eq!(request.scope, scope);
544        }
545    }
546
547    #[test]
548    fn test_admin_config_response_has_version() {
549        let response = AdminConfigResponse {
550            version: "2.0.0-a1".to_string(),
551            config:  HashMap::new(),
552        };
553
554        assert!(!response.version.is_empty());
555    }
556
557    #[test]
558    fn test_reload_schema_response_success() {
559        let response = ReloadSchemaResponse {
560            success: true,
561            message: "Reloaded".to_string(),
562        };
563
564        assert!(response.success);
565    }
566
567    #[test]
568    fn test_reload_schema_response_failure() {
569        let response = ReloadSchemaResponse {
570            success: false,
571            message: "Failed to load".to_string(),
572        };
573
574        assert!(!response.success);
575    }
576
577    #[test]
578    fn test_cache_clear_response_counts_entries() {
579        let response = CacheClearResponse {
580            success:         true,
581            entries_cleared: 42,
582            message:         "Cleared".to_string(),
583        };
584
585        assert_eq!(response.entries_cleared, 42);
586    }
587
588    #[test]
589    fn test_cache_clear_request_entity_required_for_entity_scope() {
590        let request = CacheClearRequest {
591            scope:       "entity".to_string(),
592            entity_type: Some("User".to_string()),
593            pattern:     None,
594        };
595
596        assert_eq!(request.scope, "entity");
597        assert_eq!(request.entity_type.as_deref(), Some("User"));
598    }
599
600    #[test]
601    fn test_cache_clear_request_pattern_required_for_pattern_scope() {
602        let request = CacheClearRequest {
603            scope:       "pattern".to_string(),
604            entity_type: None,
605            pattern:     Some("*_user".to_string()),
606        };
607
608        assert_eq!(request.scope, "pattern");
609        assert_eq!(request.pattern.as_deref(), Some("*_user"));
610    }
611
612    #[test]
613    fn test_admin_config_response_sanitization_excludes_paths() {
614        let response = AdminConfigResponse {
615            version: "2.0.0".to_string(),
616            config:  {
617                let mut m = HashMap::new();
618                m.insert("port".to_string(), "8000".to_string());
619                m.insert("host".to_string(), "0.0.0.0".to_string());
620                m.insert("tls_enabled".to_string(), "true".to_string());
621                m
622            },
623        };
624
625        assert_eq!(response.config.get("port"), Some(&"8000".to_string()));
626        assert_eq!(response.config.get("host"), Some(&"0.0.0.0".to_string()));
627        assert_eq!(response.config.get("tls_enabled"), Some(&"true".to_string()));
628        // Verify no cert_file or key_file keys (paths redacted)
629        assert!(!response.config.contains_key("cert_file"));
630        assert!(!response.config.contains_key("key_file"));
631    }
632
633    #[test]
634    fn test_admin_config_response_includes_limits() {
635        let response = AdminConfigResponse {
636            version: "2.0.0".to_string(),
637            config:  {
638                let mut m = HashMap::new();
639                m.insert("max_request_size".to_string(), "10MB".to_string());
640                m.insert("request_timeout".to_string(), "30s".to_string());
641                m.insert("max_concurrent_requests".to_string(), "1000".to_string());
642                m
643            },
644        };
645
646        assert!(response.config.contains_key("max_request_size"));
647        assert!(response.config.contains_key("request_timeout"));
648        assert!(response.config.contains_key("max_concurrent_requests"));
649    }
650
651    #[test]
652    fn test_cache_stats_response_structure() {
653        let response = CacheStatsResponse {
654            entries_count: 100,
655            cache_enabled: true,
656            ttl_secs:      60,
657            message:       "Cache statistics".to_string(),
658        };
659
660        assert_eq!(response.entries_count, 100);
661        assert!(response.cache_enabled);
662        assert_eq!(response.ttl_secs, 60);
663        assert!(!response.message.is_empty());
664    }
665
666    #[test]
667    fn test_reload_schema_request_validates_path() {
668        let request = ReloadSchemaRequest {
669            schema_path:   "/path/to/schema.json".to_string(),
670            validate_only: false,
671        };
672
673        assert!(!request.schema_path.is_empty());
674    }
675
676    #[test]
677    fn test_reload_schema_request_validate_only_flag() {
678        let request = ReloadSchemaRequest {
679            schema_path:   "/path/to/schema.json".to_string(),
680            validate_only: true,
681        };
682
683        assert!(request.validate_only);
684    }
685
686    #[test]
687    fn test_reload_schema_response_indicates_success() {
688        let response = ReloadSchemaResponse {
689            success: true,
690            message: "Schema reloaded".to_string(),
691        };
692
693        assert!(response.success);
694        assert!(!response.message.is_empty());
695    }
696
697    // ── Admin audit log tests (15-5) ────────────────────────────────────────
698
699    #[test]
700    fn test_reload_schema_request_carries_audit_fields() {
701        // Verifies that the request type exposes the fields needed to emit a
702        // complete audit log entry (schema_path + validate_only).
703        let req = ReloadSchemaRequest {
704            schema_path:   "/var/run/fraiseql/schema.compiled.json".to_string(),
705            validate_only: false,
706        };
707        assert!(!req.schema_path.is_empty(), "schema_path must be present for audit log");
708        // validate_only is always set (bool field) — no assertion needed.
709        let _ = req.validate_only;
710    }
711
712    #[test]
713    fn test_cache_clear_request_carries_audit_fields() {
714        // Verifies that CacheClearRequest exposes the fields needed for audit logging
715        // (scope, optional entity_type, optional pattern).
716        let all_req = CacheClearRequest {
717            scope:       "all".to_string(),
718            entity_type: None,
719            pattern:     None,
720        };
721        assert_eq!(all_req.scope, "all");
722
723        let entity_req = CacheClearRequest {
724            scope:       "entity".to_string(),
725            entity_type: Some("Order".to_string()),
726            pattern:     None,
727        };
728        assert!(
729            entity_req.entity_type.is_some(),
730            "entity scope must carry entity_type for audit"
731        );
732
733        let pattern_req = CacheClearRequest {
734            scope:       "pattern".to_string(),
735            entity_type: None,
736            pattern:     Some("v_order*".to_string()),
737        };
738        assert!(pattern_req.pattern.is_some(), "pattern scope must carry pattern for audit");
739    }
740}