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