pub mod runtime;
pub mod security;
pub mod toml_schema;
use std::path::Path;
use anyhow::{Context, Result};
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,
}
#[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,
}
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(),
}
}
}
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");
self.fraiseql.security.validate()?;
self.fraiseql.tenancy.validate()?;
self.server.validate()?;
self.database.validate()?;
Ok(())
}
}
#[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;