1pub 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#[derive(Debug, Clone, Default, Deserialize, Serialize)]
25#[serde(default, deny_unknown_fields)]
26pub struct TomlProjectConfig {
27 #[serde(rename = "project")]
29 pub project: ProjectConfig,
30
31 #[serde(rename = "fraiseql")]
33 pub fraiseql: FraiseQLSettings,
34
35 #[serde(default)]
37 pub server: ServerRuntimeConfig,
38
39 #[serde(default)]
41 pub database: DatabaseRuntimeConfig,
42
43 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub inject_defaults: Option<inject_defaults::InjectDefaultsToml>,
47
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub locale: Option<fraiseql_core::schema::LocaleConfig>,
51
52 #[serde(default)]
56 pub functions: toml_schema::FunctionsSettings,
57
58 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub observers: Option<toml_schema::ObserversConfig>,
64}
65
66#[derive(Debug, Clone, Deserialize, Serialize)]
68#[serde(default, deny_unknown_fields)]
69pub struct ProjectConfig {
70 pub name: String,
72 pub version: String,
74 pub description: Option<String>,
76 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#[derive(Debug, Clone, Deserialize, Serialize)]
95#[serde(default, deny_unknown_fields)]
96pub struct FraiseQLSettings {
97 pub schema_file: String,
99 pub output_file: String,
101 #[serde(rename = "security")]
103 pub security: SecurityConfig,
104 #[serde(default)]
106 pub tenancy: security::TenancyTomlConfig,
107 #[serde(default)]
109 pub mutations: MutationsConfig,
110 #[serde(default)]
112 pub naming: NamingTomlConfig,
113 #[serde(default)]
117 pub cost_weights: std::collections::HashMap<String, usize>,
118 #[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#[derive(Debug, Clone, Deserialize, Serialize)]
147#[serde(default, deny_unknown_fields)]
148pub struct NamingTomlConfig {
149 pub convention: NamingConvention,
158 pub acronyms: Vec<String>,
163}
164
165impl Default for NamingTomlConfig {
166 fn default() -> Self {
167 Self {
168 convention: NamingConvention::CamelCase,
171 acronyms: Vec::new(),
172 }
173 }
174}
175
176#[derive(Debug, Clone, Default, Deserialize, Serialize)]
178#[serde(default, deny_unknown_fields)]
179pub struct MutationsConfig {
180 pub auto_error_union: bool,
186}
187
188impl TomlProjectConfig {
189 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 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 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
239const 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
267fn 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
284pub(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#[allow(clippy::expect_used)] pub(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 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 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;