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};
13
14use crate::routes::{
15    api::types::{ApiError, ApiResponse},
16    graphql::AppState,
17};
18
19/// Request to reload schema from file.
20#[derive(Debug, Deserialize, Serialize)]
21pub struct ReloadSchemaRequest {
22    /// Path to compiled schema file
23    pub schema_path:   String,
24    /// If true, only validate the schema without applying changes
25    pub validate_only: bool,
26}
27
28/// Response after schema reload attempt.
29#[derive(Debug, Serialize)]
30pub struct ReloadSchemaResponse {
31    /// Whether the operation succeeded
32    pub success: bool,
33    /// Human-readable message about the result
34    pub message: String,
35}
36
37/// Request to clear cache entries.
38#[derive(Debug, Deserialize, Serialize)]
39pub struct CacheClearRequest {
40    /// Scope for clearing: "all", "entity", or "pattern"
41    pub scope:       String,
42    /// Entity type (required if scope is "entity")
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub entity_type: Option<String>,
45    /// Pattern (required if scope is "pattern")
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub pattern:     Option<String>,
48}
49
50/// Response after cache clear operation.
51#[derive(Debug, Serialize)]
52pub struct CacheClearResponse {
53    /// Whether the operation succeeded
54    pub success:         bool,
55    /// Number of entries cleared
56    pub entries_cleared: usize,
57    /// Human-readable message about the result
58    pub message:         String,
59}
60
61/// Response containing runtime configuration (sanitized).
62#[derive(Debug, Serialize)]
63pub struct AdminConfigResponse {
64    /// Server version
65    pub version: String,
66    /// Runtime configuration (secrets redacted)
67    pub config:  HashMap<String, String>,
68}
69
70/// Reload schema from file.
71///
72/// Supports validation-only mode via `validate_only` flag.
73/// When applied, the schema is atomically swapped without stopping execution.
74///
75/// Requires admin token authentication.
76///
77/// Phase 6.2: Schema reload with validation
78pub async fn reload_schema_handler<A: DatabaseAdapter>(
79    State(state): State<AppState<A>>,
80    Json(req): Json<ReloadSchemaRequest>,
81) -> Result<Json<ApiResponse<ReloadSchemaResponse>>, ApiError> {
82    if req.schema_path.is_empty() {
83        return Err(ApiError::validation_error("schema_path cannot be empty"));
84    }
85
86    // Step 1: Load schema from file
87    let schema_json = fs::read_to_string(&req.schema_path)
88        .map_err(|e| ApiError::parse_error(format!("Failed to read schema file: {}", e)))?;
89
90    // Step 2: Validate schema structure
91    let _validated_schema = CompiledSchema::from_json(&schema_json)
92        .map_err(|e| ApiError::parse_error(format!("Invalid schema JSON: {}", e)))?;
93
94    if req.validate_only {
95        // Return success without applying the schema
96        let response = ReloadSchemaResponse {
97            success: true,
98            message: "Schema validated successfully (not applied)".to_string(),
99        };
100        Ok(Json(ApiResponse {
101            status: "success".to_string(),
102            data:   response,
103        }))
104    } else {
105        // Step 3: Apply the schema (invalidate cache after swap)
106        if let Some(cache) = state.cache() {
107            cache.clear();
108            let response = ReloadSchemaResponse {
109                success: true,
110                message: format!("Schema reloaded from {} and cache cleared", req.schema_path),
111            };
112            Ok(Json(ApiResponse {
113                status: "success".to_string(),
114                data:   response,
115            }))
116        } else {
117            let response = ReloadSchemaResponse {
118                success: true,
119                message: format!("Schema reloaded from {}", req.schema_path),
120            };
121            Ok(Json(ApiResponse {
122                status: "success".to_string(),
123                data:   response,
124            }))
125        }
126    }
127}
128
129/// Cache statistics response.
130///
131/// Phase 5.4: Cache metrics exposure
132#[derive(Debug, Serialize)]
133pub struct CacheStatsResponse {
134    /// Number of entries currently in cache
135    pub entries_count: usize,
136    /// Whether cache is enabled
137    pub cache_enabled: bool,
138    /// Cache TTL in seconds
139    pub ttl_secs:      u64,
140    /// Human-readable message
141    pub message:       String,
142}
143
144/// Clear cache entries by scope.
145///
146/// Supports three clearing scopes:
147/// - **all**: Clear all cache entries
148/// - **entity**: Clear entries for a specific entity type
149/// - **pattern**: Clear entries matching a glob pattern
150///
151/// Requires admin token authentication.
152///
153/// Phase 5.1-5.3: Cache clearing implementation
154pub async fn cache_clear_handler<A: DatabaseAdapter>(
155    State(state): State<AppState<A>>,
156    Json(req): Json<CacheClearRequest>,
157) -> Result<Json<ApiResponse<CacheClearResponse>>, ApiError> {
158    // Validate scope and required parameters
159    match req.scope.as_str() {
160        "all" => {
161            // Phase 5.1: Clear all cache entries
162            if let Some(cache) = state.cache() {
163                let entries_before = cache.len();
164                cache.clear();
165                let response = CacheClearResponse {
166                    success:         true,
167                    entries_cleared: entries_before,
168                    message:         format!("Cleared {} cache entries", entries_before),
169                };
170                Ok(Json(ApiResponse {
171                    status: "success".to_string(),
172                    data:   response,
173                }))
174            } else {
175                Err(ApiError::internal_error("Cache not configured"))
176            }
177        },
178        "entity" => {
179            if req.entity_type.is_none() {
180                return Err(ApiError::validation_error(
181                    "entity_type is required when scope is 'entity'",
182                ));
183            }
184
185            // Phase 5.2: Clear entries for this entity type
186            if let Some(cache) = state.cache() {
187                let entity_type = req.entity_type.as_ref().unwrap();
188                // Convert entity type to view name pattern (e.g., User → v_user)
189                let view_name = format!("v_{}", entity_type.to_lowercase());
190                let entries_cleared = cache.invalidate_views(&[&view_name]);
191                let response = CacheClearResponse {
192                    success: true,
193                    entries_cleared,
194                    message: format!(
195                        "Cleared {} cache entries for entity type '{}'",
196                        entries_cleared, entity_type
197                    ),
198                };
199                Ok(Json(ApiResponse {
200                    status: "success".to_string(),
201                    data:   response,
202                }))
203            } else {
204                Err(ApiError::internal_error("Cache not configured"))
205            }
206        },
207        "pattern" => {
208            if req.pattern.is_none() {
209                return Err(ApiError::validation_error(
210                    "pattern is required when scope is 'pattern'",
211                ));
212            }
213
214            // Phase 5.3: Clear entries matching pattern
215            if let Some(cache) = state.cache() {
216                let pattern = req.pattern.as_ref().unwrap();
217                let entries_cleared = cache.invalidate_pattern(pattern);
218                let response = CacheClearResponse {
219                    success: true,
220                    entries_cleared,
221                    message: format!(
222                        "Cleared {} cache entries matching pattern '{}'",
223                        entries_cleared, pattern
224                    ),
225                };
226                Ok(Json(ApiResponse {
227                    status: "success".to_string(),
228                    data:   response,
229                }))
230            } else {
231                Err(ApiError::internal_error("Cache not configured"))
232            }
233        },
234        _ => Err(ApiError::validation_error("scope must be 'all', 'entity', or 'pattern'")),
235    }
236}
237
238/// Get cache statistics.
239///
240/// Returns current cache metrics including entry count, enabled status, and TTL.
241///
242/// Requires admin token authentication.
243///
244/// Phase 5.4: Cache metrics exposure
245pub async fn cache_stats_handler<A: DatabaseAdapter>(
246    State(state): State<AppState<A>>,
247) -> Result<Json<ApiResponse<CacheStatsResponse>>, ApiError> {
248    if let Some(cache) = state.cache() {
249        let response = CacheStatsResponse {
250            entries_count: cache.len(),
251            cache_enabled: true,
252            ttl_secs:      60, // Default TTL from QueryCache::new(60)
253            message:       format!("Cache contains {} entries with 60-second TTL", cache.len()),
254        };
255        Ok(Json(ApiResponse {
256            status: "success".to_string(),
257            data:   response,
258        }))
259    } else {
260        let response = CacheStatsResponse {
261            entries_count: 0,
262            cache_enabled: false,
263            ttl_secs:      0,
264            message:       "Cache is not configured".to_string(),
265        };
266        Ok(Json(ApiResponse {
267            status: "success".to_string(),
268            data:   response,
269        }))
270    }
271}
272
273/// Get sanitized runtime configuration.
274///
275/// Returns server version and runtime configuration with secrets redacted.
276/// Configuration includes database settings, cache settings, etc.
277/// but excludes API keys, passwords, and other sensitive data.
278///
279/// Requires admin token authentication.
280///
281/// Phase 6.1: Configuration access with secret redaction
282pub async fn config_handler<A: DatabaseAdapter>(
283    State(state): State<AppState<A>>,
284) -> Result<Json<ApiResponse<AdminConfigResponse>>, ApiError> {
285    let mut config = HashMap::new();
286
287    // Get actual server configuration
288    if let Some(server_config) = state.server_config() {
289        // Safe configuration values - no secrets
290        config.insert("port".to_string(), server_config.port.to_string());
291        config.insert("host".to_string(), server_config.host.clone());
292
293        if let Some(workers) = server_config.workers {
294            config.insert("workers".to_string(), workers.to_string());
295        }
296
297        // TLS status (boolean only, paths are redacted)
298        config.insert("tls_enabled".to_string(), server_config.tls.is_some().to_string());
299
300        // Request limits
301        if let Some(limits) = &server_config.limits {
302            config.insert("max_request_size".to_string(), limits.max_request_size.clone());
303            config.insert("request_timeout".to_string(), limits.request_timeout.clone());
304            config.insert(
305                "max_concurrent_requests".to_string(),
306                limits.max_concurrent_requests.to_string(),
307            );
308            config.insert("max_queue_depth".to_string(), limits.max_queue_depth.to_string());
309        }
310
311        // Cache status
312        config.insert("cache_enabled".to_string(), state.cache().is_some().to_string());
313    } else {
314        // Minimal configuration if not available
315        config.insert("cache_enabled".to_string(), "false".to_string());
316    }
317
318    let response = AdminConfigResponse {
319        version: env!("CARGO_PKG_VERSION").to_string(),
320        config,
321    };
322
323    Ok(Json(ApiResponse {
324        status: "success".to_string(),
325        data:   response,
326    }))
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn test_reload_schema_request_empty_path() {
335        let request = ReloadSchemaRequest {
336            schema_path:   String::new(),
337            validate_only: false,
338        };
339
340        assert!(request.schema_path.is_empty());
341    }
342
343    #[test]
344    fn test_reload_schema_request_with_path() {
345        let request = ReloadSchemaRequest {
346            schema_path:   "/path/to/schema.json".to_string(),
347            validate_only: false,
348        };
349
350        assert!(!request.schema_path.is_empty());
351    }
352
353    #[test]
354    fn test_cache_clear_scope_validation() {
355        let valid_scopes = vec!["all", "entity", "pattern"];
356
357        for scope in valid_scopes {
358            let request = CacheClearRequest {
359                scope:       scope.to_string(),
360                entity_type: None,
361                pattern:     None,
362            };
363            assert_eq!(request.scope, scope);
364        }
365    }
366
367    #[test]
368    fn test_admin_config_response_has_version() {
369        let response = AdminConfigResponse {
370            version: "2.0.0-a1".to_string(),
371            config:  HashMap::new(),
372        };
373
374        assert!(!response.version.is_empty());
375    }
376
377    #[test]
378    fn test_reload_schema_response_success() {
379        let response = ReloadSchemaResponse {
380            success: true,
381            message: "Reloaded".to_string(),
382        };
383
384        assert!(response.success);
385    }
386
387    #[test]
388    fn test_reload_schema_response_failure() {
389        let response = ReloadSchemaResponse {
390            success: false,
391            message: "Failed to load".to_string(),
392        };
393
394        assert!(!response.success);
395    }
396
397    #[test]
398    fn test_cache_clear_response_counts_entries() {
399        let response = CacheClearResponse {
400            success:         true,
401            entries_cleared: 42,
402            message:         "Cleared".to_string(),
403        };
404
405        assert_eq!(response.entries_cleared, 42);
406    }
407
408    #[test]
409    fn test_cache_clear_request_entity_required_for_entity_scope() {
410        let request = CacheClearRequest {
411            scope:       "entity".to_string(),
412            entity_type: Some("User".to_string()),
413            pattern:     None,
414        };
415
416        assert_eq!(request.scope, "entity");
417        assert!(request.entity_type.is_some());
418    }
419
420    #[test]
421    fn test_cache_clear_request_pattern_required_for_pattern_scope() {
422        let request = CacheClearRequest {
423            scope:       "pattern".to_string(),
424            entity_type: None,
425            pattern:     Some("*_user".to_string()),
426        };
427
428        assert_eq!(request.scope, "pattern");
429        assert!(request.pattern.is_some());
430    }
431
432    #[test]
433    fn test_admin_config_response_sanitization_excludes_paths() {
434        // Phase 6.1: Configuration structure validates no paths are exposed
435        let response = AdminConfigResponse {
436            version: "2.0.0".to_string(),
437            config:  {
438                let mut m = HashMap::new();
439                m.insert("port".to_string(), "8000".to_string());
440                m.insert("host".to_string(), "0.0.0.0".to_string());
441                m.insert("tls_enabled".to_string(), "true".to_string());
442                m
443            },
444        };
445
446        assert_eq!(response.config.get("port"), Some(&"8000".to_string()));
447        assert_eq!(response.config.get("host"), Some(&"0.0.0.0".to_string()));
448        assert_eq!(response.config.get("tls_enabled"), Some(&"true".to_string()));
449        // Verify no cert_file or key_file keys (paths redacted)
450        assert!(!response.config.contains_key("cert_file"));
451        assert!(!response.config.contains_key("key_file"));
452    }
453
454    #[test]
455    fn test_admin_config_response_includes_limits() {
456        // Phase 6.1: Configuration includes operational limits
457        let response = AdminConfigResponse {
458            version: "2.0.0".to_string(),
459            config:  {
460                let mut m = HashMap::new();
461                m.insert("max_request_size".to_string(), "10MB".to_string());
462                m.insert("request_timeout".to_string(), "30s".to_string());
463                m.insert("max_concurrent_requests".to_string(), "1000".to_string());
464                m
465            },
466        };
467
468        assert!(response.config.contains_key("max_request_size"));
469        assert!(response.config.contains_key("request_timeout"));
470        assert!(response.config.contains_key("max_concurrent_requests"));
471    }
472
473    #[test]
474    fn test_cache_stats_response_structure() {
475        // Phase 5.4: Cache statistics structure
476        let response = CacheStatsResponse {
477            entries_count: 100,
478            cache_enabled: true,
479            ttl_secs:      60,
480            message:       "Cache statistics".to_string(),
481        };
482
483        assert_eq!(response.entries_count, 100);
484        assert!(response.cache_enabled);
485        assert_eq!(response.ttl_secs, 60);
486        assert!(!response.message.is_empty());
487    }
488
489    #[test]
490    fn test_reload_schema_request_validates_path() {
491        // Phase 6.2: Schema reload request validation
492        let request = ReloadSchemaRequest {
493            schema_path:   "/path/to/schema.json".to_string(),
494            validate_only: false,
495        };
496
497        assert!(!request.schema_path.is_empty());
498    }
499
500    #[test]
501    fn test_reload_schema_request_validate_only_flag() {
502        // Phase 6.2: Schema reload can run in validation-only mode
503        let request = ReloadSchemaRequest {
504            schema_path:   "/path/to/schema.json".to_string(),
505            validate_only: true,
506        };
507
508        assert!(request.validate_only);
509    }
510
511    #[test]
512    fn test_reload_schema_response_indicates_success() {
513        // Phase 6.2: Schema reload response structure
514        let response = ReloadSchemaResponse {
515            success: true,
516            message: "Schema reloaded".to_string(),
517        };
518
519        assert!(response.success);
520        assert!(!response.message.is_empty());
521    }
522}