fraiseql_server/routes/api/
tenant_admin.rs1use axum::{
10 Json,
11 extract::{Path, State},
12};
13use fraiseql_core::db::traits::DatabaseAdapter;
14use serde::{Deserialize, Serialize};
15use tracing::info;
16
17use crate::{
18 routes::{api::types::ApiError, graphql::AppState},
19 tenancy::pool_factory::TenantPoolConfig,
20};
21
22#[derive(Debug, Deserialize)]
26pub struct TenantRegistrationRequest {
27 pub schema: serde_json::Value,
29 pub connection: TenantPoolConfig,
31}
32
33#[derive(Debug, Serialize)]
35pub struct TenantResponse {
36 pub key: String,
38 pub status: &'static str,
40}
41
42#[derive(Debug, Serialize)]
44pub struct TenantMetadata {
45 pub key: String,
47 pub query_count: usize,
49 pub mutation_count: usize,
51}
52
53#[derive(Debug, Serialize)]
55pub struct TenantListResponse {
56 pub tenants: Vec<String>,
58 pub count: usize,
60}
61
62#[derive(Debug, Serialize)]
64pub struct TenantHealthResponse {
65 pub key: String,
67 pub status: &'static str,
69}
70
71#[derive(Debug, Deserialize)]
73pub struct DomainRegistrationRequest {
74 pub tenant_key: String,
76}
77
78#[derive(Debug, Serialize)]
80pub struct DomainResponse {
81 pub domain: String,
83 pub status: &'static str,
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub tenant_key: Option<String>,
88}
89
90#[derive(Debug, Serialize)]
92pub struct DomainListResponse {
93 pub domains: Vec<DomainMapping>,
95 pub count: usize,
97}
98
99#[derive(Debug, Serialize)]
101pub struct DomainMapping {
102 pub domain: String,
104 pub tenant_key: String,
106}
107
108pub async fn upsert_tenant_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
123 State(state): State<AppState<A>>,
124 Path(key): Path<String>,
125 Json(body): Json<TenantRegistrationRequest>,
126) -> Result<Json<TenantResponse>, ApiError> {
127 let registry = state
128 .tenant_registry()
129 .ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
130
131 let factory = state
132 .tenant_executor_factory()
133 .ok_or_else(|| ApiError::internal_error("tenant executor factory not configured"))?;
134
135 let schema_json = serde_json::to_string(&body.schema)
136 .map_err(|e| ApiError::validation_error(format!("invalid schema JSON: {e}")))?;
137
138 let executor = factory(schema_json, body.connection).await.map_err(|e| match &e {
139 fraiseql_error::FraiseQLError::Parse { .. }
140 | fraiseql_error::FraiseQLError::Validation { .. } => ApiError::validation_error(e),
141 fraiseql_error::FraiseQLError::ConnectionPool { .. }
142 | fraiseql_error::FraiseQLError::Database { .. } => {
143 ApiError::new(format!("Connection failed: {e}"), "SERVICE_UNAVAILABLE")
144 },
145 _ => ApiError::internal_error(e),
146 })?;
147
148 let was_insert = registry.upsert(&key, executor);
149 let status = if was_insert { "created" } else { "updated" };
150
151 info!(tenant_key = %key, status, "tenant executor registered");
152
153 Ok(Json(TenantResponse { key, status }))
154}
155
156pub async fn delete_tenant_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
165 State(state): State<AppState<A>>,
166 Path(key): Path<String>,
167) -> Result<Json<TenantResponse>, ApiError> {
168 let registry = state
169 .tenant_registry()
170 .ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
171
172 registry
173 .remove(&key)
174 .map_err(|_| ApiError::not_found(format!("tenant '{key}'")))?;
175
176 info!(tenant_key = %key, "tenant executor removed");
177
178 Ok(Json(TenantResponse {
179 key,
180 status: "removed",
181 }))
182}
183
184pub async fn get_tenant_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
193 State(state): State<AppState<A>>,
194 Path(key): Path<String>,
195) -> Result<Json<TenantMetadata>, ApiError> {
196 let registry = state
197 .tenant_registry()
198 .ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
199
200 let executor = registry
201 .executor_for(Some(&key))
202 .map_err(|_| ApiError::not_found(format!("tenant '{key}'")))?;
203
204 Ok(Json(TenantMetadata {
205 key,
206 query_count: executor.schema().queries.len(),
207 mutation_count: executor.schema().mutations.len(),
208 }))
209}
210
211pub async fn list_tenants_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
219 State(state): State<AppState<A>>,
220) -> Result<Json<TenantListResponse>, ApiError> {
221 let registry = state
222 .tenant_registry()
223 .ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
224
225 let tenants = registry.tenant_keys();
226 let count = tenants.len();
227
228 Ok(Json(TenantListResponse { tenants, count }))
229}
230
231pub async fn tenant_health_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
238 State(state): State<AppState<A>>,
239 Path(key): Path<String>,
240) -> Result<Json<TenantHealthResponse>, ApiError> {
241 let registry = state
242 .tenant_registry()
243 .ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
244
245 registry.health_check(&key).await.map_err(|e| match &e {
246 fraiseql_error::FraiseQLError::NotFound { .. } => {
247 ApiError::not_found(format!("tenant '{key}'"))
248 },
249 _ => ApiError::new(format!("Health check failed: {e}"), "SERVICE_UNAVAILABLE"),
250 })?;
251
252 Ok(Json(TenantHealthResponse {
253 key,
254 status: "healthy",
255 }))
256}
257
258pub async fn upsert_domain_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
271 State(state): State<AppState<A>>,
272 Path(domain): Path<String>,
273 Json(body): Json<DomainRegistrationRequest>,
274) -> Result<Json<DomainResponse>, ApiError> {
275 let registry = state
277 .tenant_registry()
278 .ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
279
280 registry
282 .executor_for(Some(&body.tenant_key))
283 .map_err(|_| ApiError::not_found(format!("tenant '{}'", body.tenant_key)))?;
284
285 state.domain_registry().register(&domain, &body.tenant_key);
286
287 info!(domain = %domain, tenant_key = %body.tenant_key, "domain mapping registered");
288
289 Ok(Json(DomainResponse {
290 domain,
291 status: "registered",
292 tenant_key: Some(body.tenant_key),
293 }))
294}
295
296pub async fn delete_domain_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
303 State(state): State<AppState<A>>,
304 Path(domain): Path<String>,
305) -> Result<Json<DomainResponse>, ApiError> {
306 state
307 .tenant_registry()
308 .ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
309
310 if !state.domain_registry().remove(&domain) {
311 return Err(ApiError::not_found(format!("domain '{domain}'")));
312 }
313
314 info!(domain = %domain, "domain mapping removed");
315
316 Ok(Json(DomainResponse {
317 domain,
318 status: "removed",
319 tenant_key: None,
320 }))
321}
322
323pub async fn list_domains_handler<A: DatabaseAdapter + Clone + Send + Sync + 'static>(
329 State(state): State<AppState<A>>,
330) -> Result<Json<DomainListResponse>, ApiError> {
331 state
332 .tenant_registry()
333 .ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
334
335 let mappings = state.domain_registry().domains();
336 let count = mappings.len();
337
338 Ok(Json(DomainListResponse {
339 domains: mappings
340 .into_iter()
341 .map(|(domain, tenant_key)| DomainMapping { domain, tenant_key })
342 .collect(),
343 count,
344 }))
345}
346
347#[cfg(test)]
348mod tests {
349 #![allow(clippy::unwrap_used)] #![allow(clippy::missing_panics_doc)] #![allow(clippy::missing_errors_doc)] #![allow(missing_docs)] use std::sync::Arc;
355
356 use async_trait::async_trait;
357 use fraiseql_core::{
358 db::{
359 WhereClause,
360 traits::DatabaseAdapter,
361 types::{DatabaseType, JsonbValue, PoolMetrics},
362 },
363 error::Result as FraiseQLResult,
364 runtime::Executor,
365 schema::CompiledSchema,
366 };
367
368 use super::*;
369 use crate::routes::graphql::TenantExecutorRegistry;
370
371 #[derive(Debug, Clone)]
373 struct StubAdapter;
374
375 #[async_trait]
376 impl DatabaseAdapter for StubAdapter {
377 async fn execute_where_query(
378 &self,
379 _view: &str,
380 _where_clause: Option<&WhereClause>,
381 _limit: Option<u32>,
382 _offset: Option<u32>,
383 _order_by: Option<&[fraiseql_core::db::types::OrderByClause]>,
384 ) -> FraiseQLResult<Vec<JsonbValue>> {
385 Ok(vec![])
386 }
387
388 async fn execute_with_projection(
389 &self,
390 _view: &str,
391 _projection: Option<&fraiseql_core::schema::SqlProjectionHint>,
392 _where_clause: Option<&WhereClause>,
393 _limit: Option<u32>,
394 _offset: Option<u32>,
395 _order_by: Option<&[fraiseql_core::db::types::OrderByClause]>,
396 ) -> FraiseQLResult<Vec<JsonbValue>> {
397 Ok(vec![])
398 }
399
400 fn database_type(&self) -> DatabaseType {
401 DatabaseType::SQLite
402 }
403
404 async fn health_check(&self) -> FraiseQLResult<()> {
405 Ok(())
406 }
407
408 fn pool_metrics(&self) -> PoolMetrics {
409 PoolMetrics::default()
410 }
411
412 async fn execute_raw_query(
413 &self,
414 _sql: &str,
415 ) -> FraiseQLResult<Vec<std::collections::HashMap<String, serde_json::Value>>> {
416 Ok(vec![])
417 }
418
419 async fn execute_parameterized_aggregate(
420 &self,
421 _sql: &str,
422 _params: &[serde_json::Value],
423 ) -> FraiseQLResult<Vec<std::collections::HashMap<String, serde_json::Value>>> {
424 Ok(vec![])
425 }
426 }
427
428 fn make_multitenant_state() -> AppState<StubAdapter> {
429 let schema = CompiledSchema::default();
430 let executor = Arc::new(Executor::new(schema, Arc::new(StubAdapter)));
431 let state = AppState::new(executor);
432 let registry = TenantExecutorRegistry::new(state.executor.clone());
433 state.with_tenant_registry(Arc::new(registry))
434 }
435
436 fn make_single_tenant_state() -> AppState<StubAdapter> {
437 let schema = CompiledSchema::default();
438 let executor = Arc::new(Executor::new(schema, Arc::new(StubAdapter)));
439 AppState::new(executor)
440 }
441
442 #[test]
445 fn test_single_tenant_mode_has_no_registry() {
446 let state = make_single_tenant_state();
447 assert!(state.tenant_registry().is_none());
448 }
449
450 #[test]
451 fn test_multi_tenant_empty_registry() {
452 let state = make_multitenant_state();
453 let registry = state.tenant_registry().unwrap();
454 assert!(registry.is_empty());
455 assert_eq!(registry.tenant_keys().len(), 0);
456 }
457
458 #[test]
459 fn test_register_and_list_tenants() {
460 let state = make_multitenant_state();
461 let registry = state.tenant_registry().unwrap();
462
463 let executor = Arc::new(Executor::new(CompiledSchema::default(), Arc::new(StubAdapter)));
464 registry.upsert("tenant-abc", executor);
465
466 assert_eq!(registry.len(), 1);
467 assert_eq!(registry.tenant_keys(), vec!["tenant-abc"]);
468 }
469
470 #[test]
471 fn test_upsert_existing_returns_false() {
472 let state = make_multitenant_state();
473 let registry = state.tenant_registry().unwrap();
474
475 let executor = Arc::new(Executor::new(CompiledSchema::default(), Arc::new(StubAdapter)));
476 assert!(registry.upsert("tenant-abc", executor));
477
478 let executor2 = Arc::new(Executor::new(CompiledSchema::default(), Arc::new(StubAdapter)));
479 assert!(!registry.upsert("tenant-abc", executor2));
480 }
481
482 #[test]
483 fn test_delete_unknown_returns_error() {
484 let state = make_multitenant_state();
485 let registry = state.tenant_registry().unwrap();
486 assert!(registry.remove("unknown").is_err());
487 }
488
489 #[test]
490 fn test_get_tenant_metadata_via_registry() {
491 let state = make_multitenant_state();
492 let registry = state.tenant_registry().unwrap();
493
494 let mut schema = CompiledSchema::default();
495 schema
496 .queries
497 .push(fraiseql_core::schema::QueryDefinition::new("users", "User"));
498 let executor = Arc::new(Executor::new(schema, Arc::new(StubAdapter)));
499 registry.upsert("tenant-abc", executor);
500
501 let exec = registry.executor_for(Some("tenant-abc")).unwrap();
502 assert_eq!(exec.schema().queries.len(), 1);
503 assert_eq!(exec.schema().mutations.len(), 0);
504 }
505
506 #[tokio::test]
507 async fn test_health_check_registered_tenant() {
508 let state = make_multitenant_state();
509 let registry = state.tenant_registry().unwrap();
510
511 let executor = Arc::new(Executor::new(CompiledSchema::default(), Arc::new(StubAdapter)));
512 registry.upsert("tenant-abc", executor);
513
514 assert!(registry.health_check("tenant-abc").await.is_ok());
515 }
516
517 #[tokio::test]
518 async fn test_health_check_unknown_tenant() {
519 let state = make_multitenant_state();
520 let registry = state.tenant_registry().unwrap();
521
522 assert!(registry.health_check("unknown").await.is_err());
523 }
524
525 #[test]
528 fn test_domain_registry_register_and_list() {
529 let state = make_multitenant_state();
530 let registry = state.tenant_registry().unwrap();
531
532 let executor = Arc::new(Executor::new(CompiledSchema::default(), Arc::new(StubAdapter)));
534 registry.upsert("tenant-abc", executor);
535
536 state.domain_registry().register("api.acme.com", "tenant-abc");
538
539 let mappings = state.domain_registry().domains();
540 assert_eq!(mappings.len(), 1);
541 assert_eq!(mappings[0].0, "api.acme.com");
542 assert_eq!(mappings[0].1, "tenant-abc");
543 }
544
545 #[test]
546 fn test_domain_registry_remove() {
547 let state = make_multitenant_state();
548
549 state.domain_registry().register("api.acme.com", "tenant-abc");
550 assert!(state.domain_registry().remove("api.acme.com"));
551 assert!(!state.domain_registry().remove("api.acme.com"));
552 }
553
554 #[test]
555 fn test_domain_registry_lookup_with_port() {
556 let state = make_multitenant_state();
557 state.domain_registry().register("api.acme.com", "tenant-abc");
558
559 assert_eq!(
560 state.domain_registry().lookup("api.acme.com:8080"),
561 Some("tenant-abc".to_string())
562 );
563 }
564
565 #[test]
566 fn test_domain_empty_in_single_tenant_mode() {
567 let state = make_single_tenant_state();
568 assert!(state.domain_registry().is_empty());
569 }
570}