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, Deserialize, Serialize)]
22pub struct ReloadSchemaRequest {
23 pub schema_path: String,
25 pub validate_only: bool,
27}
28
29#[derive(Debug, Serialize)]
31pub struct ReloadSchemaResponse {
32 pub success: bool,
34 pub message: String,
36}
37
38#[derive(Debug, Deserialize, Serialize)]
40pub struct CacheClearRequest {
41 pub scope: String,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub entity_type: Option<String>,
46 #[serde(skip_serializing_if = "Option::is_none")]
48 pub pattern: Option<String>,
49}
50
51#[derive(Debug, Serialize)]
53pub struct CacheClearResponse {
54 pub success: bool,
56 pub entries_cleared: usize,
58 pub message: String,
60}
61
62#[derive(Debug, Serialize)]
64pub struct AdminConfigResponse {
65 pub version: String,
67 pub config: HashMap<String, String>,
69}
70
71pub 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; if req.schema_path.is_empty() {
88 return Err(ApiError::validation_error("schema_path cannot be empty"));
89 }
90
91 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 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 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#[derive(Debug, Serialize)]
162pub struct CacheStatsResponse {
163 pub entries_count: usize,
165 pub cache_enabled: bool,
167 pub ttl_secs: u64,
169 pub message: String,
171}
172
173pub 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 #[cfg(not(feature = "arrow"))]
193 {
194 let _ = (state, req);
195 Err(ApiError::internal_error("Cache not configured"))
196 }
197
198 #[cfg(feature = "arrow")]
199 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 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
305pub 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, 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#[allow(clippy::branches_sharing_code)] pub 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 if let Some(server_config) = state.server_config() {
368 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 config.insert("tls_enabled".to_string(), server_config.tls.is_some().to_string());
378
379 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 #[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 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#[derive(Debug, Deserialize, Serialize)]
413pub struct ExplainRequest {
414 pub query: String,
416
417 #[serde(skip_serializing_if = "Option::is_none")]
422 pub variables: Option<serde_json::Value>,
423
424 #[serde(skip_serializing_if = "Option::is_none")]
426 pub limit: Option<u32>,
427
428 #[serde(skip_serializing_if = "Option::is_none")]
430 pub offset: Option<u32>,
431}
432
433pub 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
458pub 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 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 #[test]
700 fn test_reload_schema_request_carries_audit_fields() {
701 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 let _ = req.validate_only;
710 }
711
712 #[test]
713 fn test_cache_clear_request_carries_audit_fields() {
714 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}