Skip to main content

fraiseql_cli/config/
mod.rs

1//! Configuration loading and management
2//!
3//! This module handles loading configuration from fraiseql.toml files,
4//! including security settings, project metadata, and compilation options.
5
6pub mod runtime;
7pub mod security;
8pub mod toml_schema;
9
10use std::path::Path;
11
12use anyhow::{Context, Result};
13use fraiseql_core::schema::NamingConvention;
14pub use runtime::{DatabaseRuntimeConfig, ServerRuntimeConfig};
15pub use security::SecurityConfig;
16use serde::{Deserialize, Serialize};
17pub use toml_schema::TomlSchema;
18use tracing::info;
19
20/// Project configuration from fraiseql.toml
21#[derive(Debug, Clone, Default, Deserialize, Serialize)]
22#[serde(default, deny_unknown_fields)]
23pub struct TomlProjectConfig {
24    /// Project metadata (name, version, description)
25    #[serde(rename = "project")]
26    pub project: ProjectConfig,
27
28    /// FraiseQL-specific settings
29    #[serde(rename = "fraiseql")]
30    pub fraiseql: FraiseQLSettings,
31
32    /// HTTP server runtime configuration (optional — all fields have defaults).
33    #[serde(default)]
34    pub server: ServerRuntimeConfig,
35
36    /// Database connection pool configuration (optional — all fields have defaults).
37    #[serde(default)]
38    pub database: DatabaseRuntimeConfig,
39}
40
41/// Project metadata
42#[derive(Debug, Clone, Deserialize, Serialize)]
43#[serde(default, deny_unknown_fields)]
44pub struct ProjectConfig {
45    /// Project name
46    pub name:            String,
47    /// Project version
48    pub version:         String,
49    /// Optional project description
50    pub description:     Option<String>,
51    /// Target database backend (e.g. "postgresql", "mysql", "sqlite", "sqlserver")
52    pub database_target: Option<String>,
53}
54
55impl Default for ProjectConfig {
56    fn default() -> Self {
57        Self {
58            name:            "my-fraiseql-app".to_string(),
59            version:         "1.0.0".to_string(),
60            description:     None,
61            database_target: None,
62        }
63    }
64}
65
66/// FraiseQL-specific settings
67#[derive(Debug, Clone, Deserialize, Serialize)]
68#[serde(default, deny_unknown_fields)]
69pub struct FraiseQLSettings {
70    /// Path to the GraphQL schema file
71    pub schema_file:  String,
72    /// Path to the output compiled schema file
73    pub output_file:  String,
74    /// Security configuration
75    #[serde(rename = "security")]
76    pub security:     SecurityConfig,
77    /// Tenancy isolation configuration
78    #[serde(default)]
79    pub tenancy:      security::TenancyTomlConfig,
80    /// Mutation compilation options (`[fraiseql.mutations]`)
81    #[serde(default)]
82    pub mutations:    MutationsConfig,
83    /// Naming/casing options (`[fraiseql.naming]`)
84    #[serde(default)]
85    pub naming:       NamingTomlConfig,
86    /// Per-operation `@cost` weight overrides (`[fraiseql.cost_weights]`, #379):
87    /// root query/mutation name → manual cost weight, consulted by the runtime
88    /// per-tenant cost-budget check. Empty by default.
89    #[serde(default)]
90    pub cost_weights: std::collections::HashMap<String, usize>,
91}
92
93impl Default for FraiseQLSettings {
94    fn default() -> Self {
95        Self {
96            schema_file:  "schema.json".to_string(),
97            output_file:  "schema.compiled.json".to_string(),
98            security:     SecurityConfig::default(),
99            tenancy:      security::TenancyTomlConfig::default(),
100            mutations:    MutationsConfig::default(),
101            naming:       NamingTomlConfig::default(),
102            cost_weights: std::collections::HashMap::new(),
103        }
104    }
105}
106
107/// Naming/casing options from `[fraiseql.naming]`.
108#[derive(Debug, Clone, Deserialize, Serialize)]
109#[serde(default, deny_unknown_fields)]
110pub struct NamingTomlConfig {
111    /// GraphQL surface naming convention for the JSON-schema compile workflow
112    /// (`fraiseql-cli compile schema.json`). Defaults to `camelCase`:
113    /// `snake_case` columns/functions in the database, `camelCase` operation and
114    /// field names exposed to clients, with incoming mutation input keys recased
115    /// `camelCase` → `snake_case` before they reach the SQL functions. Set
116    /// `convention = "preserve"` to keep names exactly as authored (`snake_case`
117    /// on the wire, no recasing). This knob does not apply to the author-in-TOML
118    /// workflow, which carries its own `naming_convention`.
119    pub convention: NamingConvention,
120    /// Acronyms whose internal digit stays attached when a GraphQL field name is
121    /// mapped back to its `snake_case` JSONB key (e.g. `s3`, `ipv4`, `oauth2`),
122    /// added on top of the engine's built-in defaults. Author the atomic form
123    /// (`s3`, not `s_3`). Example: `acronyms = ["s3", "ipv4"]`.
124    pub acronyms:   Vec<String>,
125}
126
127impl Default for NamingTomlConfig {
128    fn default() -> Self {
129        Self {
130            // Workflow-B compiles to a camelCase GraphQL surface by default; see
131            // the `convention` field docs. Override with `convention = "preserve"`.
132            convention: NamingConvention::CamelCase,
133            acronyms:   Vec::new(),
134        }
135    }
136}
137
138/// Mutation compilation options from `[fraiseql.mutations]`.
139#[derive(Debug, Clone, Default, Deserialize, Serialize)]
140#[serde(default, deny_unknown_fields)]
141pub struct MutationsConfig {
142    /// Opt-in: auto-synthesize a shared `MutationError` type and a per-mutation
143    /// result union (`<Mutation>Result = Entity | MutationError`) for every
144    /// object-returning mutation, rewriting its return type to that union so the
145    /// server's success/error discrimination has a union to resolve against.
146    /// Off by default; mutations that already return a union are left untouched.
147    pub auto_error_union: bool,
148}
149
150impl TomlProjectConfig {
151    /// Load configuration from fraiseql.toml file.
152    ///
153    /// Supports `${VAR}` environment variable interpolation throughout the file.
154    ///
155    /// # Errors
156    ///
157    /// Returns an error if the file does not exist, cannot be read, or cannot be
158    /// parsed as valid TOML matching the `TomlProjectConfig` structure.
159    pub fn from_file(path: &str) -> Result<Self> {
160        info!("Loading configuration from {path}");
161
162        let path = Path::new(path);
163        if !path.exists() {
164            anyhow::bail!("Configuration file not found: {}", path.display());
165        }
166
167        let raw = std::fs::read_to_string(path).context("Failed to read fraiseql.toml")?;
168        let toml_content = expand_env_vars(&raw)?;
169
170        let config: TomlProjectConfig = toml::from_str(&toml_content)
171            .map_err(|e| anyhow::anyhow!("Failed to parse fraiseql.toml: {e}"))?;
172
173        Ok(config)
174    }
175
176    /// Validate configuration.
177    ///
178    /// # Errors
179    ///
180    /// Returns an error if any security, server, or database configuration value
181    /// is invalid (e.g. unsupported algorithm, zero window, or bad port range).
182    pub fn validate(&self) -> Result<()> {
183        info!("Validating configuration");
184        self.fraiseql.security.validate()?;
185        self.fraiseql.tenancy.validate()?;
186        self.server.validate()?;
187        self.database.validate()?;
188        Ok(())
189    }
190}
191
192/// Expand `${VAR}` environment variable placeholders in a string.
193///
194/// Unknown variables are left as-is (no panic, silent passthrough).
195#[allow(clippy::expect_used)] // Reason: regex pattern is a compile-time constant guaranteed to be valid
196pub(crate) fn expand_env_vars(content: &str) -> Result<String> {
197    use std::sync::LazyLock;
198
199    static ENV_VAR_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
200        regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").expect("env var regex is valid")
201    });
202
203    let mut result = String::with_capacity(content.len());
204    let mut last_end = 0;
205
206    for cap in ENV_VAR_REGEX.captures_iter(content) {
207        // INVARIANT: Regex captures iterator yields Captures where group 0 (the full match) is
208        // always present
209        let m = cap.get(0).expect("INVARIANT: Regex captures group 0 is always present");
210        result.push_str(&content[last_end..m.start()]);
211        let var_name = &cap[1];
212        match std::env::var(var_name) {
213            Ok(val) => {
214                validate_env_var_value(var_name, &val)?;
215                result.push_str(&val);
216            },
217            Err(_) => {
218                result.push_str(&format!("${{{}}}", var_name));
219            },
220        }
221        last_end = m.end();
222    }
223    result.push_str(&content[last_end..]);
224    Ok(result)
225}
226
227fn validate_env_var_value(var_name: &str, value: &str) -> Result<()> {
228    if value.contains('\n') {
229        anyhow::bail!("Environment variable {} contains newline character", var_name);
230    }
231    if value.contains('\r') {
232        anyhow::bail!("Environment variable {} contains carriage return character", var_name);
233    }
234    if value.contains('\0') {
235        anyhow::bail!("Environment variable {} contains null character", var_name);
236    }
237    // Check for unescaped TOML metacharacters: ", ', \, ], [, {, }
238    if value.contains('"')
239        || value.contains('\'')
240        || value.contains('\\')
241        || value.contains(']')
242        || value.contains('[')
243        || value.contains('{')
244        || value.contains('}')
245    {
246        anyhow::bail!(
247            "Environment variable {} contains TOML metacharacter that could break TOML parsing",
248            var_name
249        );
250    }
251    Ok(())
252}
253
254#[cfg(test)]
255mod tests;