use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::error::{FraiseQLError, Result};
mod auth;
mod cache;
mod cors;
mod database;
mod rate_limit;
mod server;
pub use auth::{AuthConfig, AuthProvider};
pub use cache::CacheConfig;
pub use cors::CorsConfig;
pub use database::{DatabaseConfig, MutationTimingConfig, SslMode};
pub use fraiseql_db::{
CollationConfig, DatabaseCollationOverrides, InvalidLocaleStrategy, MySqlCollationConfig,
PostgresCollationConfig, SqlServerCollationConfig, SqliteCollationConfig,
};
pub use rate_limit::{PathRateLimit, RateLimitConfig, RateLimitKey};
pub use server::CoreServerConfig;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct FraiseQLConfig {
pub server: CoreServerConfig,
pub database: DatabaseConfig,
pub cors: CorsConfig,
pub auth: AuthConfig,
pub rate_limit: RateLimitConfig,
pub cache: CacheConfig,
pub collation: CollationConfig,
#[serde(skip)]
database_url_compat: Option<String>,
#[serde(skip_serializing, default)]
pub database_url: String,
#[serde(skip_serializing, default)]
pub host: String,
#[serde(skip_serializing, default)]
pub port: u16,
#[serde(skip_serializing, default)]
pub max_connections: u32,
#[serde(skip_serializing, default)]
pub query_timeout_secs: u64,
}
impl Default for FraiseQLConfig {
fn default() -> Self {
let server = CoreServerConfig::default();
let database = DatabaseConfig::default();
Self {
database_url: String::new(),
host: server.host.clone(),
port: server.port,
max_connections: database.max_connections,
query_timeout_secs: database.query_timeout_secs,
database_url_compat: None,
server,
database,
cors: CorsConfig::default(),
auth: AuthConfig::default(),
rate_limit: RateLimitConfig::default(),
cache: CacheConfig::default(),
collation: CollationConfig::default(),
}
}
}
impl FraiseQLConfig {
pub fn builder() -> ConfigBuilder {
ConfigBuilder::default()
}
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref();
let content = std::fs::read_to_string(path).map_err(|e| FraiseQLError::Configuration {
message: format!("Failed to read config file '{}': {}", path.display(), e),
})?;
Self::from_toml(&content)
}
pub fn from_toml(content: &str) -> Result<Self> {
let expanded = expand_env_vars(content);
let mut config: Self =
toml::from_str(&expanded).map_err(|e| FraiseQLError::Configuration {
message: format!("Invalid TOML configuration: {e}"),
})?;
config.sync_legacy_fields();
Ok(config)
}
pub fn from_env() -> Result<Self> {
let database_url =
std::env::var("DATABASE_URL").map_err(|_| FraiseQLError::Configuration {
message: "DATABASE_URL not set".to_string(),
})?;
let host = std::env::var("FRAISEQL_HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
let port = std::env::var("FRAISEQL_PORT").ok().and_then(|s| s.parse().ok()).unwrap_or(8000);
let max_connections = std::env::var("FRAISEQL_MAX_CONNECTIONS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(10);
let query_timeout = std::env::var("FRAISEQL_QUERY_TIMEOUT")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(30);
let mut config = Self {
server: CoreServerConfig {
host: host.clone(),
port,
..Default::default()
},
database: DatabaseConfig {
url: database_url.clone(),
max_connections,
query_timeout_secs: query_timeout,
..Default::default()
},
database_url,
host,
port,
max_connections,
query_timeout_secs: query_timeout,
..Default::default()
};
if let Ok(provider) = std::env::var("FRAISEQL_AUTH_PROVIDER") {
config.auth.enabled = true;
config.auth.provider = match provider.to_lowercase().as_str() {
"jwt" => AuthProvider::Jwt,
"auth0" => AuthProvider::Auth0,
"clerk" => AuthProvider::Clerk,
"webhook" => AuthProvider::Webhook,
_ => AuthProvider::None,
};
}
if let Ok(secret) = std::env::var("JWT_SECRET") {
config.auth.jwt_secret = Some(secret);
}
if let Ok(domain) = std::env::var("AUTH0_DOMAIN") {
config.auth.domain = Some(domain);
}
if let Ok(audience) = std::env::var("AUTH0_AUDIENCE") {
config.auth.audience = Some(audience);
}
Ok(config)
}
fn sync_legacy_fields(&mut self) {
if !self.database.url.is_empty() {
self.database_url = self.database.url.clone();
} else if !self.database_url.is_empty() {
self.database.url = self.database_url.clone();
}
self.host = self.server.host.clone();
self.port = self.server.port;
self.max_connections = self.database.max_connections;
self.query_timeout_secs = self.database.query_timeout_secs;
}
#[must_use]
pub fn test() -> Self {
Self {
server: CoreServerConfig {
host: "127.0.0.1".to_string(),
port: 0, ..Default::default()
},
database: DatabaseConfig {
url: "postgresql://postgres:postgres@localhost:5432/fraiseql_test".to_string(),
max_connections: 2,
query_timeout_secs: 5,
..Default::default()
},
database_url: "postgresql://postgres:postgres@localhost:5432/fraiseql_test".to_string(),
host: "127.0.0.1".to_string(),
port: 0,
max_connections: 2,
query_timeout_secs: 5,
..Default::default()
}
}
pub fn validate(&self) -> Result<()> {
if self.database.url.is_empty() && self.database_url.is_empty() {
return Err(FraiseQLError::Configuration {
message: "database.url is required".to_string(),
});
}
if self.database.max_connections == 0 {
return Err(FraiseQLError::Configuration {
message: "database.max_connections must be at least 1".to_string(),
});
}
if self.database.min_connections > self.database.max_connections {
return Err(FraiseQLError::Configuration {
message: format!(
"database.min_connections ({}) must not exceed max_connections ({})",
self.database.min_connections, self.database.max_connections
),
});
}
if self.auth.enabled {
match self.auth.provider {
AuthProvider::Jwt => {
if self.auth.jwt_secret.is_none() {
return Err(FraiseQLError::Configuration {
message: "auth.jwt_secret is required when using JWT provider"
.to_string(),
});
}
},
AuthProvider::Auth0 | AuthProvider::Clerk => {
if self.auth.domain.is_none() {
return Err(FraiseQLError::Configuration {
message: format!(
"auth.domain is required when using {:?} provider",
self.auth.provider
),
});
}
},
AuthProvider::Webhook | AuthProvider::None => {},
}
}
Ok(())
}
#[must_use]
pub fn to_toml(&self) -> String {
toml::to_string_pretty(self).unwrap_or_default()
}
}
#[allow(clippy::expect_used)] fn expand_env_vars(content: &str) -> String {
use std::sync::LazyLock;
static BRACED_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").expect("braced env var regex is valid")
});
static BARE_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"\$([A-Za-z_][A-Za-z0-9_]*)").expect("bare env var regex is valid")
});
let expand = |input: &str, re: ®ex::Regex| -> String {
let mut result = input.to_string();
let replacements: Vec<(String, String)> = re
.captures_iter(input)
.filter_map(|cap| {
let full = cap.get(0)?.as_str().to_string();
let var_name = cap.get(1)?.as_str();
let value = std::env::var(var_name).ok()?;
Some((full, value))
})
.collect();
for (pattern, value) in replacements {
result = result.replace(&pattern, &value);
}
result
};
let after_braced = expand(content, &BRACED_REGEX);
expand(&after_braced, &BARE_REGEX)
}
#[must_use = "call .build() to construct the final value"]
#[derive(Debug, Default)]
pub struct ConfigBuilder {
config: FraiseQLConfig,
}
impl ConfigBuilder {
pub fn database_url(mut self, url: &str) -> Self {
self.config.database.url = url.to_string();
self.config.database_url = url.to_string();
self
}
pub fn host(mut self, host: &str) -> Self {
self.config.server.host = host.to_string();
self.config.host = host.to_string();
self
}
pub const fn port(mut self, port: u16) -> Self {
self.config.server.port = port;
self.config.port = port;
self
}
pub const fn max_connections(mut self, n: u32) -> Self {
self.config.database.max_connections = n;
self.config.max_connections = n;
self
}
pub const fn query_timeout(mut self, secs: u64) -> Self {
self.config.database.query_timeout_secs = secs;
self.config.query_timeout_secs = secs;
self
}
pub fn cors(mut self, cors: CorsConfig) -> Self {
self.config.cors = cors;
self
}
pub fn auth(mut self, auth: AuthConfig) -> Self {
self.config.auth = auth;
self
}
pub fn rate_limit(mut self, rate_limit: RateLimitConfig) -> Self {
self.config.rate_limit = rate_limit;
self
}
pub const fn cache(mut self, cache: CacheConfig) -> Self {
self.config.cache = cache;
self
}
pub fn collation(mut self, collation: CollationConfig) -> Self {
self.config.collation = collation;
self
}
pub fn build(mut self) -> Result<FraiseQLConfig> {
self.config.sync_legacy_fields();
self.config.validate()?;
Ok(self.config)
}
}
#[cfg(test)]
mod tests;