Skip to main content

fraiseql_core/config/
mod.rs

1//! Configuration management.
2//!
3//! This module provides comprehensive configuration for FraiseQL servers:
4//!
5//! - **Server**: Host, port, worker threads
6//! - **Database**: Connection URL, pool settings, timeouts
7//! - **CORS**: Allowed origins, methods, headers
8//! - **Auth**: JWT/Auth0/Clerk configuration
9//! - **Rate Limiting**: Request limits per window
10//! - **Caching**: APQ and response caching settings
11//!
12//! # Configuration File Format
13//!
14//! FraiseQL supports TOML configuration files:
15//!
16//! ```toml
17//! [server]
18//! host = "0.0.0.0"
19//! port = 8000
20//!
21//! [database]
22//! url = "postgresql://localhost/mydb"
23//! max_connections = 10
24//! timeout_secs = 30
25//!
26//! [cors]
27//! allowed_origins = ["http://localhost:3000"]
28//! allow_credentials = true
29//!
30//! [auth]
31//! provider = "jwt"
32//! secret = "${JWT_SECRET}"
33//!
34//! [rate_limit]
35//! requests_per_minute = 100
36//! ```
37//!
38//! # Environment Variable Expansion
39//!
40//! Config values can reference environment variables using `${VAR}` syntax.
41//! This is especially useful for secrets that shouldn't be in config files.
42
43use std::path::Path;
44
45use serde::{Deserialize, Serialize};
46
47use crate::error::{FraiseQLError, Result};
48
49mod auth;
50mod cache;
51mod cors;
52mod database;
53mod rate_limit;
54mod server;
55
56pub use auth::{AuthConfig, AuthProvider};
57pub use cache::CacheConfig;
58pub use cors::CorsConfig;
59pub use database::{DatabaseConfig, MutationTimingConfig, SslMode};
60// =============================================================================
61// Collation Configuration — re-exported from fraiseql-db
62// =============================================================================
63pub use fraiseql_db::{
64    CollationConfig, DatabaseCollationOverrides, InvalidLocaleStrategy, MySqlCollationConfig,
65    PostgresCollationConfig, SqlServerCollationConfig, SqliteCollationConfig,
66};
67pub use rate_limit::{PathRateLimit, RateLimitConfig, RateLimitKey};
68pub use server::CoreServerConfig;
69
70// =============================================================================
71// Main Configuration
72// =============================================================================
73
74/// Main configuration structure.
75///
76/// This is the complete configuration for a `FraiseQL` server instance.
77/// It can be loaded from a TOML file, environment variables, or built programmatically.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79#[serde(default)]
80pub struct FraiseQLConfig {
81    /// Server configuration.
82    pub server: CoreServerConfig,
83
84    /// Database configuration.
85    pub database: DatabaseConfig,
86
87    /// CORS configuration.
88    pub cors: CorsConfig,
89
90    /// Authentication configuration.
91    pub auth: AuthConfig,
92
93    /// Rate limiting configuration.
94    pub rate_limit: RateLimitConfig,
95
96    /// Caching configuration.
97    pub cache: CacheConfig,
98
99    /// Collation configuration.
100    pub collation: CollationConfig,
101
102    // Legacy fields for backward compatibility
103    #[serde(skip)]
104    database_url_compat: Option<String>,
105
106    /// Database connection URL (legacy, prefer database.url).
107    #[serde(skip_serializing, default)]
108    pub database_url: String,
109
110    /// Server host (legacy, prefer server.host).
111    #[serde(skip_serializing, default)]
112    pub host: String,
113
114    /// Server port (legacy, prefer server.port).
115    #[serde(skip_serializing, default)]
116    pub port: u16,
117
118    /// Maximum connections (legacy, prefer `database.max_connections`).
119    #[serde(skip_serializing, default)]
120    pub max_connections: u32,
121
122    /// Query timeout (legacy, prefer `database.query_timeout_secs`).
123    #[serde(skip_serializing, default)]
124    pub query_timeout_secs: u64,
125}
126
127impl Default for FraiseQLConfig {
128    fn default() -> Self {
129        let server = CoreServerConfig::default();
130        let database = DatabaseConfig::default();
131
132        Self {
133            // Legacy compat fields
134            database_url: String::new(),
135            host: server.host.clone(),
136            port: server.port,
137            max_connections: database.max_connections,
138            query_timeout_secs: database.query_timeout_secs,
139            database_url_compat: None,
140
141            // New structured config
142            server,
143            database,
144            cors: CorsConfig::default(),
145            auth: AuthConfig::default(),
146            rate_limit: RateLimitConfig::default(),
147            cache: CacheConfig::default(),
148            collation: CollationConfig::default(),
149        }
150    }
151}
152
153impl FraiseQLConfig {
154    /// Create a new configuration builder.
155    pub fn builder() -> ConfigBuilder {
156        ConfigBuilder::default()
157    }
158
159    /// Load configuration from a TOML file.
160    ///
161    /// # Errors
162    ///
163    /// Returns error if the file cannot be read or parsed.
164    pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
165        let path = path.as_ref();
166        let content = std::fs::read_to_string(path).map_err(|e| FraiseQLError::Configuration {
167            message: format!("Failed to read config file '{}': {}", path.display(), e),
168        })?;
169
170        Self::from_toml(&content)
171    }
172
173    /// Load configuration from a TOML string.
174    ///
175    /// # Errors
176    ///
177    /// Returns error if the TOML is invalid.
178    pub fn from_toml(content: &str) -> Result<Self> {
179        // Expand environment variables in the content
180        let expanded = expand_env_vars(content);
181
182        let mut config: Self =
183            toml::from_str(&expanded).map_err(|e| FraiseQLError::Configuration {
184                message: format!("Invalid TOML configuration: {e}"),
185            })?;
186
187        // Sync legacy fields
188        config.sync_legacy_fields();
189
190        Ok(config)
191    }
192
193    /// Load configuration from environment variables.
194    ///
195    /// # Errors
196    ///
197    /// Returns error if required environment variables are missing.
198    pub fn from_env() -> Result<Self> {
199        let database_url =
200            std::env::var("DATABASE_URL").map_err(|_| FraiseQLError::Configuration {
201                message: "DATABASE_URL not set".to_string(),
202            })?;
203
204        let host = std::env::var("FRAISEQL_HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
205
206        let port = std::env::var("FRAISEQL_PORT").ok().and_then(|s| s.parse().ok()).unwrap_or(8000);
207
208        let max_connections = std::env::var("FRAISEQL_MAX_CONNECTIONS")
209            .ok()
210            .and_then(|s| s.parse().ok())
211            .unwrap_or(10);
212
213        let query_timeout = std::env::var("FRAISEQL_QUERY_TIMEOUT")
214            .ok()
215            .and_then(|s| s.parse().ok())
216            .unwrap_or(30);
217
218        let mut config = Self {
219            server: CoreServerConfig {
220                host: host.clone(),
221                port,
222                ..Default::default()
223            },
224            database: DatabaseConfig {
225                url: database_url.clone(),
226                max_connections,
227                query_timeout_secs: query_timeout,
228                ..Default::default()
229            },
230            // Legacy compat
231            database_url,
232            host,
233            port,
234            max_connections,
235            query_timeout_secs: query_timeout,
236            ..Default::default()
237        };
238
239        // Load optional auth settings from env
240        if let Ok(provider) = std::env::var("FRAISEQL_AUTH_PROVIDER") {
241            config.auth.enabled = true;
242            config.auth.provider = match provider.to_lowercase().as_str() {
243                "jwt" => AuthProvider::Jwt,
244                "auth0" => AuthProvider::Auth0,
245                "clerk" => AuthProvider::Clerk,
246                "webhook" => AuthProvider::Webhook,
247                _ => AuthProvider::None,
248            };
249        }
250
251        if let Ok(secret) = std::env::var("JWT_SECRET") {
252            config.auth.jwt_secret = Some(secret);
253        }
254
255        if let Ok(domain) = std::env::var("AUTH0_DOMAIN") {
256            config.auth.domain = Some(domain);
257        }
258
259        if let Ok(audience) = std::env::var("AUTH0_AUDIENCE") {
260            config.auth.audience = Some(audience);
261        }
262
263        Ok(config)
264    }
265
266    /// Sync legacy flat fields with new structured fields.
267    fn sync_legacy_fields(&mut self) {
268        // If structured database.url is set, use it for legacy field
269        if !self.database.url.is_empty() {
270            self.database_url = self.database.url.clone();
271        } else if !self.database_url.is_empty() {
272            // If legacy field is set, copy to structured
273            self.database.url = self.database_url.clone();
274        }
275
276        // Sync server fields
277        self.host = self.server.host.clone();
278        self.port = self.server.port;
279        self.max_connections = self.database.max_connections;
280        self.query_timeout_secs = self.database.query_timeout_secs;
281    }
282
283    /// Create a test configuration.
284    #[must_use]
285    pub fn test() -> Self {
286        Self {
287            server: CoreServerConfig {
288                host: "127.0.0.1".to_string(),
289                port: 0, // Random port
290                ..Default::default()
291            },
292            database: DatabaseConfig {
293                url: "postgresql://postgres:postgres@localhost:5432/fraiseql_test".to_string(),
294                max_connections: 2,
295                query_timeout_secs: 5,
296                ..Default::default()
297            },
298            // Legacy compat
299            database_url: "postgresql://postgres:postgres@localhost:5432/fraiseql_test".to_string(),
300            host: "127.0.0.1".to_string(),
301            port: 0,
302            max_connections: 2,
303            query_timeout_secs: 5,
304            ..Default::default()
305        }
306    }
307
308    /// Validate the configuration.
309    ///
310    /// # Errors
311    ///
312    /// Returns error if configuration is invalid.
313    pub fn validate(&self) -> Result<()> {
314        // Database URL required
315        if self.database.url.is_empty() && self.database_url.is_empty() {
316            return Err(FraiseQLError::Configuration {
317                message: "database.url is required".to_string(),
318            });
319        }
320
321        // Pool invariants
322        if self.database.max_connections == 0 {
323            return Err(FraiseQLError::Configuration {
324                message: "database.max_connections must be at least 1".to_string(),
325            });
326        }
327        if self.database.min_connections > self.database.max_connections {
328            return Err(FraiseQLError::Configuration {
329                message: format!(
330                    "database.min_connections ({}) must not exceed max_connections ({})",
331                    self.database.min_connections, self.database.max_connections
332                ),
333            });
334        }
335
336        // Server port (0 means "pick a random OS port" which is valid in tests
337        // but not in production; we only reject it if a non-zero port is expected)
338        // Note: port = 0 is allowed by design (OS-assigned). No check added here.
339
340        // Validate auth config
341        if self.auth.enabled {
342            match self.auth.provider {
343                AuthProvider::Jwt => {
344                    if self.auth.jwt_secret.is_none() {
345                        return Err(FraiseQLError::Configuration {
346                            message: "auth.jwt_secret is required when using JWT provider"
347                                .to_string(),
348                        });
349                    }
350                },
351                AuthProvider::Auth0 | AuthProvider::Clerk => {
352                    if self.auth.domain.is_none() {
353                        return Err(FraiseQLError::Configuration {
354                            message: format!(
355                                "auth.domain is required when using {:?} provider",
356                                self.auth.provider
357                            ),
358                        });
359                    }
360                },
361                AuthProvider::Webhook | AuthProvider::None => {},
362            }
363        }
364
365        Ok(())
366    }
367
368    /// Export configuration to TOML string.
369    #[must_use]
370    pub fn to_toml(&self) -> String {
371        toml::to_string_pretty(self).unwrap_or_default()
372    }
373}
374
375/// Expand environment variables in a string.
376///
377/// Supports both `${VAR}` and `$VAR` syntax. The `${VAR}` form is matched
378/// first (higher priority) so that `${FOO}BAR` expands the braced form only.
379#[allow(clippy::expect_used)] // Reason: regex patterns are compile-time constants guaranteed to be valid
380fn expand_env_vars(content: &str) -> String {
381    use std::sync::LazyLock;
382
383    // Matches ${VAR} (braced form)
384    static BRACED_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
385        regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").expect("braced env var regex is valid")
386    });
387
388    // Matches $VAR (bare form). Applied after the braced pass so any ${VAR}
389    // patterns have already been resolved and won't be double-matched.
390    static BARE_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
391        regex::Regex::new(r"\$([A-Za-z_][A-Za-z0-9_]*)").expect("bare env var regex is valid")
392    });
393
394    let expand = |input: &str, re: &regex::Regex| -> String {
395        let mut result = input.to_string();
396        // Collect matches before replacing to avoid offset issues
397        let replacements: Vec<(String, String)> = re
398            .captures_iter(input)
399            .filter_map(|cap| {
400                let full = cap.get(0)?.as_str().to_string();
401                let var_name = cap.get(1)?.as_str();
402                let value = std::env::var(var_name).ok()?;
403                Some((full, value))
404            })
405            .collect();
406        for (pattern, value) in replacements {
407            result = result.replace(&pattern, &value);
408        }
409        result
410    };
411
412    // Expand braced form first, then bare form on the result
413    let after_braced = expand(content, &BRACED_REGEX);
414    expand(&after_braced, &BARE_REGEX)
415}
416
417/// Configuration builder.
418#[must_use = "call .build() to construct the final value"]
419#[derive(Debug, Default)]
420pub struct ConfigBuilder {
421    config: FraiseQLConfig,
422}
423
424impl ConfigBuilder {
425    /// Set the database URL.
426    pub fn database_url(mut self, url: &str) -> Self {
427        self.config.database.url = url.to_string();
428        self.config.database_url = url.to_string();
429        self
430    }
431
432    /// Set the server host.
433    pub fn host(mut self, host: &str) -> Self {
434        self.config.server.host = host.to_string();
435        self.config.host = host.to_string();
436        self
437    }
438
439    /// Set the server port.
440    pub const fn port(mut self, port: u16) -> Self {
441        self.config.server.port = port;
442        self.config.port = port;
443        self
444    }
445
446    /// Set maximum database connections.
447    pub const fn max_connections(mut self, n: u32) -> Self {
448        self.config.database.max_connections = n;
449        self.config.max_connections = n;
450        self
451    }
452
453    /// Set query timeout.
454    pub const fn query_timeout(mut self, secs: u64) -> Self {
455        self.config.database.query_timeout_secs = secs;
456        self.config.query_timeout_secs = secs;
457        self
458    }
459
460    /// Set CORS configuration.
461    pub fn cors(mut self, cors: CorsConfig) -> Self {
462        self.config.cors = cors;
463        self
464    }
465
466    /// Set auth configuration.
467    pub fn auth(mut self, auth: AuthConfig) -> Self {
468        self.config.auth = auth;
469        self
470    }
471
472    /// Set rate limit configuration.
473    pub fn rate_limit(mut self, rate_limit: RateLimitConfig) -> Self {
474        self.config.rate_limit = rate_limit;
475        self
476    }
477
478    /// Set cache configuration.
479    pub const fn cache(mut self, cache: CacheConfig) -> Self {
480        self.config.cache = cache;
481        self
482    }
483
484    /// Set collation configuration.
485    pub fn collation(mut self, collation: CollationConfig) -> Self {
486        self.config.collation = collation;
487        self
488    }
489
490    /// Build the configuration.
491    ///
492    /// # Errors
493    ///
494    /// Returns error if configuration is invalid.
495    pub fn build(mut self) -> Result<FraiseQLConfig> {
496        self.config.sync_legacy_fields();
497        self.config.validate()?;
498        Ok(self.config)
499    }
500}
501
502#[cfg(test)]
503mod tests;