pub mod discovery;
pub mod inject_defaults;
pub mod runtime;
pub mod security;
pub mod toml_schema;
use std::path::Path;
use anyhow::{Context, Result};
pub use discovery::ConfigSource;
use fraiseql_core::schema::NamingConvention;
pub use runtime::{DatabaseRuntimeConfig, ServerRuntimeConfig};
pub use security::SecurityConfig;
use serde::{Deserialize, Serialize};
pub use toml_schema::TomlSchema;
use tracing::info;
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct TomlProjectConfig {
#[serde(rename = "project")]
pub project: ProjectConfig,
#[serde(rename = "fraiseql")]
pub fraiseql: FraiseQLSettings,
#[serde(default)]
pub server: ServerRuntimeConfig,
#[serde(default)]
pub database: DatabaseRuntimeConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inject_defaults: Option<inject_defaults::InjectDefaultsToml>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct ProjectConfig {
pub name: String,
pub version: String,
pub description: Option<String>,
pub database_target: Option<String>,
}
impl Default for ProjectConfig {
fn default() -> Self {
Self {
name: "my-fraiseql-app".to_string(),
version: "1.0.0".to_string(),
description: None,
database_target: None,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct FraiseQLSettings {
pub schema_file: String,
pub output_file: String,
#[serde(rename = "security")]
pub security: SecurityConfig,
#[serde(default)]
pub tenancy: security::TenancyTomlConfig,
#[serde(default)]
pub mutations: MutationsConfig,
#[serde(default)]
pub naming: NamingTomlConfig,
#[serde(default)]
pub cost_weights: std::collections::HashMap<String, usize>,
#[serde(default)]
pub session_variables: fraiseql_core::schema::SessionVariablesConfig,
}
impl Default for FraiseQLSettings {
fn default() -> Self {
Self {
schema_file: "schema.json".to_string(),
output_file: "schema.compiled.json".to_string(),
security: SecurityConfig::default(),
tenancy: security::TenancyTomlConfig::default(),
mutations: MutationsConfig::default(),
naming: NamingTomlConfig::default(),
cost_weights: std::collections::HashMap::new(),
session_variables: fraiseql_core::schema::SessionVariablesConfig::default(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct NamingTomlConfig {
pub convention: NamingConvention,
pub acronyms: Vec<String>,
}
impl Default for NamingTomlConfig {
fn default() -> Self {
Self {
convention: NamingConvention::CamelCase,
acronyms: Vec::new(),
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct MutationsConfig {
pub auto_error_union: bool,
}
impl TomlProjectConfig {
pub fn from_file(path: &str) -> Result<Self> {
info!("Loading configuration from {path}");
let path = Path::new(path);
if !path.exists() {
anyhow::bail!("Configuration file not found: {}", path.display());
}
let raw = std::fs::read_to_string(path).context("Failed to read fraiseql.toml")?;
let toml_content = expand_env_vars(&raw)?;
let config: TomlProjectConfig = toml::from_str(&toml_content)
.map_err(|e| anyhow::anyhow!("Failed to parse fraiseql.toml: {e}"))?;
Ok(config)
}
pub fn validate(&self) -> Result<()> {
info!("Validating configuration");
if let Some(target) = &self.project.database_target {
validate_database_target("[project]", target)?;
}
self.fraiseql.security.validate()?;
self.fraiseql.tenancy.validate()?;
self.server.validate()?;
self.database.validate()?;
Ok(())
}
}
pub(crate) fn validate_database_target(section: &str, target: &str) -> Result<()> {
let target = target.trim();
if target.eq_ignore_ascii_case("postgresql") {
return Ok(());
}
anyhow::bail!(
"{section} database_target = {target:?} is not supported: FraiseQL targets \
PostgreSQL only. The `mysql`, `sqlite` and `sqlserver` backends were removed in \
v2.15.0 (#374), so a schema declaring one would compile PostgreSQL SQL. Set \
`database_target = \"postgresql\"` or remove the key. See \
docs/database-compatibility.md."
)
}
#[allow(clippy::expect_used)] pub(crate) fn expand_env_vars(content: &str) -> Result<String> {
use std::sync::LazyLock;
static ENV_VAR_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").expect("env var regex is valid")
});
let mut result = String::with_capacity(content.len());
let mut last_end = 0;
for cap in ENV_VAR_REGEX.captures_iter(content) {
let m = cap.get(0).expect("INVARIANT: Regex captures group 0 is always present");
result.push_str(&content[last_end..m.start()]);
let var_name = &cap[1];
match std::env::var(var_name) {
Ok(val) => {
validate_env_var_value(var_name, &val)?;
result.push_str(&val);
},
Err(_) => {
result.push_str(&format!("${{{}}}", var_name));
},
}
last_end = m.end();
}
result.push_str(&content[last_end..]);
Ok(result)
}
fn validate_env_var_value(var_name: &str, value: &str) -> Result<()> {
if value.contains('\n') {
anyhow::bail!("Environment variable {} contains newline character", var_name);
}
if value.contains('\r') {
anyhow::bail!("Environment variable {} contains carriage return character", var_name);
}
if value.contains('\0') {
anyhow::bail!("Environment variable {} contains null character", var_name);
}
if value.contains('"')
|| value.contains('\'')
|| value.contains('\\')
|| value.contains(']')
|| value.contains('[')
|| value.contains('{')
|| value.contains('}')
{
anyhow::bail!(
"Environment variable {} contains TOML metacharacter that could break TOML parsing",
var_name
);
}
Ok(())
}
#[cfg(test)]
mod tests;