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")]
26#[non_exhaustive]
27pub enum CacheStatus {
28 Disabled,
30 #[deprecated(
33 since = "2.2.0",
34 note = "CachedDatabaseAdapter is now always wired when cache_enabled = true. \
35 Use `Active` or `Disabled` instead."
36 )]
37 RlsGuardOnly,
38 Active,
42}
43
44impl CacheStatus {
45 #[must_use]
51 #[deprecated(
52 since = "2.2.0",
53 note = "Use `AppState::adapter_cache_enabled` to determine the true cache state. \
54 This function returns `RlsGuardOnly` which is no longer accurate."
55 )]
56 pub const fn from_cache_enabled(cache_enabled: bool) -> Self {
57 #[allow(deprecated)] if cache_enabled {
59 Self::RlsGuardOnly
60 } else {
61 Self::Disabled
62 }
63 }
64}
65
66#[derive(Debug, Deserialize, Serialize)]
68pub struct ReloadSchemaRequest {
69 pub schema_path: String,
71 pub validate_only: bool,
73}
74
75#[derive(Debug, Serialize)]
77pub struct ReloadSchemaResponse {
78 pub success: bool,
80 pub message: String,
82}
83
84#[derive(Debug, Deserialize, Serialize)]
86pub struct CacheClearRequest {
87 pub scope: String,
89 #[serde(skip_serializing_if = "Option::is_none")]
91 pub entity_type: Option<String>,
92 #[serde(skip_serializing_if = "Option::is_none")]
94 pub pattern: Option<String>,
95}
96
97#[derive(Debug, Serialize)]
99pub struct CacheClearResponse {
100 pub success: bool,
102 pub entries_cleared: usize,
104 pub message: String,
106}
107
108#[derive(Debug, Serialize)]
110pub struct AdminConfigResponse {
111 pub version: String,
113 pub config: HashMap<String, String>,
115}
116
117pub fn validate_schema_path(
131 path: &str,
132 allowed_base: Option<&std::path::Path>,
133) -> Result<(), ApiError> {
134 use std::path::{Component, Path};
135
136 let p = Path::new(path);
137
138 if p.components().any(|c| c == Component::ParentDir) {
140 return Err(ApiError::validation_error(
141 "schema_path must not contain '..' (path traversal rejected)",
142 ));
143 }
144
145 if let Some(base) = allowed_base {
147 let candidate = if p.is_absolute() {
149 p.to_path_buf()
150 } else {
151 base.join(p)
152 };
153
154 if !candidate.starts_with(base) {
158 return Err(ApiError::validation_error(
159 "schema_path is outside the allowed base directory",
160 ));
161 }
162 }
163
164 Ok(())
165}
166
167pub async fn reload_schema_handler<A: DatabaseAdapter>(
180 State(state): State<AppState<A>>,
181 Json(req): Json<ReloadSchemaRequest>,
182) -> Result<Json<ApiResponse<ReloadSchemaResponse>>, ApiError> {
183 let _ = &state; if req.schema_path.is_empty() {
185 return Err(ApiError::validation_error("schema_path cannot be empty"));
186 }
187
188 validate_schema_path(&req.schema_path, None)?;
190
191 let schema_json = fs::read_to_string(&req.schema_path)
193 .map_err(|e| ApiError::parse_error(format!("Failed to read schema file: {}", e)))?;
194
195 let _validated_schema = CompiledSchema::from_json(&schema_json, false)
197 .map_err(|e| ApiError::parse_error(format!("Invalid schema JSON: {}", e)))?;
198
199 if req.validate_only {
200 info!(
201 operation = "admin.reload_schema",
202 schema_path = %req.schema_path,
203 validate_only = true,
204 success = true,
205 "Admin: schema validation requested"
206 );
207 let response = ReloadSchemaResponse {
208 success: true,
209 message: "Schema validated successfully (not applied)".to_string(),
210 };
211 Ok(Json(ApiResponse {
212 status: "success".to_string(),
213 data: response,
214 }))
215 } else {
216 let start = std::time::Instant::now();
220
221 match state.reload_schema_from_json(&schema_json).await {
222 Ok(()) => {
223 let duration_ms = start.elapsed().as_millis();
224 state
225 .metrics
226 .schema_reloads_total
227 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
228 info!(
229 operation = "admin.reload_schema",
230 schema_path = %req.schema_path,
231 duration_ms,
232 "Schema reloaded successfully"
233 );
234
235 let response = ReloadSchemaResponse {
236 success: true,
237 message: format!("Schema reloaded from {} in {duration_ms}ms", req.schema_path),
238 };
239 Ok(Json(ApiResponse {
240 status: "success".to_string(),
241 data: response,
242 }))
243 },
244 Err(e) => {
245 state
246 .metrics
247 .schema_reload_errors_total
248 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
249 error!(
250 operation = "admin.reload_schema",
251 schema_path = %req.schema_path,
252 error = %e,
253 "Schema reload failed"
254 );
255 Err(ApiError::internal_error(format!("Schema reload failed: {e}")))
256 },
257 }
258 }
259}
260
261#[derive(Debug, Serialize)]
263pub struct CacheStatsResponse {
264 pub entries_count: usize,
266 pub cache_enabled: bool,
268 pub ttl_secs: u64,
270 pub message: String,
272}
273
274pub async fn cache_clear_handler<A: DatabaseAdapter>(
289 State(state): State<AppState<A>>,
290 Json(req): Json<CacheClearRequest>,
291) -> Result<Json<ApiResponse<CacheClearResponse>>, ApiError> {
292 #[cfg(not(feature = "arrow"))]
294 {
295 let _ = (state, req);
296 Err(ApiError::internal_error("Cache not configured"))
297 }
298
299 #[cfg(feature = "arrow")]
300 match req.scope.as_str() {
302 "all" => {
303 if let Some(cache) = state.cache() {
304 let entries_before = cache.len();
305 cache.clear();
306 info!(
307 operation = "admin.cache_clear",
308 scope = "all",
309 entries_cleared = entries_before,
310 success = true,
311 "Admin: cache cleared (all entries)"
312 );
313 let response = CacheClearResponse {
314 success: true,
315 entries_cleared: entries_before,
316 message: format!("Cleared {} cache entries", entries_before),
317 };
318 Ok(Json(ApiResponse {
319 status: "success".to_string(),
320 data: response,
321 }))
322 } else {
323 Err(ApiError::internal_error("Cache not configured"))
324 }
325 },
326 "entity" => {
327 if req.entity_type.is_none() {
328 return Err(ApiError::validation_error(
329 "entity_type is required when scope is 'entity'",
330 ));
331 }
332
333 if let Some(cache) = state.cache() {
334 let entity_type = req.entity_type.as_ref().ok_or_else(|| {
335 ApiError::internal_error(
336 "entity_type was None after validation — this is a bug",
337 )
338 })?;
339 let view_name = format!("v_{}", entity_type.to_lowercase());
341 let entries_cleared = cache.invalidate_views(&[&view_name]);
342 info!(
343 operation = "admin.cache_clear",
344 scope = "entity",
345 entity_type = %entity_type,
346 entries_cleared,
347 success = true,
348 "Admin: cache cleared for entity"
349 );
350 let response = CacheClearResponse {
351 success: true,
352 entries_cleared,
353 message: format!(
354 "Cleared {} cache entries for entity type '{}'",
355 entries_cleared, entity_type
356 ),
357 };
358 Ok(Json(ApiResponse {
359 status: "success".to_string(),
360 data: response,
361 }))
362 } else {
363 Err(ApiError::internal_error("Cache not configured"))
364 }
365 },
366 "pattern" => {
367 if req.pattern.is_none() {
368 return Err(ApiError::validation_error(
369 "pattern is required when scope is 'pattern'",
370 ));
371 }
372
373 if let Some(cache) = state.cache() {
374 let pattern = req.pattern.as_ref().ok_or_else(|| {
375 ApiError::internal_error("pattern was None after validation — this is a bug")
376 })?;
377 let entries_cleared = cache.invalidate_pattern(pattern);
378 info!(
379 operation = "admin.cache_clear",
380 scope = "pattern",
381 %pattern,
382 entries_cleared,
383 success = true,
384 "Admin: cache cleared by pattern"
385 );
386 let response = CacheClearResponse {
387 success: true,
388 entries_cleared,
389 message: format!(
390 "Cleared {} cache entries matching pattern '{}'",
391 entries_cleared, pattern
392 ),
393 };
394 Ok(Json(ApiResponse {
395 status: "success".to_string(),
396 data: response,
397 }))
398 } else {
399 Err(ApiError::internal_error("Cache not configured"))
400 }
401 },
402 _ => Err(ApiError::validation_error("scope must be 'all', 'entity', or 'pattern'")),
403 }
404}
405
406pub async fn cache_stats_handler<A: DatabaseAdapter>(
416 State(state): State<AppState<A>>,
417) -> Result<Json<ApiResponse<CacheStatsResponse>>, ApiError> {
418 #[cfg(feature = "arrow")]
419 if let Some(cache) = state.cache() {
420 let response = CacheStatsResponse {
421 entries_count: cache.len(),
422 cache_enabled: true,
423 ttl_secs: 60, message: format!("Cache contains {} entries with 60-second TTL", cache.len()),
425 };
426 return Ok(Json(ApiResponse {
427 status: "success".to_string(),
428 data: response,
429 }));
430 }
431 {
432 let _ = state;
433 let response = CacheStatsResponse {
434 entries_count: 0,
435 cache_enabled: false,
436 ttl_secs: 0,
437 message: "Cache is not configured".to_string(),
438 };
439 Ok(Json(ApiResponse {
440 status: "success".to_string(),
441 data: response,
442 }))
443 }
444}
445
446#[allow(clippy::branches_sharing_code)] pub async fn config_handler<A: DatabaseAdapter>(
463 State(state): State<AppState<A>>,
464) -> Result<Json<ApiResponse<AdminConfigResponse>>, ApiError> {
465 let mut config = HashMap::new();
466
467 if let Some(server_config) = state.server_config() {
469 config.insert("port".to_string(), server_config.port.to_string());
471 config.insert("host".to_string(), server_config.host.clone());
472
473 if let Some(workers) = server_config.workers {
474 config.insert("workers".to_string(), workers.to_string());
475 }
476
477 config.insert("tls_enabled".to_string(), server_config.tls.is_some().to_string());
479
480 if let Some(limits) = &server_config.limits {
482 config.insert("max_request_size".to_string(), limits.max_request_size.clone());
483 config.insert("request_timeout".to_string(), limits.request_timeout.clone());
484 config.insert(
485 "max_concurrent_requests".to_string(),
486 limits.max_concurrent_requests.to_string(),
487 );
488 config.insert("max_queue_depth".to_string(), limits.max_queue_depth.to_string());
489 }
490
491 let cache_active = state.adapter_cache_enabled;
494
495 config.insert("cache_enabled".to_string(), cache_active.to_string());
496 let cache_status = if cache_active {
497 CacheStatus::Active
498 } else {
499 CacheStatus::Disabled
500 };
501 config.insert(
502 "cache_status".to_string(),
503 serde_json::to_string(&cache_status)
504 .unwrap_or_else(|_| "\"disabled\"".to_string())
505 .trim_matches('"')
506 .to_string(),
507 );
508 let _ = server_config; } else {
510 config.insert("cache_enabled".to_string(), "false".to_string());
512 config.insert("cache_status".to_string(), "disabled".to_string());
513 }
514
515 let response = AdminConfigResponse {
516 version: env!("CARGO_PKG_VERSION").to_string(),
517 config,
518 };
519
520 Ok(Json(ApiResponse {
521 status: "success".to_string(),
522 data: response,
523 }))
524}
525
526#[derive(Debug, Deserialize, Serialize)]
528pub struct ExplainRequest {
529 pub query: String,
531
532 #[serde(skip_serializing_if = "Option::is_none")]
537 pub variables: Option<serde_json::Value>,
538
539 #[serde(skip_serializing_if = "Option::is_none")]
541 pub limit: Option<u32>,
542
543 #[serde(skip_serializing_if = "Option::is_none")]
545 pub offset: Option<u32>,
546}
547
548pub async fn grafana_dashboard_handler<A: DatabaseAdapter>(
561 State(_state): State<AppState<A>>,
562) -> impl axum::response::IntoResponse {
563 const DASHBOARD_JSON: &str = include_str!("../../../resources/fraiseql-dashboard.json");
564
565 (
566 axum::http::StatusCode::OK,
567 [(axum::http::header::CONTENT_TYPE, "application/json")],
568 DASHBOARD_JSON,
569 )
570}
571
572pub async fn explain_handler<A: DatabaseAdapter + 'static>(
585 State(state): State<AppState<A>>,
586 Json(req): Json<ExplainRequest>,
587) -> Result<Json<ApiResponse<fraiseql_core::runtime::ExplainResult>>, ApiError> {
588 if req.query.is_empty() {
589 return Err(ApiError::validation_error("query cannot be empty"));
590 }
591
592 state
593 .executor()
594 .explain(&req.query, req.variables.as_ref(), req.limit, req.offset)
595 .await
596 .map(ApiResponse::success)
597 .map_err(|e| match e {
598 fraiseql_core::error::FraiseQLError::Validation { message, .. } => {
599 ApiError::validation_error(message)
600 },
601 fraiseql_core::error::FraiseQLError::Unsupported { message } => {
602 ApiError::validation_error(format!("Unsupported: {message}"))
603 },
604 other => ApiError::internal_error(other.to_string()),
605 })
606}