Skip to main content

fraiseql_server/
runtime_state.rs

1//! Application state with dependency injection traits.
2//!
3//! This module provides the shared application state structure with injectable
4//! components for testing and modularity.
5
6use std::{sync::Arc, time::SystemTime};
7
8use fraiseql_error::RuntimeError;
9#[cfg(feature = "database")]
10use sqlx::PgPool;
11
12use crate::lifecycle::shutdown::ShutdownCoordinator;
13
14/// Shared application state with injectable components
15pub struct AppState {
16    /// Configuration
17    pub config: Arc<crate::config::RuntimeConfig>,
18
19    /// Database connection pool (optional - requires "database" feature)
20    #[cfg(feature = "database")]
21    pub db: PgPool,
22
23    /// Read replica pools (for load balancing)
24    #[cfg(feature = "database")]
25    pub replicas: Vec<PgPool>,
26
27    /// Cache client (optional, injectable)
28    pub cache: Option<Arc<dyn CacheClient>>,
29
30    /// Rate limiter state
31    pub rate_limiter: Option<Arc<dyn RateLimiter>>,
32
33    /// Webhook idempotency store (injectable)
34    pub idempotency: Option<Arc<dyn IdempotencyStore>>,
35
36    /// Shutdown coordinator
37    pub shutdown: Arc<ShutdownCoordinator>,
38}
39
40impl AppState {
41    /// Create new application state from configuration (without database)
42    ///
43    /// # Panics
44    /// Panics if the "database" feature is enabled - use `new_with_database` instead.
45    #[allow(unreachable_code, unused_variables)]
46    pub fn new(config: crate::config::RuntimeConfig, shutdown: Arc<ShutdownCoordinator>) -> Self {
47        Self {
48            config: Arc::new(config),
49            #[cfg(feature = "database")]
50            db: panic!("Use new_with_database when database feature is enabled"),
51            #[cfg(feature = "database")]
52            replicas: Vec::new(),
53            cache: None,
54            rate_limiter: None,
55            idempotency: None,
56            shutdown,
57        }
58    }
59
60    /// Create state with database connection (requires "database" feature)
61    #[cfg(feature = "database")]
62    pub async fn new_with_database(
63        config: crate::config::RuntimeConfig,
64        shutdown: Arc<ShutdownCoordinator>,
65    ) -> Result<Self, RuntimeError> {
66        // Connect to database
67        let db_url =
68            std::env::var(&config.database.url_env).map_err(|_| RuntimeError::Internal {
69                message: format!("Missing environment variable: {}", config.database.url_env),
70                source:  None,
71            })?;
72        let db = PgPool::connect(&db_url).await.map_err(|e| RuntimeError::Database(e))?;
73
74        // Connect to replicas
75        let mut replicas = Vec::new();
76        for replica in &config.database.replicas {
77            let url = std::env::var(&replica.url_env).map_err(|_| RuntimeError::Internal {
78                message: format!("Missing environment variable: {}", replica.url_env),
79                source:  None,
80            })?;
81            replicas.push(PgPool::connect(&url).await.map_err(|e| RuntimeError::Database(e))?);
82        }
83
84        Ok(Self {
85            config: Arc::new(config),
86            db,
87            replicas,
88            cache: None,
89            rate_limiter: None,
90            idempotency: None,
91            shutdown,
92        })
93    }
94
95    /// Get a database connection for reads (load-balanced across replicas)
96    #[cfg(feature = "database")]
97    pub fn read_connection(&self) -> &PgPool {
98        if self.replicas.is_empty() {
99            &self.db
100        } else {
101            use std::sync::atomic::{AtomicUsize, Ordering};
102            static COUNTER: AtomicUsize = AtomicUsize::new(0);
103            let idx = COUNTER.fetch_add(1, Ordering::Relaxed) % self.replicas.len();
104            &self.replicas[idx]
105        }
106    }
107
108    /// Get primary database connection (for writes)
109    #[cfg(feature = "database")]
110    pub fn write_connection(&self) -> &PgPool {
111        &self.db
112    }
113}
114
115/// Trait for cache operations (injectable for testing)
116#[async_trait::async_trait]
117pub trait CacheClient: Send + Sync {
118    async fn get(&self, key: &str) -> Result<Option<Vec<u8>>, RuntimeError>;
119    async fn set(
120        &self,
121        key: &str,
122        value: &[u8],
123        ttl: Option<std::time::Duration>,
124    ) -> Result<(), RuntimeError>;
125    async fn delete(&self, key: &str) -> Result<(), RuntimeError>;
126    async fn ping(&self) -> Result<(), RuntimeError>;
127}
128
129/// Trait for rate limiting (injectable for testing)
130#[async_trait::async_trait]
131pub trait RateLimiter: Send + Sync {
132    async fn check(
133        &self,
134        key: &str,
135        limit: u32,
136        window: std::time::Duration,
137    ) -> Result<RateLimitResult, RuntimeError>;
138}
139
140pub struct RateLimitResult {
141    pub allowed:   bool,
142    pub remaining: u32,
143    pub reset_at:  SystemTime,
144}
145
146/// Trait for idempotency checking (injectable for testing)
147#[async_trait::async_trait]
148pub trait IdempotencyStore: Send + Sync {
149    async fn check_and_store(
150        &self,
151        key: &str,
152        ttl: std::time::Duration,
153    ) -> Result<bool, RuntimeError>;
154    async fn get_result(&self, key: &str) -> Result<Option<serde_json::Value>, RuntimeError>;
155    async fn store_result(&self, key: &str, result: &serde_json::Value)
156    -> Result<(), RuntimeError>;
157}