use axum::{
Json,
extract::{Path, Query, State},
};
use fraiseql_core::db::traits::DatabaseAdapter;
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::{
routes::{
api::types::ApiError,
graphql::{AppState, tenant_registry::TenantQuota},
},
tenancy::{audit::TenantEventKind, pool_factory::TenantPoolConfig},
};
#[derive(Debug, Deserialize)]
pub struct TenantRegistrationRequest {
pub schema: serde_json::Value,
pub connection: TenantPoolConfig,
#[serde(default)]
pub max_requests_per_sec: Option<u32>,
#[serde(default)]
pub max_concurrent: Option<u32>,
#[serde(default)]
pub max_storage_bytes: Option<u64>,
}
#[derive(Debug, Serialize)]
pub struct TenantResponse {
pub key: String,
pub status: &'static str,
}
#[derive(Debug, Serialize)]
pub struct TenantMetadata {
pub key: String,
pub status: &'static str,
pub query_count: usize,
pub mutation_count: usize,
}
#[derive(Debug, Serialize)]
pub struct TenantListResponse {
pub tenants: Vec<String>,
pub count: usize,
}
#[derive(Debug, Serialize)]
pub struct TenantHealthResponse {
pub key: String,
pub status: &'static str,
}
#[derive(Debug, Deserialize)]
pub struct EventsQuery {
#[serde(default = "default_events_limit")]
pub limit: usize,
#[serde(default)]
pub offset: usize,
}
const fn default_events_limit() -> usize {
50
}
#[derive(Debug, Serialize)]
pub struct TenantEventsResponse {
pub key: String,
pub events: Vec<crate::tenancy::audit::TenantEvent>,
pub count: usize,
}
#[derive(Debug, Deserialize)]
pub struct DomainRegistrationRequest {
pub tenant_key: String,
}
#[derive(Debug, Serialize)]
pub struct DomainResponse {
pub domain: String,
pub status: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub tenant_key: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct DomainListResponse {
pub domains: Vec<DomainMapping>,
pub count: usize,
}
#[derive(Debug, Serialize)]
pub struct DomainMapping {
pub domain: String,
pub tenant_key: String,
}
pub async fn upsert_tenant_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
State(state): State<AppState<A>>,
Path(key): Path<String>,
Json(body): Json<TenantRegistrationRequest>,
) -> Result<Json<TenantResponse>, ApiError> {
let registry = state
.tenant_registry()
.ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
let factory = state
.tenant_executor_factory()
.ok_or_else(|| ApiError::internal_error("tenant executor factory not configured"))?;
let schema_json = serde_json::to_string(&body.schema)
.map_err(|e| ApiError::validation_error(format!("invalid schema JSON: {e}")))?;
let executor =
factory(key.clone(), schema_json, body.connection).await.map_err(|e| match &e {
fraiseql_error::FraiseQLError::Parse { .. }
| fraiseql_error::FraiseQLError::Validation { .. } => ApiError::validation_error(e),
fraiseql_error::FraiseQLError::ConnectionPool { .. }
| fraiseql_error::FraiseQLError::Database { .. } => {
ApiError::new(format!("Connection failed: {e}"), "SERVICE_UNAVAILABLE")
},
_ => ApiError::internal_error(e),
})?;
let quota = TenantQuota {
max_requests_per_sec: body.max_requests_per_sec,
max_concurrent: body.max_concurrent,
max_storage_bytes: body.max_storage_bytes,
};
let was_insert = registry.upsert_with_quota(&key, executor, quota);
let status = if was_insert { "created" } else { "updated" };
info!(tenant_key = %key, status, "tenant executor registered");
if let Some(audit_log) = state.tenant_audit_log() {
let event = if was_insert {
TenantEventKind::Created
} else {
TenantEventKind::ConfigChanged
};
if let Err(e) = audit_log.record(&key, event, None, None).await {
tracing::warn!(tenant_key = %key, error = %e, "failed to record audit event");
}
}
Ok(Json(TenantResponse { key, status }))
}
pub async fn delete_tenant_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
State(state): State<AppState<A>>,
Path(key): Path<String>,
) -> Result<Json<TenantResponse>, ApiError> {
let registry = state
.tenant_registry()
.ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
registry
.remove(&key)
.map_err(|_| ApiError::not_found(format!("tenant '{key}'")))?;
info!(tenant_key = %key, "tenant executor removed");
if let Some(audit_log) = state.tenant_audit_log() {
if let Err(e) = audit_log.record(&key, TenantEventKind::Deleted, None, None).await {
tracing::warn!(tenant_key = %key, error = %e, "failed to record audit event");
}
}
Ok(Json(TenantResponse {
key,
status: "removed",
}))
}
pub async fn suspend_tenant_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
State(state): State<AppState<A>>,
Path(key): Path<String>,
) -> Result<Json<TenantResponse>, ApiError> {
let registry = state
.tenant_registry()
.ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
registry
.suspend(&key)
.map_err(|_| ApiError::not_found(format!("tenant '{key}'")))?;
info!(tenant_key = %key, "tenant suspended");
if let Some(audit_log) = state.tenant_audit_log() {
if let Err(e) = audit_log.record(&key, TenantEventKind::Suspended, None, None).await {
tracing::warn!(tenant_key = %key, error = %e, "failed to record audit event");
}
}
Ok(Json(TenantResponse {
key,
status: "suspended",
}))
}
pub async fn resume_tenant_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
State(state): State<AppState<A>>,
Path(key): Path<String>,
) -> Result<Json<TenantResponse>, ApiError> {
let registry = state
.tenant_registry()
.ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
registry
.resume(&key)
.map_err(|_| ApiError::not_found(format!("tenant '{key}'")))?;
info!(tenant_key = %key, "tenant resumed");
if let Some(audit_log) = state.tenant_audit_log() {
if let Err(e) = audit_log.record(&key, TenantEventKind::Resumed, None, None).await {
tracing::warn!(tenant_key = %key, error = %e, "failed to record audit event");
}
}
Ok(Json(TenantResponse {
key,
status: "resumed",
}))
}
pub async fn get_tenant_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
State(state): State<AppState<A>>,
Path(key): Path<String>,
) -> Result<Json<TenantMetadata>, ApiError> {
let registry = state
.tenant_registry()
.ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
let status = registry
.tenant_status(&key)
.map_err(|_| ApiError::not_found(format!("tenant '{key}'")))?;
let executor = registry
.executor_for_admin(&key)
.map_err(|_| ApiError::not_found(format!("tenant '{key}'")))?;
Ok(Json(TenantMetadata {
key,
status: status.as_str(),
query_count: executor.schema().queries.len(),
mutation_count: executor.schema().mutations.len(),
}))
}
pub async fn list_tenants_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
State(state): State<AppState<A>>,
) -> Result<Json<TenantListResponse>, ApiError> {
let registry = state
.tenant_registry()
.ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
let tenants = registry.tenant_keys();
let count = tenants.len();
Ok(Json(TenantListResponse { tenants, count }))
}
pub async fn tenant_health_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
State(state): State<AppState<A>>,
Path(key): Path<String>,
) -> Result<Json<TenantHealthResponse>, ApiError> {
let registry = state
.tenant_registry()
.ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
registry.health_check(&key).await.map_err(|e| match &e {
fraiseql_error::FraiseQLError::NotFound { .. } => {
ApiError::not_found(format!("tenant '{key}'"))
},
_ => ApiError::new(format!("Health check failed: {e}"), "SERVICE_UNAVAILABLE"),
})?;
Ok(Json(TenantHealthResponse {
key,
status: "healthy",
}))
}
const MAX_EVENTS_LIMIT: usize = 200;
pub async fn tenant_events_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
State(state): State<AppState<A>>,
Path(key): Path<String>,
Query(params): Query<EventsQuery>,
) -> Result<Json<TenantEventsResponse>, ApiError> {
let registry = state
.tenant_registry()
.ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
registry
.executor_for_admin(&key)
.map_err(|_| ApiError::not_found(format!("tenant '{key}'")))?;
let audit_log = state
.tenant_audit_log()
.ok_or_else(|| ApiError::not_found("audit log not configured"))?;
let limit = params.limit.min(MAX_EVENTS_LIMIT);
let events = audit_log
.events_for(&key, limit, params.offset)
.await
.map_err(|e| ApiError::internal_error(format!("failed to query audit events: {e}")))?;
let count = events.len();
Ok(Json(TenantEventsResponse { key, events, count }))
}
pub async fn upsert_domain_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
State(state): State<AppState<A>>,
Path(domain): Path<String>,
Json(body): Json<DomainRegistrationRequest>,
) -> Result<Json<DomainResponse>, ApiError> {
let registry = state
.tenant_registry()
.ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
registry
.executor_for(Some(&body.tenant_key))
.map_err(|_| ApiError::not_found(format!("tenant '{}'", body.tenant_key)))?;
state.domain_registry().register(&domain, &body.tenant_key);
info!(domain = %domain, tenant_key = %body.tenant_key, "domain mapping registered");
Ok(Json(DomainResponse {
domain,
status: "registered",
tenant_key: Some(body.tenant_key),
}))
}
pub async fn delete_domain_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
State(state): State<AppState<A>>,
Path(domain): Path<String>,
) -> Result<Json<DomainResponse>, ApiError> {
state
.tenant_registry()
.ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
if !state.domain_registry().remove(&domain) {
return Err(ApiError::not_found(format!("domain '{domain}'")));
}
info!(domain = %domain, "domain mapping removed");
Ok(Json(DomainResponse {
domain,
status: "removed",
tenant_key: None,
}))
}
pub async fn list_domains_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
State(state): State<AppState<A>>,
) -> Result<Json<DomainListResponse>, ApiError> {
state
.tenant_registry()
.ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
let mappings = state.domain_registry().domains();
let count = mappings.len();
Ok(Json(DomainListResponse {
domains: mappings
.into_iter()
.map(|(domain, tenant_key)| DomainMapping { domain, tenant_key })
.collect(),
count,
}))
}