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")]
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 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"));
}
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)
.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();
let schema_path = std::path::Path::new(&req.schema_path);
match state.reload_schema(schema_path).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()),
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
#[test]
#[allow(deprecated)] fn cache_status_serializes_to_snake_case() {
let json = serde_json::to_string(&CacheStatus::RlsGuardOnly).unwrap();
assert_eq!(json, "\"rls_guard_only\"");
let json = serde_json::to_string(&CacheStatus::Disabled).unwrap();
assert_eq!(json, "\"disabled\"");
let json = serde_json::to_string(&CacheStatus::Active).unwrap();
assert_eq!(json, "\"active\"");
}
#[test]
#[allow(deprecated)] fn cache_status_from_config_enabled() {
assert_eq!(CacheStatus::from_cache_enabled(true), CacheStatus::RlsGuardOnly);
}
#[test]
#[allow(deprecated)] fn cache_status_from_config_disabled() {
assert_eq!(CacheStatus::from_cache_enabled(false), CacheStatus::Disabled);
}
#[test]
#[allow(deprecated)] fn cache_status_deserializes_from_snake_case() {
let status: CacheStatus = serde_json::from_str("\"rls_guard_only\"").unwrap();
assert_eq!(status, CacheStatus::RlsGuardOnly);
let status: CacheStatus = serde_json::from_str("\"active\"").unwrap();
assert_eq!(status, CacheStatus::Active);
}
#[test]
fn test_grafana_dashboard_is_valid_json() {
let parsed: serde_json::Value =
serde_json::from_str(include_str!("../../../resources/fraiseql-dashboard.json"))
.expect("fraiseql-dashboard.json must be valid JSON");
assert_eq!(parsed["title"], "FraiseQL Performance");
assert_eq!(parsed["uid"], "fraiseql-perf-v1");
assert!(
parsed["panels"].as_array().map_or(0, |p| p.len()) >= 10,
"dashboard should have at least 10 panels"
);
}
#[test]
fn test_reload_schema_request_empty_path() {
let request = ReloadSchemaRequest {
schema_path: String::new(),
validate_only: false,
};
assert!(request.schema_path.is_empty());
}
#[test]
fn test_reload_schema_request_with_path() {
let request = ReloadSchemaRequest {
schema_path: "/path/to/schema.json".to_string(),
validate_only: false,
};
assert!(!request.schema_path.is_empty());
}
#[test]
fn test_cache_clear_scope_validation() {
let valid_scopes = vec!["all", "entity", "pattern"];
for scope in valid_scopes {
let request = CacheClearRequest {
scope: scope.to_string(),
entity_type: None,
pattern: None,
};
assert_eq!(request.scope, scope);
}
}
#[test]
fn test_admin_config_response_has_version() {
let response = AdminConfigResponse {
version: "2.0.0-a1".to_string(),
config: HashMap::new(),
};
assert!(!response.version.is_empty());
}
#[test]
fn test_reload_schema_response_success() {
let response = ReloadSchemaResponse {
success: true,
message: "Reloaded".to_string(),
};
assert!(response.success);
}
#[test]
fn test_reload_schema_response_failure() {
let response = ReloadSchemaResponse {
success: false,
message: "Failed to load".to_string(),
};
assert!(!response.success);
}
#[test]
fn test_cache_clear_response_counts_entries() {
let response = CacheClearResponse {
success: true,
entries_cleared: 42,
message: "Cleared".to_string(),
};
assert_eq!(response.entries_cleared, 42);
}
#[test]
fn test_cache_clear_request_entity_required_for_entity_scope() {
let request = CacheClearRequest {
scope: "entity".to_string(),
entity_type: Some("User".to_string()),
pattern: None,
};
assert_eq!(request.scope, "entity");
assert_eq!(request.entity_type.as_deref(), Some("User"));
}
#[test]
fn test_cache_clear_request_pattern_required_for_pattern_scope() {
let request = CacheClearRequest {
scope: "pattern".to_string(),
entity_type: None,
pattern: Some("*_user".to_string()),
};
assert_eq!(request.scope, "pattern");
assert_eq!(request.pattern.as_deref(), Some("*_user"));
}
#[test]
fn test_admin_config_response_sanitization_excludes_paths() {
let response = AdminConfigResponse {
version: "2.0.0".to_string(),
config: {
let mut m = HashMap::new();
m.insert("port".to_string(), "8000".to_string());
m.insert("host".to_string(), "0.0.0.0".to_string());
m.insert("tls_enabled".to_string(), "true".to_string());
m
},
};
assert_eq!(response.config.get("port"), Some(&"8000".to_string()));
assert_eq!(response.config.get("host"), Some(&"0.0.0.0".to_string()));
assert_eq!(response.config.get("tls_enabled"), Some(&"true".to_string()));
assert!(!response.config.contains_key("cert_file"));
assert!(!response.config.contains_key("key_file"));
}
#[test]
fn test_admin_config_response_includes_limits() {
let response = AdminConfigResponse {
version: "2.0.0".to_string(),
config: {
let mut m = HashMap::new();
m.insert("max_request_size".to_string(), "10MB".to_string());
m.insert("request_timeout".to_string(), "30s".to_string());
m.insert("max_concurrent_requests".to_string(), "1000".to_string());
m
},
};
assert!(response.config.contains_key("max_request_size"));
assert!(response.config.contains_key("request_timeout"));
assert!(response.config.contains_key("max_concurrent_requests"));
}
#[test]
fn test_cache_stats_response_structure() {
let response = CacheStatsResponse {
entries_count: 100,
cache_enabled: true,
ttl_secs: 60,
message: "Cache statistics".to_string(),
};
assert_eq!(response.entries_count, 100);
assert!(response.cache_enabled);
assert_eq!(response.ttl_secs, 60);
assert!(!response.message.is_empty());
}
#[test]
fn test_reload_schema_request_validates_path() {
let request = ReloadSchemaRequest {
schema_path: "/path/to/schema.json".to_string(),
validate_only: false,
};
assert!(!request.schema_path.is_empty());
}
#[test]
fn test_reload_schema_request_validate_only_flag() {
let request = ReloadSchemaRequest {
schema_path: "/path/to/schema.json".to_string(),
validate_only: true,
};
assert!(request.validate_only);
}
#[test]
fn test_reload_schema_response_indicates_success() {
let response = ReloadSchemaResponse {
success: true,
message: "Schema reloaded".to_string(),
};
assert!(response.success);
assert!(!response.message.is_empty());
}
#[test]
fn test_reload_schema_request_carries_audit_fields() {
let req = ReloadSchemaRequest {
schema_path: "/var/run/fraiseql/schema.compiled.json".to_string(),
validate_only: false,
};
assert!(!req.schema_path.is_empty(), "schema_path must be present for audit log");
let _ = req.validate_only;
}
#[test]
fn test_cache_clear_request_carries_audit_fields() {
let all_req = CacheClearRequest {
scope: "all".to_string(),
entity_type: None,
pattern: None,
};
assert_eq!(all_req.scope, "all");
let entity_req = CacheClearRequest {
scope: "entity".to_string(),
entity_type: Some("Order".to_string()),
pattern: None,
};
assert!(
entity_req.entity_type.is_some(),
"entity scope must carry entity_type for audit"
);
let pattern_req = CacheClearRequest {
scope: "pattern".to_string(),
entity_type: None,
pattern: Some("v_order*".to_string()),
};
assert!(pattern_req.pattern.is_some(), "pattern scope must carry pattern for audit");
}
}