1use 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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum CacheStatus {
27 Disabled,
29 #[deprecated(
32 since = "2.2.0",
33 note = "CachedDatabaseAdapter is now always wired when cache_enabled = true. \
34 Use `Active` or `Disabled` instead."
35 )]
36 RlsGuardOnly,
37 Active,
41}
42
43impl CacheStatus {
44 #[must_use]
50 #[deprecated(
51 since = "2.2.0",
52 note = "Use `AppState::adapter_cache_enabled` to determine the true cache state. \
53 This function returns `RlsGuardOnly` which is no longer accurate."
54 )]
55 pub const fn from_cache_enabled(cache_enabled: bool) -> Self {
56 #[allow(deprecated)] if cache_enabled {
58 Self::RlsGuardOnly
59 } else {
60 Self::Disabled
61 }
62 }
63}
64
65#[derive(Debug, Deserialize, Serialize)]
67pub struct ReloadSchemaRequest {
68 pub schema_path: String,
70 pub validate_only: bool,
72}
73
74#[derive(Debug, Serialize)]
76pub struct ReloadSchemaResponse {
77 pub success: bool,
79 pub message: String,
81}
82
83#[derive(Debug, Deserialize, Serialize)]
85pub struct CacheClearRequest {
86 pub scope: String,
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub entity_type: Option<String>,
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub pattern: Option<String>,
94}
95
96#[derive(Debug, Serialize)]
98pub struct CacheClearResponse {
99 pub success: bool,
101 pub entries_cleared: usize,
103 pub message: String,
105}
106
107#[derive(Debug, Serialize)]
109pub struct AdminConfigResponse {
110 pub version: String,
112 pub config: HashMap<String, String>,
114}
115
116pub async fn reload_schema_handler<A: DatabaseAdapter>(
128 State(state): State<AppState<A>>,
129 Json(req): Json<ReloadSchemaRequest>,
130) -> Result<Json<ApiResponse<ReloadSchemaResponse>>, ApiError> {
131 let _ = &state; if req.schema_path.is_empty() {
133 return Err(ApiError::validation_error("schema_path cannot be empty"));
134 }
135
136 let schema_json = fs::read_to_string(&req.schema_path)
138 .map_err(|e| ApiError::parse_error(format!("Failed to read schema file: {}", e)))?;
139
140 let _validated_schema = CompiledSchema::from_json(&schema_json)
142 .map_err(|e| ApiError::parse_error(format!("Invalid schema JSON: {}", e)))?;
143
144 if req.validate_only {
145 info!(
146 operation = "admin.reload_schema",
147 schema_path = %req.schema_path,
148 validate_only = true,
149 success = true,
150 "Admin: schema validation requested"
151 );
152 let response = ReloadSchemaResponse {
153 success: true,
154 message: "Schema validated successfully (not applied)".to_string(),
155 };
156 Ok(Json(ApiResponse {
157 status: "success".to_string(),
158 data: response,
159 }))
160 } else {
161 let start = std::time::Instant::now();
163 let schema_path = std::path::Path::new(&req.schema_path);
164
165 match state.reload_schema(schema_path).await {
166 Ok(()) => {
167 let duration_ms = start.elapsed().as_millis();
168 state
169 .metrics
170 .schema_reloads_total
171 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
172 info!(
173 operation = "admin.reload_schema",
174 schema_path = %req.schema_path,
175 duration_ms,
176 "Schema reloaded successfully"
177 );
178
179 let response = ReloadSchemaResponse {
180 success: true,
181 message: format!("Schema reloaded from {} in {duration_ms}ms", req.schema_path),
182 };
183 Ok(Json(ApiResponse {
184 status: "success".to_string(),
185 data: response,
186 }))
187 },
188 Err(e) => {
189 state
190 .metrics
191 .schema_reload_errors_total
192 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
193 error!(
194 operation = "admin.reload_schema",
195 schema_path = %req.schema_path,
196 error = %e,
197 "Schema reload failed"
198 );
199 Err(ApiError::internal_error(format!("Schema reload failed: {e}")))
200 },
201 }
202 }
203}
204
205#[derive(Debug, Serialize)]
207pub struct CacheStatsResponse {
208 pub entries_count: usize,
210 pub cache_enabled: bool,
212 pub ttl_secs: u64,
214 pub message: String,
216}
217
218pub async fn cache_clear_handler<A: DatabaseAdapter>(
233 State(state): State<AppState<A>>,
234 Json(req): Json<CacheClearRequest>,
235) -> Result<Json<ApiResponse<CacheClearResponse>>, ApiError> {
236 #[cfg(not(feature = "arrow"))]
238 {
239 let _ = (state, req);
240 Err(ApiError::internal_error("Cache not configured"))
241 }
242
243 #[cfg(feature = "arrow")]
244 match req.scope.as_str() {
246 "all" => {
247 if let Some(cache) = state.cache() {
248 let entries_before = cache.len();
249 cache.clear();
250 info!(
251 operation = "admin.cache_clear",
252 scope = "all",
253 entries_cleared = entries_before,
254 success = true,
255 "Admin: cache cleared (all entries)"
256 );
257 let response = CacheClearResponse {
258 success: true,
259 entries_cleared: entries_before,
260 message: format!("Cleared {} cache entries", entries_before),
261 };
262 Ok(Json(ApiResponse {
263 status: "success".to_string(),
264 data: response,
265 }))
266 } else {
267 Err(ApiError::internal_error("Cache not configured"))
268 }
269 },
270 "entity" => {
271 if req.entity_type.is_none() {
272 return Err(ApiError::validation_error(
273 "entity_type is required when scope is 'entity'",
274 ));
275 }
276
277 if let Some(cache) = state.cache() {
278 let entity_type = req.entity_type.as_ref().ok_or_else(|| {
279 ApiError::internal_error(
280 "entity_type was None after validation — this is a bug",
281 )
282 })?;
283 let view_name = format!("v_{}", entity_type.to_lowercase());
285 let entries_cleared = cache.invalidate_views(&[&view_name]);
286 info!(
287 operation = "admin.cache_clear",
288 scope = "entity",
289 entity_type = %entity_type,
290 entries_cleared,
291 success = true,
292 "Admin: cache cleared for entity"
293 );
294 let response = CacheClearResponse {
295 success: true,
296 entries_cleared,
297 message: format!(
298 "Cleared {} cache entries for entity type '{}'",
299 entries_cleared, entity_type
300 ),
301 };
302 Ok(Json(ApiResponse {
303 status: "success".to_string(),
304 data: response,
305 }))
306 } else {
307 Err(ApiError::internal_error("Cache not configured"))
308 }
309 },
310 "pattern" => {
311 if req.pattern.is_none() {
312 return Err(ApiError::validation_error(
313 "pattern is required when scope is 'pattern'",
314 ));
315 }
316
317 if let Some(cache) = state.cache() {
318 let pattern = req.pattern.as_ref().ok_or_else(|| {
319 ApiError::internal_error("pattern was None after validation — this is a bug")
320 })?;
321 let entries_cleared = cache.invalidate_pattern(pattern);
322 info!(
323 operation = "admin.cache_clear",
324 scope = "pattern",
325 %pattern,
326 entries_cleared,
327 success = true,
328 "Admin: cache cleared by pattern"
329 );
330 let response = CacheClearResponse {
331 success: true,
332 entries_cleared,
333 message: format!(
334 "Cleared {} cache entries matching pattern '{}'",
335 entries_cleared, pattern
336 ),
337 };
338 Ok(Json(ApiResponse {
339 status: "success".to_string(),
340 data: response,
341 }))
342 } else {
343 Err(ApiError::internal_error("Cache not configured"))
344 }
345 },
346 _ => Err(ApiError::validation_error("scope must be 'all', 'entity', or 'pattern'")),
347 }
348}
349
350pub async fn cache_stats_handler<A: DatabaseAdapter>(
360 State(state): State<AppState<A>>,
361) -> Result<Json<ApiResponse<CacheStatsResponse>>, ApiError> {
362 #[cfg(feature = "arrow")]
363 if let Some(cache) = state.cache() {
364 let response = CacheStatsResponse {
365 entries_count: cache.len(),
366 cache_enabled: true,
367 ttl_secs: 60, message: format!("Cache contains {} entries with 60-second TTL", cache.len()),
369 };
370 return Ok(Json(ApiResponse {
371 status: "success".to_string(),
372 data: response,
373 }));
374 }
375 {
376 let _ = state;
377 let response = CacheStatsResponse {
378 entries_count: 0,
379 cache_enabled: false,
380 ttl_secs: 0,
381 message: "Cache is not configured".to_string(),
382 };
383 Ok(Json(ApiResponse {
384 status: "success".to_string(),
385 data: response,
386 }))
387 }
388}
389
390#[allow(clippy::branches_sharing_code)] pub async fn config_handler<A: DatabaseAdapter>(
407 State(state): State<AppState<A>>,
408) -> Result<Json<ApiResponse<AdminConfigResponse>>, ApiError> {
409 let mut config = HashMap::new();
410
411 if let Some(server_config) = state.server_config() {
413 config.insert("port".to_string(), server_config.port.to_string());
415 config.insert("host".to_string(), server_config.host.clone());
416
417 if let Some(workers) = server_config.workers {
418 config.insert("workers".to_string(), workers.to_string());
419 }
420
421 config.insert("tls_enabled".to_string(), server_config.tls.is_some().to_string());
423
424 if let Some(limits) = &server_config.limits {
426 config.insert("max_request_size".to_string(), limits.max_request_size.clone());
427 config.insert("request_timeout".to_string(), limits.request_timeout.clone());
428 config.insert(
429 "max_concurrent_requests".to_string(),
430 limits.max_concurrent_requests.to_string(),
431 );
432 config.insert("max_queue_depth".to_string(), limits.max_queue_depth.to_string());
433 }
434
435 let cache_active = state.adapter_cache_enabled;
438
439 config.insert("cache_enabled".to_string(), cache_active.to_string());
440 let cache_status = if cache_active {
441 CacheStatus::Active
442 } else {
443 CacheStatus::Disabled
444 };
445 config.insert(
446 "cache_status".to_string(),
447 serde_json::to_string(&cache_status)
448 .unwrap_or_else(|_| "\"disabled\"".to_string())
449 .trim_matches('"')
450 .to_string(),
451 );
452 let _ = server_config; } else {
454 config.insert("cache_enabled".to_string(), "false".to_string());
456 config.insert("cache_status".to_string(), "disabled".to_string());
457 }
458
459 let response = AdminConfigResponse {
460 version: env!("CARGO_PKG_VERSION").to_string(),
461 config,
462 };
463
464 Ok(Json(ApiResponse {
465 status: "success".to_string(),
466 data: response,
467 }))
468}
469
470#[derive(Debug, Deserialize, Serialize)]
472pub struct ExplainRequest {
473 pub query: String,
475
476 #[serde(skip_serializing_if = "Option::is_none")]
481 pub variables: Option<serde_json::Value>,
482
483 #[serde(skip_serializing_if = "Option::is_none")]
485 pub limit: Option<u32>,
486
487 #[serde(skip_serializing_if = "Option::is_none")]
489 pub offset: Option<u32>,
490}
491
492pub async fn grafana_dashboard_handler<A: DatabaseAdapter>(
505 State(_state): State<AppState<A>>,
506) -> impl axum::response::IntoResponse {
507 const DASHBOARD_JSON: &str = include_str!("../../../resources/fraiseql-dashboard.json");
508
509 (
510 axum::http::StatusCode::OK,
511 [(axum::http::header::CONTENT_TYPE, "application/json")],
512 DASHBOARD_JSON,
513 )
514}
515
516pub async fn explain_handler<A: DatabaseAdapter + 'static>(
529 State(state): State<AppState<A>>,
530 Json(req): Json<ExplainRequest>,
531) -> Result<Json<ApiResponse<fraiseql_core::runtime::ExplainResult>>, ApiError> {
532 if req.query.is_empty() {
533 return Err(ApiError::validation_error("query cannot be empty"));
534 }
535
536 state
537 .executor()
538 .explain(&req.query, req.variables.as_ref(), req.limit, req.offset)
539 .await
540 .map(ApiResponse::success)
541 .map_err(|e| match e {
542 fraiseql_core::error::FraiseQLError::Validation { message, .. } => {
543 ApiError::validation_error(message)
544 },
545 fraiseql_core::error::FraiseQLError::Unsupported { message } => {
546 ApiError::validation_error(format!("Unsupported: {message}"))
547 },
548 other => ApiError::internal_error(other.to_string()),
549 })
550}
551
552#[cfg(test)]
553#[allow(clippy::unwrap_used)] mod tests {
555 use super::*;
556
557 #[test]
560 #[allow(deprecated)] fn cache_status_serializes_to_snake_case() {
562 let json = serde_json::to_string(&CacheStatus::RlsGuardOnly).unwrap();
563 assert_eq!(json, "\"rls_guard_only\"");
564
565 let json = serde_json::to_string(&CacheStatus::Disabled).unwrap();
566 assert_eq!(json, "\"disabled\"");
567
568 let json = serde_json::to_string(&CacheStatus::Active).unwrap();
569 assert_eq!(json, "\"active\"");
570 }
571
572 #[test]
573 #[allow(deprecated)] fn cache_status_from_config_enabled() {
575 assert_eq!(CacheStatus::from_cache_enabled(true), CacheStatus::RlsGuardOnly);
576 }
577
578 #[test]
579 #[allow(deprecated)] fn cache_status_from_config_disabled() {
581 assert_eq!(CacheStatus::from_cache_enabled(false), CacheStatus::Disabled);
582 }
583
584 #[test]
585 #[allow(deprecated)] fn cache_status_deserializes_from_snake_case() {
587 let status: CacheStatus = serde_json::from_str("\"rls_guard_only\"").unwrap();
588 assert_eq!(status, CacheStatus::RlsGuardOnly);
589
590 let status: CacheStatus = serde_json::from_str("\"active\"").unwrap();
591 assert_eq!(status, CacheStatus::Active);
592 }
593
594 #[test]
597 fn test_grafana_dashboard_is_valid_json() {
598 let parsed: serde_json::Value =
599 serde_json::from_str(include_str!("../../../resources/fraiseql-dashboard.json"))
600 .expect("fraiseql-dashboard.json must be valid JSON");
601
602 assert_eq!(parsed["title"], "FraiseQL Performance");
603 assert_eq!(parsed["uid"], "fraiseql-perf-v1");
604 assert!(
605 parsed["panels"].as_array().map_or(0, |p| p.len()) >= 10,
606 "dashboard should have at least 10 panels"
607 );
608 }
609
610 #[test]
611 fn test_reload_schema_request_empty_path() {
612 let request = ReloadSchemaRequest {
613 schema_path: String::new(),
614 validate_only: false,
615 };
616
617 assert!(request.schema_path.is_empty());
618 }
619
620 #[test]
621 fn test_reload_schema_request_with_path() {
622 let request = ReloadSchemaRequest {
623 schema_path: "/path/to/schema.json".to_string(),
624 validate_only: false,
625 };
626
627 assert!(!request.schema_path.is_empty());
628 }
629
630 #[test]
631 fn test_cache_clear_scope_validation() {
632 let valid_scopes = vec!["all", "entity", "pattern"];
633
634 for scope in valid_scopes {
635 let request = CacheClearRequest {
636 scope: scope.to_string(),
637 entity_type: None,
638 pattern: None,
639 };
640 assert_eq!(request.scope, scope);
641 }
642 }
643
644 #[test]
645 fn test_admin_config_response_has_version() {
646 let response = AdminConfigResponse {
647 version: "2.0.0-a1".to_string(),
648 config: HashMap::new(),
649 };
650
651 assert!(!response.version.is_empty());
652 }
653
654 #[test]
655 fn test_reload_schema_response_success() {
656 let response = ReloadSchemaResponse {
657 success: true,
658 message: "Reloaded".to_string(),
659 };
660
661 assert!(response.success);
662 }
663
664 #[test]
665 fn test_reload_schema_response_failure() {
666 let response = ReloadSchemaResponse {
667 success: false,
668 message: "Failed to load".to_string(),
669 };
670
671 assert!(!response.success);
672 }
673
674 #[test]
675 fn test_cache_clear_response_counts_entries() {
676 let response = CacheClearResponse {
677 success: true,
678 entries_cleared: 42,
679 message: "Cleared".to_string(),
680 };
681
682 assert_eq!(response.entries_cleared, 42);
683 }
684
685 #[test]
686 fn test_cache_clear_request_entity_required_for_entity_scope() {
687 let request = CacheClearRequest {
688 scope: "entity".to_string(),
689 entity_type: Some("User".to_string()),
690 pattern: None,
691 };
692
693 assert_eq!(request.scope, "entity");
694 assert_eq!(request.entity_type.as_deref(), Some("User"));
695 }
696
697 #[test]
698 fn test_cache_clear_request_pattern_required_for_pattern_scope() {
699 let request = CacheClearRequest {
700 scope: "pattern".to_string(),
701 entity_type: None,
702 pattern: Some("*_user".to_string()),
703 };
704
705 assert_eq!(request.scope, "pattern");
706 assert_eq!(request.pattern.as_deref(), Some("*_user"));
707 }
708
709 #[test]
710 fn test_admin_config_response_sanitization_excludes_paths() {
711 let response = AdminConfigResponse {
712 version: "2.0.0".to_string(),
713 config: {
714 let mut m = HashMap::new();
715 m.insert("port".to_string(), "8000".to_string());
716 m.insert("host".to_string(), "0.0.0.0".to_string());
717 m.insert("tls_enabled".to_string(), "true".to_string());
718 m
719 },
720 };
721
722 assert_eq!(response.config.get("port"), Some(&"8000".to_string()));
723 assert_eq!(response.config.get("host"), Some(&"0.0.0.0".to_string()));
724 assert_eq!(response.config.get("tls_enabled"), Some(&"true".to_string()));
725 assert!(!response.config.contains_key("cert_file"));
727 assert!(!response.config.contains_key("key_file"));
728 }
729
730 #[test]
731 fn test_admin_config_response_includes_limits() {
732 let response = AdminConfigResponse {
733 version: "2.0.0".to_string(),
734 config: {
735 let mut m = HashMap::new();
736 m.insert("max_request_size".to_string(), "10MB".to_string());
737 m.insert("request_timeout".to_string(), "30s".to_string());
738 m.insert("max_concurrent_requests".to_string(), "1000".to_string());
739 m
740 },
741 };
742
743 assert!(response.config.contains_key("max_request_size"));
744 assert!(response.config.contains_key("request_timeout"));
745 assert!(response.config.contains_key("max_concurrent_requests"));
746 }
747
748 #[test]
749 fn test_cache_stats_response_structure() {
750 let response = CacheStatsResponse {
751 entries_count: 100,
752 cache_enabled: true,
753 ttl_secs: 60,
754 message: "Cache statistics".to_string(),
755 };
756
757 assert_eq!(response.entries_count, 100);
758 assert!(response.cache_enabled);
759 assert_eq!(response.ttl_secs, 60);
760 assert!(!response.message.is_empty());
761 }
762
763 #[test]
764 fn test_reload_schema_request_validates_path() {
765 let request = ReloadSchemaRequest {
766 schema_path: "/path/to/schema.json".to_string(),
767 validate_only: false,
768 };
769
770 assert!(!request.schema_path.is_empty());
771 }
772
773 #[test]
774 fn test_reload_schema_request_validate_only_flag() {
775 let request = ReloadSchemaRequest {
776 schema_path: "/path/to/schema.json".to_string(),
777 validate_only: true,
778 };
779
780 assert!(request.validate_only);
781 }
782
783 #[test]
784 fn test_reload_schema_response_indicates_success() {
785 let response = ReloadSchemaResponse {
786 success: true,
787 message: "Schema reloaded".to_string(),
788 };
789
790 assert!(response.success);
791 assert!(!response.message.is_empty());
792 }
793
794 #[test]
797 fn test_reload_schema_request_carries_audit_fields() {
798 let req = ReloadSchemaRequest {
801 schema_path: "/var/run/fraiseql/schema.compiled.json".to_string(),
802 validate_only: false,
803 };
804 assert!(!req.schema_path.is_empty(), "schema_path must be present for audit log");
805 let _ = req.validate_only;
807 }
808
809 #[test]
810 fn test_cache_clear_request_carries_audit_fields() {
811 let all_req = CacheClearRequest {
814 scope: "all".to_string(),
815 entity_type: None,
816 pattern: None,
817 };
818 assert_eq!(all_req.scope, "all");
819
820 let entity_req = CacheClearRequest {
821 scope: "entity".to_string(),
822 entity_type: Some("Order".to_string()),
823 pattern: None,
824 };
825 assert!(
826 entity_req.entity_type.is_some(),
827 "entity scope must carry entity_type for audit"
828 );
829
830 let pattern_req = CacheClearRequest {
831 scope: "pattern".to_string(),
832 entity_type: None,
833 pattern: Some("v_order*".to_string()),
834 };
835 assert!(pattern_req.pattern.is_some(), "pattern scope must carry pattern for audit");
836 }
837}