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