use axum::{
Json,
extract::{Path, State},
};
use fraiseql_core::db::traits::DatabaseAdapter;
use serde::{Deserialize, Serialize};
use tracing::info;
use crate::{
routes::{api::types::ApiError, graphql::AppState},
tenancy::pool_factory::TenantPoolConfig,
};
#[derive(Debug, Deserialize)]
pub struct TenantRegistrationRequest {
pub schema: serde_json::Value,
pub connection: TenantPoolConfig,
}
#[derive(Debug, Serialize)]
pub struct TenantResponse {
pub key: String,
pub status: &'static str,
}
#[derive(Debug, Serialize)]
pub struct TenantMetadata {
pub key: String,
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 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(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 was_insert = registry.upsert(&key, executor);
let status = if was_insert { "created" } else { "updated" };
info!(tenant_key = %key, status, "tenant executor registered");
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");
Ok(Json(TenantResponse {
key,
status: "removed",
}))
}
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 executor = registry
.executor_for(Some(&key))
.map_err(|_| ApiError::not_found(format!("tenant '{key}'")))?;
Ok(Json(TenantMetadata {
key,
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",
}))
}
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,
}))
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)] #![allow(clippy::missing_panics_doc)] #![allow(clippy::missing_errors_doc)] #![allow(missing_docs)]
use std::sync::Arc;
use async_trait::async_trait;
use fraiseql_core::{
db::{
WhereClause,
traits::DatabaseAdapter,
types::{DatabaseType, JsonbValue, PoolMetrics},
},
error::Result as FraiseQLResult,
runtime::Executor,
schema::CompiledSchema,
};
use super::*;
use crate::routes::graphql::TenantExecutorRegistry;
#[derive(Debug, Clone)]
struct StubAdapter;
#[async_trait]
impl DatabaseAdapter for StubAdapter {
async fn execute_where_query(
&self,
_view: &str,
_where_clause: Option<&WhereClause>,
_limit: Option<u32>,
_offset: Option<u32>,
_order_by: Option<&[fraiseql_core::db::types::OrderByClause]>,
) -> FraiseQLResult<Vec<JsonbValue>> {
Ok(vec![])
}
async fn execute_with_projection(
&self,
_view: &str,
_projection: Option<&fraiseql_core::schema::SqlProjectionHint>,
_where_clause: Option<&WhereClause>,
_limit: Option<u32>,
_offset: Option<u32>,
_order_by: Option<&[fraiseql_core::db::types::OrderByClause]>,
) -> FraiseQLResult<Vec<JsonbValue>> {
Ok(vec![])
}
fn database_type(&self) -> DatabaseType {
DatabaseType::SQLite
}
async fn health_check(&self) -> FraiseQLResult<()> {
Ok(())
}
fn pool_metrics(&self) -> PoolMetrics {
PoolMetrics::default()
}
async fn execute_raw_query(
&self,
_sql: &str,
) -> FraiseQLResult<Vec<std::collections::HashMap<String, serde_json::Value>>> {
Ok(vec![])
}
async fn execute_parameterized_aggregate(
&self,
_sql: &str,
_params: &[serde_json::Value],
) -> FraiseQLResult<Vec<std::collections::HashMap<String, serde_json::Value>>> {
Ok(vec![])
}
}
fn make_multitenant_state() -> AppState<StubAdapter> {
let schema = CompiledSchema::default();
let executor = Arc::new(Executor::new(schema, Arc::new(StubAdapter)));
let state = AppState::new(executor);
let registry = TenantExecutorRegistry::new(state.executor.clone());
state.with_tenant_registry(Arc::new(registry))
}
fn make_single_tenant_state() -> AppState<StubAdapter> {
let schema = CompiledSchema::default();
let executor = Arc::new(Executor::new(schema, Arc::new(StubAdapter)));
AppState::new(executor)
}
#[test]
fn test_single_tenant_mode_has_no_registry() {
let state = make_single_tenant_state();
assert!(state.tenant_registry().is_none());
}
#[test]
fn test_multi_tenant_empty_registry() {
let state = make_multitenant_state();
let registry = state.tenant_registry().unwrap();
assert!(registry.is_empty());
assert_eq!(registry.tenant_keys().len(), 0);
}
#[test]
fn test_register_and_list_tenants() {
let state = make_multitenant_state();
let registry = state.tenant_registry().unwrap();
let executor = Arc::new(Executor::new(CompiledSchema::default(), Arc::new(StubAdapter)));
registry.upsert("tenant-abc", executor);
assert_eq!(registry.len(), 1);
assert_eq!(registry.tenant_keys(), vec!["tenant-abc"]);
}
#[test]
fn test_upsert_existing_returns_false() {
let state = make_multitenant_state();
let registry = state.tenant_registry().unwrap();
let executor = Arc::new(Executor::new(CompiledSchema::default(), Arc::new(StubAdapter)));
assert!(registry.upsert("tenant-abc", executor));
let executor2 = Arc::new(Executor::new(CompiledSchema::default(), Arc::new(StubAdapter)));
assert!(!registry.upsert("tenant-abc", executor2));
}
#[test]
fn test_delete_unknown_returns_error() {
let state = make_multitenant_state();
let registry = state.tenant_registry().unwrap();
assert!(registry.remove("unknown").is_err());
}
#[test]
fn test_get_tenant_metadata_via_registry() {
let state = make_multitenant_state();
let registry = state.tenant_registry().unwrap();
let mut schema = CompiledSchema::default();
schema
.queries
.push(fraiseql_core::schema::QueryDefinition::new("users", "User"));
let executor = Arc::new(Executor::new(schema, Arc::new(StubAdapter)));
registry.upsert("tenant-abc", executor);
let exec = registry.executor_for(Some("tenant-abc")).unwrap();
assert_eq!(exec.schema().queries.len(), 1);
assert_eq!(exec.schema().mutations.len(), 0);
}
#[tokio::test]
async fn test_health_check_registered_tenant() {
let state = make_multitenant_state();
let registry = state.tenant_registry().unwrap();
let executor = Arc::new(Executor::new(CompiledSchema::default(), Arc::new(StubAdapter)));
registry.upsert("tenant-abc", executor);
assert!(registry.health_check("tenant-abc").await.is_ok());
}
#[tokio::test]
async fn test_health_check_unknown_tenant() {
let state = make_multitenant_state();
let registry = state.tenant_registry().unwrap();
assert!(registry.health_check("unknown").await.is_err());
}
#[test]
fn test_domain_registry_register_and_list() {
let state = make_multitenant_state();
let registry = state.tenant_registry().unwrap();
let executor = Arc::new(Executor::new(CompiledSchema::default(), Arc::new(StubAdapter)));
registry.upsert("tenant-abc", executor);
state.domain_registry().register("api.acme.com", "tenant-abc");
let mappings = state.domain_registry().domains();
assert_eq!(mappings.len(), 1);
assert_eq!(mappings[0].0, "api.acme.com");
assert_eq!(mappings[0].1, "tenant-abc");
}
#[test]
fn test_domain_registry_remove() {
let state = make_multitenant_state();
state.domain_registry().register("api.acme.com", "tenant-abc");
assert!(state.domain_registry().remove("api.acme.com"));
assert!(!state.domain_registry().remove("api.acme.com"));
}
#[test]
fn test_domain_registry_lookup_with_port() {
let state = make_multitenant_state();
state.domain_registry().register("api.acme.com", "tenant-abc");
assert_eq!(
state.domain_registry().lookup("api.acme.com:8080"),
Some("tenant-abc".to_string())
);
}
#[test]
fn test_domain_empty_in_single_tenant_mode() {
let state = make_single_tenant_state();
assert!(state.domain_registry().is_empty());
}
}