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    /// The functions subsystem's deployment settings (`[functions]`: `module_dir`,
53    /// `dlq_store`), for a schema document an SDK authored; see
54    /// [`toml_schema::FunctionsSettings`].
55    #[serde(default)]
56    pub functions: toml_schema::FunctionsSettings,
57
58    /// The observer backend's deployment settings (`[observers]`: `enabled`, `backend`,
59    /// `redis_url`, `nats_url`), for a schema document an SDK authored; see
60    /// [`toml_schema::ObserversConfig::reconcile`]. The server's `[observers.runtime]`
61    /// sub-table is accepted and left to the server.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub observers: Option<toml_schema::ObserversConfig>,
64}
65
66/// Project metadata
67#[derive(Debug, Clone, Deserialize, Serialize)]
68#[serde(default, deny_unknown_fields)]
69pub struct ProjectConfig {
70    /// Project name
71    pub name:            String,
72    /// Project version
73    pub version:         String,
74    /// Optional project description
75    pub description:     Option<String>,
76    /// Target database backend. Only `"postgresql"` is accepted: MySQL, SQLite and SQL
77    /// Server were removed in v2.15.0 (#374), and [`TomlProjectConfig::validate`] refuses
78    /// them (see `config::validate_database_target`).
79    pub database_target: Option<String>,
80}
81
82impl Default for ProjectConfig {
83    fn default() -> Self {
84        Self {
85            name:            "my-fraiseql-app".to_string(),
86            version:         "1.0.0".to_string(),
87            description:     None,
88            database_target: None,
89        }
90    }
91}
92
93/// FraiseQL-specific settings
94#[derive(Debug, Clone, Deserialize, Serialize)]
95#[serde(default, deny_unknown_fields)]
96pub struct FraiseQLSettings {
97    /// Path to the GraphQL schema file
98    pub schema_file:       String,
99    /// Path to the output compiled schema file
100    pub output_file:       String,
101    /// Security configuration
102    #[serde(rename = "security")]
103    pub security:          SecurityConfig,
104    /// Tenancy isolation configuration
105    #[serde(default)]
106    pub tenancy:           security::TenancyTomlConfig,
107    /// Mutation compilation options (`[fraiseql.mutations]`)
108    #[serde(default)]
109    pub mutations:         MutationsConfig,
110    /// Naming/casing options (`[fraiseql.naming]`)
111    #[serde(default)]
112    pub naming:            NamingTomlConfig,
113    /// Per-operation `@cost` weight overrides (`[fraiseql.cost_weights]`, #379):
114    /// root query/mutation name → manual cost weight, consulted by the runtime
115    /// per-tenant cost-budget check. Empty by default.
116    #[serde(default)]
117    pub cost_weights:      std::collections::HashMap<String, usize>,
118    /// Per-request PostgreSQL session variables (`[fraiseql.session_variables]`).
119    ///
120    /// Applied with `set_config(name, value, true)` before every query and
121    /// mutation, so RLS policies can read them with `current_setting(…)`. This is
122    /// the mechanism database-layer tenant isolation is built on (#628).
123    ///
124    /// Deliberately the same type the runtime consumes — a mirror struct here is
125    /// exactly the producer↔consumer seam that keeps dropping declarations.
126    #[serde(default)]
127    pub session_variables: fraiseql_core::schema::SessionVariablesConfig,
128}
129
130impl Default for FraiseQLSettings {
131    fn default() -> Self {
132        Self {
133            schema_file:       "schema.json".to_string(),
134            output_file:       "schema.compiled.json".to_string(),
135            security:          SecurityConfig::default(),
136            tenancy:           security::TenancyTomlConfig::default(),
137            mutations:         MutationsConfig::default(),
138            naming:            NamingTomlConfig::default(),
139            cost_weights:      std::collections::HashMap::new(),
140            session_variables: fraiseql_core::schema::SessionVariablesConfig::default(),
141        }
142    }
143}
144
145/// Naming/casing options from `[fraiseql.naming]`.
146#[derive(Debug, Clone, Deserialize, Serialize)]
147#[serde(default, deny_unknown_fields)]
148pub struct NamingTomlConfig {
149    /// GraphQL surface naming convention for the JSON-schema compile workflow
150    /// (`fraiseql-cli compile schema.json`). Defaults to `camelCase`:
151    /// `snake_case` columns/functions in the database, `camelCase` operation and
152    /// field names exposed to clients, with incoming mutation input keys recased
153    /// `camelCase` → `snake_case` before they reach the SQL functions. Set
154    /// `convention = "preserve"` to keep names exactly as authored (`snake_case`
155    /// on the wire, no recasing). This knob does not apply to the author-in-TOML
156    /// workflow, which carries its own `naming_convention`.
157    pub convention: NamingConvention,
158    /// Acronyms whose internal digit stays attached when a GraphQL field name is
159    /// mapped back to its `snake_case` JSONB key (e.g. `s3`, `ipv4`, `oauth2`),
160    /// added on top of the engine's built-in defaults. Author the atomic form
161    /// (`s3`, not `s_3`). Example: `acronyms = ["s3", "ipv4"]`.
162    pub acronyms:   Vec<String>,
163}
164
165impl Default for NamingTomlConfig {
166    fn default() -> Self {
167        Self {
168            // Workflow-B compiles to a camelCase GraphQL surface by default; see
169            // the `convention` field docs. Override with `convention = "preserve"`.
170            convention: NamingConvention::CamelCase,
171            acronyms:   Vec::new(),
172        }
173    }
174}
175
176/// Mutation compilation options from `[fraiseql.mutations]`.
177#[derive(Debug, Clone, Default, Deserialize, Serialize)]
178#[serde(default, deny_unknown_fields)]
179pub struct MutationsConfig {
180    /// Opt-in: auto-synthesize a shared `MutationError` type and a per-mutation
181    /// result union (`<Mutation>Result = Entity | MutationError`) for every
182    /// object-returning mutation, rewriting its return type to that union so the
183    /// server's success/error discrimination has a union to resolve against.
184    /// Off by default; mutations that already return a union are left untouched.
185    pub auto_error_union: bool,
186}
187
188impl TomlProjectConfig {
189    /// Load configuration from fraiseql.toml file.
190    ///
191    /// Supports `${VAR}` environment variable interpolation throughout the file.
192    ///
193    /// # Errors
194    ///
195    /// Returns an error if the file does not exist, cannot be read, or cannot be
196    /// parsed as valid TOML matching the `TomlProjectConfig` structure.
197    pub fn from_file(path: &str) -> Result<Self> {
198        info!("Loading configuration from {path}");
199
200        let path = Path::new(path);
201        if !path.exists() {
202            anyhow::bail!("Configuration file not found: {}", path.display());
203        }
204
205        let raw = std::fs::read_to_string(path).context("Failed to read fraiseql.toml")?;
206        let toml_content = expand_env_vars(&raw)?;
207
208        // A file that is not TOML at all is reported by the typed parse below.
209        if let Ok(table) = toml_content.parse::<toml::Table>() {
210            refuse_unapplied_keys(&table)?;
211        }
212
213        let config: TomlProjectConfig = toml::from_str(&toml_content)
214            .map_err(|e| anyhow::anyhow!("Failed to parse fraiseql.toml: {e}"))?;
215
216        Ok(config)
217    }
218
219    /// Validate configuration.
220    ///
221    /// # Errors
222    ///
223    /// Returns an error if `[project] database_target` names anything but PostgreSQL, or
224    /// if any security, server, or database configuration value is invalid (e.g.
225    /// unsupported algorithm, zero window, or bad port range).
226    pub fn validate(&self) -> Result<()> {
227        info!("Validating configuration");
228        if let Some(target) = &self.project.database_target {
229            validate_database_target("[project]", target)?;
230        }
231        self.fraiseql.security.validate()?;
232        self.fraiseql.tenancy.validate()?;
233        self.server.validate()?;
234        self.database.validate()?;
235        Ok(())
236    }
237}
238
239/// Keys `fraiseql.toml` once accepted that `fraiseql run` never applied: the section, the
240/// key, and what to do instead.
241///
242/// Each is refused by name when it is written, even at its old default, so a project
243/// that relied on one learns it never had an effect. Without this, `deny_unknown_fields`
244/// would still refuse them, but as an unknown field, with no word of what to use.
245const UNAPPLIED_KEYS: &[(&str, &str, &str)] = &[
246    (
247        "server",
248        "request_timeout_ms",
249        "set `request_timeout_secs` (in seconds) in the server configuration file, the file \
250         FRAISEQL_CONFIG names",
251    ),
252    ("server", "keep_alive_secs", "remove it: the server has no keep-alive setting"),
253    (
254        "server.cors",
255        "credentials",
256        "remove it: the server never sends `Access-Control-Allow-Credentials`, so a \
257         browser app on another origin cannot send the session cookie; serve the app \
258         from the same origin as the API, or authenticate with a bearer token",
259    ),
260    (
261        "database",
262        "idle_timeout_ms",
263        "remove it: the database pool has no idle timeout",
264    ),
265];
266
267/// Refuse the first key of [`UNAPPLIED_KEYS`] that `table` sets.
268fn refuse_unapplied_keys(table: &toml::Table) -> Result<()> {
269    for (section, key, instead) in UNAPPLIED_KEYS {
270        let mut found = Some(table);
271        for part in section.split('.') {
272            found = found.and_then(|t| t.get(part)).and_then(toml::Value::as_table);
273        }
274        if found.is_some_and(|t| t.contains_key(*key)) {
275            anyhow::bail!(
276                "fraiseql.toml: `[{section}] {key}` was never applied by `fraiseql run` and is \
277                 no longer accepted; {instead}"
278            );
279        }
280    }
281    Ok(())
282}
283
284/// Refuse a `database_target` other than `postgresql` (compared case-insensitively).
285///
286/// The one rule for both keys that carry a target: `[project] database_target`, which
287/// `fraiseql init` writes into every new `fraiseql.toml`, and `[schema] database_target`
288/// in a TOML schema. `section` names the key in the message.
289///
290/// # Errors
291///
292/// Returns an error naming the removed backends and `docs/database-compatibility.md`.
293pub(crate) fn validate_database_target(section: &str, target: &str) -> Result<()> {
294    let target = target.trim();
295    if target.eq_ignore_ascii_case("postgresql") {
296        return Ok(());
297    }
298    anyhow::bail!(
299        "{section} database_target = {target:?} is not supported: FraiseQL targets \
300         PostgreSQL only. The `mysql`, `sqlite` and `sqlserver` backends were removed in \
301         v2.15.0 (#374), so a schema declaring one would compile PostgreSQL SQL. Set \
302         `database_target = \"postgresql\"` or remove the key. See \
303         docs/database-compatibility.md."
304    )
305}
306
307/// Expand `${VAR}` environment variable placeholders in a string.
308///
309/// Unknown variables are left as-is (no panic, silent passthrough).
310#[allow(clippy::expect_used)] // Reason: regex pattern is a compile-time constant guaranteed to be valid
311pub(crate) fn expand_env_vars(content: &str) -> Result<String> {
312    use std::sync::LazyLock;
313
314    static ENV_VAR_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
315        regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").expect("env var regex is valid")
316    });
317
318    let mut result = String::with_capacity(content.len());
319    let mut last_end = 0;
320
321    for cap in ENV_VAR_REGEX.captures_iter(content) {
322        // INVARIANT: Regex captures iterator yields Captures where group 0 (the full match) is
323        // always present
324        let m = cap.get(0).expect("INVARIANT: Regex captures group 0 is always present");
325        result.push_str(&content[last_end..m.start()]);
326        let var_name = &cap[1];
327        match std::env::var(var_name) {
328            Ok(val) => {
329                validate_env_var_value(var_name, &val)?;
330                result.push_str(&val);
331            },
332            Err(_) => {
333                result.push_str(&format!("${{{}}}", var_name));
334            },
335        }
336        last_end = m.end();
337    }
338    result.push_str(&content[last_end..]);
339    Ok(result)
340}
341
342fn validate_env_var_value(var_name: &str, value: &str) -> Result<()> {
343    if value.contains('\n') {
344        anyhow::bail!("Environment variable {} contains newline character", var_name);
345    }
346    if value.contains('\r') {
347        anyhow::bail!("Environment variable {} contains carriage return character", var_name);
348    }
349    if value.contains('\0') {
350        anyhow::bail!("Environment variable {} contains null character", var_name);
351    }
352    // Check for unescaped TOML metacharacters: ", ', \, ], [, {, }
353    if value.contains('"')
354        || value.contains('\'')
355        || value.contains('\\')
356        || value.contains(']')
357        || value.contains('[')
358        || value.contains('{')
359        || value.contains('}')
360    {
361        anyhow::bail!(
362            "Environment variable {} contains TOML metacharacter that could break TOML parsing",
363            var_name
364        );
365    }
366    Ok(())
367}
368
369#[cfg(test)]
370mod tests;