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    /// Per-request PostgreSQL session variables (`[fraiseql.session_variables]`).
92    ///
93    /// Applied with `set_config(name, value, true)` before every query and
94    /// mutation, so RLS policies can read them with `current_setting(…)`. This is
95    /// the mechanism database-layer tenant isolation is built on (#628).
96    ///
97    /// Deliberately the same type the runtime consumes — a mirror struct here is
98    /// exactly the producer↔consumer seam that keeps dropping declarations.
99    #[serde(default)]
100    pub session_variables: fraiseql_core::schema::SessionVariablesConfig,
101}
102
103impl Default for FraiseQLSettings {
104    fn default() -> Self {
105        Self {
106            schema_file:       "schema.json".to_string(),
107            output_file:       "schema.compiled.json".to_string(),
108            security:          SecurityConfig::default(),
109            tenancy:           security::TenancyTomlConfig::default(),
110            mutations:         MutationsConfig::default(),
111            naming:            NamingTomlConfig::default(),
112            cost_weights:      std::collections::HashMap::new(),
113            session_variables: fraiseql_core::schema::SessionVariablesConfig::default(),
114        }
115    }
116}
117
118/// Naming/casing options from `[fraiseql.naming]`.
119#[derive(Debug, Clone, Deserialize, Serialize)]
120#[serde(default, deny_unknown_fields)]
121pub struct NamingTomlConfig {
122    /// GraphQL surface naming convention for the JSON-schema compile workflow
123    /// (`fraiseql-cli compile schema.json`). Defaults to `camelCase`:
124    /// `snake_case` columns/functions in the database, `camelCase` operation and
125    /// field names exposed to clients, with incoming mutation input keys recased
126    /// `camelCase` → `snake_case` before they reach the SQL functions. Set
127    /// `convention = "preserve"` to keep names exactly as authored (`snake_case`
128    /// on the wire, no recasing). This knob does not apply to the author-in-TOML
129    /// workflow, which carries its own `naming_convention`.
130    pub convention: NamingConvention,
131    /// Acronyms whose internal digit stays attached when a GraphQL field name is
132    /// mapped back to its `snake_case` JSONB key (e.g. `s3`, `ipv4`, `oauth2`),
133    /// added on top of the engine's built-in defaults. Author the atomic form
134    /// (`s3`, not `s_3`). Example: `acronyms = ["s3", "ipv4"]`.
135    pub acronyms:   Vec<String>,
136}
137
138impl Default for NamingTomlConfig {
139    fn default() -> Self {
140        Self {
141            // Workflow-B compiles to a camelCase GraphQL surface by default; see
142            // the `convention` field docs. Override with `convention = "preserve"`.
143            convention: NamingConvention::CamelCase,
144            acronyms:   Vec::new(),
145        }
146    }
147}
148
149/// Mutation compilation options from `[fraiseql.mutations]`.
150#[derive(Debug, Clone, Default, Deserialize, Serialize)]
151#[serde(default, deny_unknown_fields)]
152pub struct MutationsConfig {
153    /// Opt-in: auto-synthesize a shared `MutationError` type and a per-mutation
154    /// result union (`<Mutation>Result = Entity | MutationError`) for every
155    /// object-returning mutation, rewriting its return type to that union so the
156    /// server's success/error discrimination has a union to resolve against.
157    /// Off by default; mutations that already return a union are left untouched.
158    pub auto_error_union: bool,
159}
160
161impl TomlProjectConfig {
162    /// Load configuration from fraiseql.toml file.
163    ///
164    /// Supports `${VAR}` environment variable interpolation throughout the file.
165    ///
166    /// # Errors
167    ///
168    /// Returns an error if the file does not exist, cannot be read, or cannot be
169    /// parsed as valid TOML matching the `TomlProjectConfig` structure.
170    pub fn from_file(path: &str) -> Result<Self> {
171        info!("Loading configuration from {path}");
172
173        let path = Path::new(path);
174        if !path.exists() {
175            anyhow::bail!("Configuration file not found: {}", path.display());
176        }
177
178        let raw = std::fs::read_to_string(path).context("Failed to read fraiseql.toml")?;
179        let toml_content = expand_env_vars(&raw)?;
180
181        let config: TomlProjectConfig = toml::from_str(&toml_content)
182            .map_err(|e| anyhow::anyhow!("Failed to parse fraiseql.toml: {e}"))?;
183
184        Ok(config)
185    }
186
187    /// Validate configuration.
188    ///
189    /// # Errors
190    ///
191    /// Returns an error if any security, server, or database configuration value
192    /// is invalid (e.g. unsupported algorithm, zero window, or bad port range).
193    pub fn validate(&self) -> Result<()> {
194        info!("Validating configuration");
195        self.fraiseql.security.validate()?;
196        self.fraiseql.tenancy.validate()?;
197        self.server.validate()?;
198        self.database.validate()?;
199        Ok(())
200    }
201}
202
203/// Expand `${VAR}` environment variable placeholders in a string.
204///
205/// Unknown variables are left as-is (no panic, silent passthrough).
206#[allow(clippy::expect_used)] // Reason: regex pattern is a compile-time constant guaranteed to be valid
207pub(crate) fn expand_env_vars(content: &str) -> Result<String> {
208    use std::sync::LazyLock;
209
210    static ENV_VAR_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
211        regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").expect("env var regex is valid")
212    });
213
214    let mut result = String::with_capacity(content.len());
215    let mut last_end = 0;
216
217    for cap in ENV_VAR_REGEX.captures_iter(content) {
218        // INVARIANT: Regex captures iterator yields Captures where group 0 (the full match) is
219        // always present
220        let m = cap.get(0).expect("INVARIANT: Regex captures group 0 is always present");
221        result.push_str(&content[last_end..m.start()]);
222        let var_name = &cap[1];
223        match std::env::var(var_name) {
224            Ok(val) => {
225                validate_env_var_value(var_name, &val)?;
226                result.push_str(&val);
227            },
228            Err(_) => {
229                result.push_str(&format!("${{{}}}", var_name));
230            },
231        }
232        last_end = m.end();
233    }
234    result.push_str(&content[last_end..]);
235    Ok(result)
236}
237
238fn validate_env_var_value(var_name: &str, value: &str) -> Result<()> {
239    if value.contains('\n') {
240        anyhow::bail!("Environment variable {} contains newline character", var_name);
241    }
242    if value.contains('\r') {
243        anyhow::bail!("Environment variable {} contains carriage return character", var_name);
244    }
245    if value.contains('\0') {
246        anyhow::bail!("Environment variable {} contains null character", var_name);
247    }
248    // Check for unescaped TOML metacharacters: ", ', \, ], [, {, }
249    if value.contains('"')
250        || value.contains('\'')
251        || value.contains('\\')
252        || value.contains(']')
253        || value.contains('[')
254        || value.contains('{')
255        || value.contains('}')
256    {
257        anyhow::bail!(
258            "Environment variable {} contains TOML metacharacter that could break TOML parsing",
259            var_name
260        );
261    }
262    Ok(())
263}
264
265#[cfg(test)]
266mod tests;