1use 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};
60pub 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#[derive(Debug, Clone, Serialize, Deserialize)]
79#[serde(default)]
80pub struct FraiseQLConfig {
81 pub server: CoreServerConfig,
83
84 pub database: DatabaseConfig,
86
87 pub cors: CorsConfig,
89
90 pub auth: AuthConfig,
92
93 pub rate_limit: RateLimitConfig,
95
96 pub cache: CacheConfig,
98
99 pub collation: CollationConfig,
101
102 #[serde(skip)]
104 database_url_compat: Option<String>,
105
106 #[serde(skip_serializing, default)]
108 pub database_url: String,
109
110 #[serde(skip_serializing, default)]
112 pub host: String,
113
114 #[serde(skip_serializing, default)]
116 pub port: u16,
117
118 #[serde(skip_serializing, default)]
120 pub max_connections: u32,
121
122 #[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 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 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 pub fn builder() -> ConfigBuilder {
156 ConfigBuilder::default()
157 }
158
159 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 pub fn from_toml(content: &str) -> Result<Self> {
179 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 config.sync_legacy_fields();
189
190 Ok(config)
191 }
192
193 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 database_url,
232 host,
233 port,
234 max_connections,
235 query_timeout_secs: query_timeout,
236 ..Default::default()
237 };
238
239 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 fn sync_legacy_fields(&mut self) {
268 if !self.database.url.is_empty() {
270 self.database_url = self.database.url.clone();
271 } else if !self.database_url.is_empty() {
272 self.database.url = self.database_url.clone();
274 }
275
276 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 #[must_use]
285 pub fn test() -> Self {
286 Self {
287 server: CoreServerConfig {
288 host: "127.0.0.1".to_string(),
289 port: 0, ..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 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 pub fn validate(&self) -> Result<()> {
314 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 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 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 #[must_use]
370 pub fn to_toml(&self) -> String {
371 toml::to_string_pretty(self).unwrap_or_default()
372 }
373}
374
375#[allow(clippy::expect_used)] fn expand_env_vars(content: &str) -> String {
381 use std::sync::LazyLock;
382
383 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 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: ®ex::Regex| -> String {
395 let mut result = input.to_string();
396 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 let after_braced = expand(content, &BRACED_REGEX);
414 expand(&after_braced, &BARE_REGEX)
415}
416
417#[must_use = "call .build() to construct the final value"]
419#[derive(Debug, Default)]
420pub struct ConfigBuilder {
421 config: FraiseQLConfig,
422}
423
424impl ConfigBuilder {
425 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 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 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 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 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 pub fn cors(mut self, cors: CorsConfig) -> Self {
462 self.config.cors = cors;
463 self
464 }
465
466 pub fn auth(mut self, auth: AuthConfig) -> Self {
468 self.config.auth = auth;
469 self
470 }
471
472 pub fn rate_limit(mut self, rate_limit: RateLimitConfig) -> Self {
474 self.config.rate_limit = rate_limit;
475 self
476 }
477
478 pub const fn cache(mut self, cache: CacheConfig) -> Self {
480 self.config.cache = cache;
481 self
482 }
483
484 pub fn collation(mut self, collation: CollationConfig) -> Self {
486 self.config.collation = collation;
487 self
488 }
489
490 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;