Skip to main content

fraiseql_server/routes/api/
tenant_admin.rs

1//! Tenant management admin API endpoints.
2//!
3//! All endpoints require multi-tenant mode to be enabled (tenant registry present
4//! in `AppState`). When disabled, they return 404 to avoid leaking the feature.
5//!
6//! Write endpoints (PUT, DELETE) require `admin_token`.
7//! Read endpoints (GET, health) accept `admin_readonly_token` or `admin_token`.
8
9use 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// ── Request / Response types ─────────────────────────────────────────────
23
24/// Body for `PUT /api/v1/admin/tenants/{key}`.
25#[derive(Debug, Deserialize)]
26pub struct TenantRegistrationRequest {
27    /// Compiled schema JSON (the full `schema.compiled.json` contents).
28    pub schema:     serde_json::Value,
29    /// Database connection configuration for this tenant.
30    pub connection: TenantPoolConfig,
31}
32
33/// Response for tenant write operations.
34#[derive(Debug, Serialize)]
35pub struct TenantResponse {
36    /// The tenant key.
37    pub key:    String,
38    /// Whether this was `"created"`, `"updated"`, or `"removed"`.
39    pub status: &'static str,
40}
41
42/// Response for `GET /api/v1/admin/tenants/{key}`.
43#[derive(Debug, Serialize)]
44pub struct TenantMetadata {
45    /// The tenant key.
46    pub key:            String,
47    /// Number of queries in the tenant's compiled schema.
48    pub query_count:    usize,
49    /// Number of mutations in the tenant's compiled schema.
50    pub mutation_count: usize,
51}
52
53/// Response for `GET /api/v1/admin/tenants`.
54#[derive(Debug, Serialize)]
55pub struct TenantListResponse {
56    /// All registered tenant keys.
57    pub tenants: Vec<String>,
58    /// Number of registered tenants.
59    pub count:   usize,
60}
61
62/// Response for `GET /api/v1/admin/tenants/{key}/health`.
63#[derive(Debug, Serialize)]
64pub struct TenantHealthResponse {
65    /// The tenant key.
66    pub key:    String,
67    /// Health status.
68    pub status: &'static str,
69}
70
71/// Body for `PUT /api/v1/admin/domains/{domain}`.
72#[derive(Debug, Deserialize)]
73pub struct DomainRegistrationRequest {
74    /// The tenant key to map this domain to.
75    pub tenant_key: String,
76}
77
78/// Response for domain write operations.
79#[derive(Debug, Serialize)]
80pub struct DomainResponse {
81    /// The domain name.
82    pub domain:     String,
83    /// Whether this was `"registered"` or `"removed"`.
84    pub status:     &'static str,
85    /// The tenant key the domain maps to (omitted on removal).
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub tenant_key: Option<String>,
88}
89
90/// Response for `GET /api/v1/admin/domains`.
91#[derive(Debug, Serialize)]
92pub struct DomainListResponse {
93    /// All registered domain → tenant key mappings.
94    pub domains: Vec<DomainMapping>,
95    /// Number of registered domains.
96    pub count:   usize,
97}
98
99/// A single domain → tenant key mapping.
100#[derive(Debug, Serialize)]
101pub struct DomainMapping {
102    /// The custom domain.
103    pub domain:     String,
104    /// The tenant key it resolves to.
105    pub tenant_key: String,
106}
107
108// ── Handlers ─────────────────────────────────────────────────────────────
109
110/// `PUT /api/v1/admin/tenants/{key}` — register or update a tenant.
111///
112/// Accepts compiled schema JSON and connection configuration in a single request.
113/// Returns `"created"` or `"updated"` status.
114///
115/// Uses the `TenantExecutorFactory` stored in `AppState` to construct the
116/// executor, avoiding the need for `A: FromPoolConfig` on the handler.
117///
118/// # Errors
119///
120/// Returns `ApiError` with 404 if multi-tenant mode is disabled, 400 for invalid
121/// schema JSON, or 503 if the connection cannot be established.
122pub 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
156/// `DELETE /api/v1/admin/tenants/{key}` — remove a tenant.
157///
158/// In-flight requests on the old executor complete via Arc semantics.
159///
160/// # Errors
161///
162/// Returns `ApiError` with 404 if multi-tenant mode is disabled or the tenant
163/// key is not found.
164pub 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
184/// `GET /api/v1/admin/tenants/{key}` — get tenant metadata.
185///
186/// Returns query/mutation counts. Never includes credentials.
187///
188/// # Errors
189///
190/// Returns `ApiError` with 404 if multi-tenant mode is disabled or the tenant
191/// key is not found.
192pub 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
211/// `GET /api/v1/admin/tenants` — list all registered tenant keys.
212///
213/// Never includes credentials.
214///
215/// # Errors
216///
217/// Returns `ApiError` with 404 if multi-tenant mode is disabled.
218pub 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
231/// `GET /api/v1/admin/tenants/{key}/health` — health check a tenant's pool.
232///
233/// # Errors
234///
235/// Returns `ApiError` with 404 if multi-tenant mode is disabled or the tenant
236/// key is not found. Returns 503 if the health check fails.
237pub 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
258// ── Domain management handlers ──────────────────────────────────────────
259
260/// `PUT /api/v1/admin/domains/{domain}` — register a domain → tenant mapping.
261///
262/// Validates that the referenced tenant key exists in the tenant registry
263/// (when multi-tenant mode is enabled). Overwrites any existing mapping
264/// for the same domain.
265///
266/// # Errors
267///
268/// Returns `ApiError` with 404 if multi-tenant mode is disabled or the
269/// referenced tenant key is not registered.
270pub 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    // Multi-tenant mode must be enabled
276    let registry = state
277        .tenant_registry()
278        .ok_or_else(|| ApiError::not_found("multi-tenant mode not enabled"))?;
279
280    // Verify the tenant key is actually registered
281    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
296/// `DELETE /api/v1/admin/domains/{domain}` — remove a domain mapping.
297///
298/// # Errors
299///
300/// Returns `ApiError` with 404 if multi-tenant mode is disabled or the
301/// domain is not registered.
302pub 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
323/// `GET /api/v1/admin/domains` — list all domain → tenant mappings.
324///
325/// # Errors
326///
327/// Returns `ApiError` with 404 if multi-tenant mode is disabled.
328pub 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)] // Reason: test code, panics acceptable
350    #![allow(clippy::missing_panics_doc)] // Reason: test helpers
351    #![allow(clippy::missing_errors_doc)] // Reason: test helpers
352    #![allow(missing_docs)] // Reason: test code
353
354    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    /// Stub adapter for tenant admin tests.
372    #[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    // ── Unit tests for handler logic (via direct state manipulation) ─────
443
444    #[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    // ── Domain management tests ─────────────────────────────────────────
526
527    #[test]
528    fn test_domain_registry_register_and_list() {
529        let state = make_multitenant_state();
530        let registry = state.tenant_registry().unwrap();
531
532        // Register a tenant first
533        let executor = Arc::new(Executor::new(CompiledSchema::default(), Arc::new(StubAdapter)));
534        registry.upsert("tenant-abc", executor);
535
536        // Register a domain mapping
537        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}