use std::{collections::HashMap, fs};
use axum::{Json, extract::State};
use fraiseql_core::schema::CompiledSchema;
use serde::{Deserialize, Serialize};
use tracing::{error, info};
use crate::routes::{
api::types::{ApiError, ApiResponse},
graphql::AppState,
};
const QUERY_RESULT_CACHE: &str = "query_result";
#[cfg(feature = "arrow")]
const ARROW_FLIGHT_CACHE: &str = "arrow_flight";
#[cfg(feature = "arrow")]
const ARROW_FLIGHT_CACHE_TTL_SECS: u64 = 60;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum CacheStatus {
Disabled,
#[deprecated(
since = "2.2.0",
note = "CachedDatabaseAdapter is now always wired when cache_enabled = true. \
Use `Active` or `Disabled` instead."
)]
RlsGuardOnly,
Active,
}
impl CacheStatus {
#[must_use]
#[deprecated(
since = "2.2.0",
note = "Use `AppState::adapter_cache_enabled` to determine the true cache state. \
This function returns `RlsGuardOnly` which is no longer accurate."
)]
pub const fn from_cache_enabled(cache_enabled: bool) -> Self {
#[allow(deprecated)] if cache_enabled {
Self::RlsGuardOnly
} else {
Self::Disabled
}
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ReloadSchemaRequest {
pub schema_path: String,
pub validate_only: bool,
}
#[derive(Debug, Serialize)]
pub struct ReloadSchemaResponse {
pub success: bool,
pub message: String,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct CacheClearRequest {
pub scope: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub entity_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub pattern: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct CacheClearResponse {
pub success: bool,
pub entries_cleared: usize,
pub caches: Vec<CacheOperationResult>,
pub message: String,
}
#[derive(Debug, Serialize)]
pub struct CacheOperationResult {
pub cache: &'static str,
pub configured: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub entries_cleared: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct AdminConfigResponse {
pub version: String,
pub config: HashMap<String, String>,
}
pub fn validate_schema_path(
path: &str,
allowed_base: Option<&std::path::Path>,
) -> Result<(), ApiError> {
use std::path::{Component, Path};
let p = Path::new(path);
if p.components().any(|c| c == Component::ParentDir) {
return Err(ApiError::validation_error(
"schema_path must not contain '..' (path traversal rejected)",
));
}
if let Some(base) = allowed_base {
let candidate = if p.is_absolute() {
p.to_path_buf()
} else {
base.join(p)
};
if !candidate.starts_with(base) {
return Err(ApiError::validation_error(
"schema_path is outside the allowed base directory",
));
}
}
Ok(())
}
pub async fn reload_schema_handler(
State(state): State<AppState>,
Json(req): Json<ReloadSchemaRequest>,
) -> Result<Json<ApiResponse<ReloadSchemaResponse>>, ApiError> {
let _ = &state; if req.schema_path.is_empty() {
return Err(ApiError::validation_error("schema_path cannot be empty"));
}
validate_schema_path(&req.schema_path, None)?;
let schema_json = fs::read_to_string(&req.schema_path)
.map_err(|e| ApiError::parse_error(format!("Failed to read schema file: {}", e)))?;
let _validated_schema = CompiledSchema::from_json(&schema_json, false)
.map_err(|e| ApiError::parse_error(format!("Invalid schema JSON: {}", e)))?;
if req.validate_only {
info!(
operation = "admin.reload_schema",
schema_path = %req.schema_path,
validate_only = true,
success = true,
"Admin: schema validation requested"
);
let response = ReloadSchemaResponse {
success: true,
message: "Schema validated successfully (not applied)".to_string(),
};
Ok(Json(ApiResponse {
status: "success".to_string(),
data: response,
}))
} else {
let start = std::time::Instant::now();
match state.reload_schema_from_json(&schema_json).await {
Ok(()) => {
let duration_ms = start.elapsed().as_millis();
state
.metrics
.schema_reloads_total
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
info!(
operation = "admin.reload_schema",
schema_path = %req.schema_path,
duration_ms,
"Schema reloaded successfully"
);
let response = ReloadSchemaResponse {
success: true,
message: format!("Schema reloaded from {} in {duration_ms}ms", req.schema_path),
};
Ok(Json(ApiResponse {
status: "success".to_string(),
data: response,
}))
},
Err(e) => {
state
.metrics
.schema_reload_errors_total
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
error!(
operation = "admin.reload_schema",
schema_path = %req.schema_path,
error = %e,
"Schema reload failed"
);
Err(ApiError::internal_error(format!("Schema reload failed: {e}")))
},
}
}
}
#[derive(Debug, Serialize)]
pub struct CacheStatsResponse {
pub caches: Vec<CacheStatsEntry>,
pub message: String,
}
#[derive(Debug, Serialize)]
pub struct CacheStatsEntry {
pub cache: &'static str,
pub configured: bool,
pub entries_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub hits: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub misses: Option<u64>,
pub ttl_secs: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_entries: Option<usize>,
}
pub async fn cache_clear_handler(
State(state): State<AppState>,
Json(req): Json<CacheClearRequest>,
) -> Result<Json<ApiResponse<CacheClearResponse>>, ApiError> {
match req.scope.as_str() {
"entity" if req.entity_type.is_none() => {
return Err(ApiError::validation_error(
"entity_type is required when scope is 'entity'",
));
},
"pattern" if req.pattern.is_none() => {
return Err(ApiError::validation_error("pattern is required when scope is 'pattern'"));
},
"all" | "entity" | "pattern" => {},
_ => {
return Err(ApiError::validation_error("scope must be 'all', 'entity', or 'pattern'"));
},
}
#[cfg_attr(not(feature = "arrow"), allow(unused_mut))]
let mut caches = vec![clear_query_result_cache(&state, &req).await?];
#[cfg(feature = "arrow")]
caches.push(clear_arrow_flight_cache(&state, &req));
let entries_cleared: usize = caches.iter().filter_map(|c| c.entries_cleared).sum();
let served: Vec<&str> =
caches.iter().filter(|c| c.entries_cleared.is_some()).map(|c| c.cache).collect();
info!(
operation = "admin.cache_clear",
scope = %req.scope,
entries_cleared,
caches = ?served,
success = true,
"Admin: cache cleared"
);
let message = if served.is_empty() {
format!("No cache on this server can serve scope '{}'", req.scope)
} else {
format!("Cleared {entries_cleared} entries from {}", served.join(", "))
};
Ok(Json(ApiResponse {
status: "success".to_string(),
data: CacheClearResponse {
success: true,
entries_cleared,
caches,
message,
},
}))
}
async fn clear_query_result_cache(
state: &AppState,
req: &CacheClearRequest,
) -> Result<CacheOperationResult, ApiError> {
let executor = state.executor();
if executor.result_cache_stats().is_none() {
return Ok(CacheOperationResult {
cache: QUERY_RESULT_CACHE,
configured: false,
entries_cleared: None,
note: Some(
"the query result cache is not active (cache_enabled = false)".to_string(),
),
});
}
let cleared = match req.scope.as_str() {
"all" => executor
.clear_result_cache()
.await
.map_err(|e| ApiError::internal_error(format!("Cache clear failed: {e}")))?,
"entity" => {
let entity_type = req.entity_type.as_deref().unwrap_or_default();
let Some(view) = view_of_entity_type(state, entity_type) else {
return Ok(CacheOperationResult {
cache: QUERY_RESULT_CACHE,
configured: true,
entries_cleared: None,
note: Some(format!(
"no type named '{entity_type}' in the compiled schema, or it declares \
no sql_source"
)),
});
};
let evicted = executor
.invalidate_views(&[fraiseql_core::cache::ViewName::from(view.as_str())])
.await
.map_err(|e| ApiError::internal_error(format!("Cache clear failed: {e}")))?;
Some(usize::try_from(evicted).unwrap_or(usize::MAX))
},
_ => {
return Ok(CacheOperationResult {
cache: QUERY_RESULT_CACHE,
configured: true,
entries_cleared: None,
note: Some(
"the query result cache is keyed by hash and cannot be globbed; use \
scope 'all' or 'entity'"
.to_string(),
),
});
},
};
Ok(CacheOperationResult {
cache: QUERY_RESULT_CACHE,
configured: true,
entries_cleared: cleared,
note: None,
})
}
#[cfg(feature = "arrow")]
fn clear_arrow_flight_cache(state: &AppState, req: &CacheClearRequest) -> CacheOperationResult {
let Some(cache) = state.cache() else {
return CacheOperationResult {
cache: ARROW_FLIGHT_CACHE,
configured: false,
entries_cleared: None,
note: Some("no Arrow Flight service is configured".to_string()),
};
};
let cleared = match req.scope.as_str() {
"all" => {
let before = cache.len();
cache.clear();
before
},
"entity" => {
let entity_type = req.entity_type.as_deref().unwrap_or_default();
let view = view_of_entity_type(state, entity_type)
.unwrap_or_else(|| format!("v_{}", entity_type.to_lowercase()));
cache.invalidate_views(&[&view])
},
_ => cache.invalidate_pattern(req.pattern.as_deref().unwrap_or_default()),
};
CacheOperationResult {
cache: ARROW_FLIGHT_CACHE,
configured: true,
entries_cleared: Some(cleared),
note: None,
}
}
fn view_of_entity_type(state: &AppState, entity_type: &str) -> Option<String> {
state
.executor()
.schema()
.types
.iter()
.find(|t| t.name == entity_type)
.map(|t| t.sql_source.as_str().to_string())
.filter(|source| !source.is_empty())
}
pub async fn cache_stats_handler(
State(state): State<AppState>,
) -> Result<Json<ApiResponse<CacheStatsResponse>>, ApiError> {
#[cfg_attr(not(feature = "arrow"), allow(unused_mut))]
let mut caches = vec![state.executor().result_cache_stats().map_or(
CacheStatsEntry {
cache: QUERY_RESULT_CACHE,
configured: false,
entries_count: 0,
hits: None,
misses: None,
ttl_secs: 0,
max_entries: None,
},
|s| CacheStatsEntry {
cache: QUERY_RESULT_CACHE,
configured: true,
entries_count: s.entries,
hits: Some(s.hits),
misses: Some(s.misses),
ttl_secs: s.ttl_seconds,
max_entries: Some(s.max_entries),
},
)];
#[cfg(feature = "arrow")]
caches.push(state.cache().map_or(
CacheStatsEntry {
cache: ARROW_FLIGHT_CACHE,
configured: false,
entries_count: 0,
hits: None,
misses: None,
ttl_secs: 0,
max_entries: None,
},
|cache| CacheStatsEntry {
cache: ARROW_FLIGHT_CACHE,
configured: true,
entries_count: cache.len(),
hits: None,
misses: None,
ttl_secs: ARROW_FLIGHT_CACHE_TTL_SECS,
max_entries: None,
},
));
let configured: Vec<&str> = caches.iter().filter(|c| c.configured).map(|c| c.cache).collect();
let message = if configured.is_empty() {
"No cache is configured on this server".to_string()
} else {
format!("Configured cache(s): {}", configured.join(", "))
};
Ok(Json(ApiResponse {
status: "success".to_string(),
data: CacheStatsResponse { caches, message },
}))
}
pub async fn config_handler(
State(state): State<AppState>,
) -> Result<Json<ApiResponse<AdminConfigResponse>>, ApiError> {
let mut config = HashMap::new();
let cache_active = state.adapter_cache_enabled;
config.insert("cache_enabled".to_string(), cache_active.to_string());
let cache_status = if cache_active {
CacheStatus::Active
} else {
CacheStatus::Disabled
};
config.insert(
"cache_status".to_string(),
serde_json::to_string(&cache_status)
.unwrap_or_else(|_| "\"disabled\"".to_string())
.trim_matches('"')
.to_string(),
);
let response = AdminConfigResponse {
version: env!("CARGO_PKG_VERSION").to_string(),
config,
};
Ok(Json(ApiResponse {
status: "success".to_string(),
data: response,
}))
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ExplainRequest {
pub query: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub variables: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub offset: Option<u32>,
}
pub async fn grafana_dashboard_handler(
State(_state): State<AppState>,
) -> impl axum::response::IntoResponse {
const DASHBOARD_JSON: &str = include_str!("../../../resources/fraiseql-dashboard.json");
(
axum::http::StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, "application/json")],
DASHBOARD_JSON,
)
}
pub async fn explain_handler(
State(state): State<AppState>,
Json(req): Json<ExplainRequest>,
) -> Result<Json<ApiResponse<fraiseql_core::runtime::ExplainResult>>, ApiError> {
if req.query.is_empty() {
return Err(ApiError::validation_error("query cannot be empty"));
}
state
.executor()
.explain(&req.query, req.variables.as_ref(), req.limit, req.offset)
.await
.map(ApiResponse::success)
.map_err(|e| match e {
fraiseql_core::error::FraiseQLError::Validation { message, .. } => {
ApiError::validation_error(message)
},
fraiseql_core::error::FraiseQLError::Unsupported { message } => {
ApiError::validation_error(format!("Unsupported: {message}"))
},
other => ApiError::internal_error(other.to_string()),
})
}