fraiseql_cli/config/
mod.rs1pub 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
53#[derive(Debug, Clone, Deserialize, Serialize)]
55#[serde(default, deny_unknown_fields)]
56pub struct ProjectConfig {
57 pub name: String,
59 pub version: String,
61 pub description: Option<String>,
63 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#[derive(Debug, Clone, Deserialize, Serialize)]
82#[serde(default, deny_unknown_fields)]
83pub struct FraiseQLSettings {
84 pub schema_file: String,
86 pub output_file: String,
88 #[serde(rename = "security")]
90 pub security: SecurityConfig,
91 #[serde(default)]
93 pub tenancy: security::TenancyTomlConfig,
94 #[serde(default)]
96 pub mutations: MutationsConfig,
97 #[serde(default)]
99 pub naming: NamingTomlConfig,
100 #[serde(default)]
104 pub cost_weights: std::collections::HashMap<String, usize>,
105 #[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#[derive(Debug, Clone, Deserialize, Serialize)]
134#[serde(default, deny_unknown_fields)]
135pub struct NamingTomlConfig {
136 pub convention: NamingConvention,
145 pub acronyms: Vec<String>,
150}
151
152impl Default for NamingTomlConfig {
153 fn default() -> Self {
154 Self {
155 convention: NamingConvention::CamelCase,
158 acronyms: Vec::new(),
159 }
160 }
161}
162
163#[derive(Debug, Clone, Default, Deserialize, Serialize)]
165#[serde(default, deny_unknown_fields)]
166pub struct MutationsConfig {
167 pub auto_error_union: bool,
173}
174
175impl TomlProjectConfig {
176 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 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
221pub(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#[allow(clippy::expect_used)] pub(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 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 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;