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