use std::{collections::HashMap, fs};
use axum::{Json, extract::State};
use fraiseql_core::{db::traits::DatabaseAdapter, schema::CompiledSchema};
use serde::{Deserialize, Serialize};
use tracing::{error, info};
use crate::routes::{
api::types::{ApiError, ApiResponse},
graphql::AppState,
};
#[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 message: 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<A: DatabaseAdapter>(
State(state): State<AppState<A>>,
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 entries_count: usize,
pub cache_enabled: bool,
pub ttl_secs: u64,
pub message: String,
}
pub async fn cache_clear_handler<A: DatabaseAdapter>(
State(state): State<AppState<A>>,
Json(req): Json<CacheClearRequest>,
) -> Result<Json<ApiResponse<CacheClearResponse>>, ApiError> {
#[cfg(not(feature = "arrow"))]
{
let _ = (state, req);
Err(ApiError::internal_error("Cache not configured"))
}
#[cfg(feature = "arrow")]
match req.scope.as_str() {
"all" => {
if let Some(cache) = state.cache() {
let entries_before = cache.len();
cache.clear();
info!(
operation = "admin.cache_clear",
scope = "all",
entries_cleared = entries_before,
success = true,
"Admin: cache cleared (all entries)"
);
let response = CacheClearResponse {
success: true,
entries_cleared: entries_before,
message: format!("Cleared {} cache entries", entries_before),
};
Ok(Json(ApiResponse {
status: "success".to_string(),
data: response,
}))
} else {
Err(ApiError::internal_error("Cache not configured"))
}
},
"entity" => {
if req.entity_type.is_none() {
return Err(ApiError::validation_error(
"entity_type is required when scope is 'entity'",
));
}
if let Some(cache) = state.cache() {
let entity_type = req.entity_type.as_ref().ok_or_else(|| {
ApiError::internal_error(
"entity_type was None after validation — this is a bug",
)
})?;
let view_name = format!("v_{}", entity_type.to_lowercase());
let entries_cleared = cache.invalidate_views(&[&view_name]);
info!(
operation = "admin.cache_clear",
scope = "entity",
entity_type = %entity_type,
entries_cleared,
success = true,
"Admin: cache cleared for entity"
);
let response = CacheClearResponse {
success: true,
entries_cleared,
message: format!(
"Cleared {} cache entries for entity type '{}'",
entries_cleared, entity_type
),
};
Ok(Json(ApiResponse {
status: "success".to_string(),
data: response,
}))
} else {
Err(ApiError::internal_error("Cache not configured"))
}
},
"pattern" => {
if req.pattern.is_none() {
return Err(ApiError::validation_error(
"pattern is required when scope is 'pattern'",
));
}
if let Some(cache) = state.cache() {
let pattern = req.pattern.as_ref().ok_or_else(|| {
ApiError::internal_error("pattern was None after validation — this is a bug")
})?;
let entries_cleared = cache.invalidate_pattern(pattern);
info!(
operation = "admin.cache_clear",
scope = "pattern",
%pattern,
entries_cleared,
success = true,
"Admin: cache cleared by pattern"
);
let response = CacheClearResponse {
success: true,
entries_cleared,
message: format!(
"Cleared {} cache entries matching pattern '{}'",
entries_cleared, pattern
),
};
Ok(Json(ApiResponse {
status: "success".to_string(),
data: response,
}))
} else {
Err(ApiError::internal_error("Cache not configured"))
}
},
_ => Err(ApiError::validation_error("scope must be 'all', 'entity', or 'pattern'")),
}
}
pub async fn cache_stats_handler<A: DatabaseAdapter>(
State(state): State<AppState<A>>,
) -> Result<Json<ApiResponse<CacheStatsResponse>>, ApiError> {
#[cfg(feature = "arrow")]
if let Some(cache) = state.cache() {
let response = CacheStatsResponse {
entries_count: cache.len(),
cache_enabled: true,
ttl_secs: 60, message: format!("Cache contains {} entries with 60-second TTL", cache.len()),
};
return Ok(Json(ApiResponse {
status: "success".to_string(),
data: response,
}));
}
{
let _ = state;
let response = CacheStatsResponse {
entries_count: 0,
cache_enabled: false,
ttl_secs: 0,
message: "Cache is not configured".to_string(),
};
Ok(Json(ApiResponse {
status: "success".to_string(),
data: response,
}))
}
}
#[allow(clippy::branches_sharing_code)] pub async fn config_handler<A: DatabaseAdapter>(
State(state): State<AppState<A>>,
) -> Result<Json<ApiResponse<AdminConfigResponse>>, ApiError> {
let mut config = HashMap::new();
if let Some(server_config) = state.server_config() {
config.insert("port".to_string(), server_config.port.to_string());
config.insert("host".to_string(), server_config.host.clone());
if let Some(workers) = server_config.workers {
config.insert("workers".to_string(), workers.to_string());
}
config.insert("tls_enabled".to_string(), server_config.tls.is_some().to_string());
if let Some(limits) = &server_config.limits {
config.insert("max_request_size".to_string(), limits.max_request_size.clone());
config.insert("request_timeout".to_string(), limits.request_timeout.clone());
config.insert(
"max_concurrent_requests".to_string(),
limits.max_concurrent_requests.to_string(),
);
config.insert("max_queue_depth".to_string(), limits.max_queue_depth.to_string());
}
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 _ = server_config; } else {
config.insert("cache_enabled".to_string(), "false".to_string());
config.insert("cache_status".to_string(), "disabled".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<A: DatabaseAdapter>(
State(_state): State<AppState<A>>,
) -> 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<A: DatabaseAdapter + 'static>(
State(state): State<AppState<A>>,
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()),
})
}