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
49#[derive(Debug, Clone, Deserialize, Serialize)]
51#[serde(default, deny_unknown_fields)]
52pub struct ProjectConfig {
53 pub name: String,
55 pub version: String,
57 pub description: Option<String>,
59 pub database_target: Option<String>,
63}
64
65impl Default for ProjectConfig {
66 fn default() -> Self {
67 Self {
68 name: "my-fraiseql-app".to_string(),
69 version: "1.0.0".to_string(),
70 description: None,
71 database_target: None,
72 }
73 }
74}
75
76#[derive(Debug, Clone, Deserialize, Serialize)]
78#[serde(default, deny_unknown_fields)]
79pub struct FraiseQLSettings {
80 pub schema_file: String,
82 pub output_file: String,
84 #[serde(rename = "security")]
86 pub security: SecurityConfig,
87 #[serde(default)]
89 pub tenancy: security::TenancyTomlConfig,
90 #[serde(default)]
92 pub mutations: MutationsConfig,
93 #[serde(default)]
95 pub naming: NamingTomlConfig,
96 #[serde(default)]
100 pub cost_weights: std::collections::HashMap<String, usize>,
101 #[serde(default)]
110 pub session_variables: fraiseql_core::schema::SessionVariablesConfig,
111}
112
113impl Default for FraiseQLSettings {
114 fn default() -> Self {
115 Self {
116 schema_file: "schema.json".to_string(),
117 output_file: "schema.compiled.json".to_string(),
118 security: SecurityConfig::default(),
119 tenancy: security::TenancyTomlConfig::default(),
120 mutations: MutationsConfig::default(),
121 naming: NamingTomlConfig::default(),
122 cost_weights: std::collections::HashMap::new(),
123 session_variables: fraiseql_core::schema::SessionVariablesConfig::default(),
124 }
125 }
126}
127
128#[derive(Debug, Clone, Deserialize, Serialize)]
130#[serde(default, deny_unknown_fields)]
131pub struct NamingTomlConfig {
132 pub convention: NamingConvention,
141 pub acronyms: Vec<String>,
146}
147
148impl Default for NamingTomlConfig {
149 fn default() -> Self {
150 Self {
151 convention: NamingConvention::CamelCase,
154 acronyms: Vec::new(),
155 }
156 }
157}
158
159#[derive(Debug, Clone, Default, Deserialize, Serialize)]
161#[serde(default, deny_unknown_fields)]
162pub struct MutationsConfig {
163 pub auto_error_union: bool,
169}
170
171impl TomlProjectConfig {
172 pub fn from_file(path: &str) -> Result<Self> {
181 info!("Loading configuration from {path}");
182
183 let path = Path::new(path);
184 if !path.exists() {
185 anyhow::bail!("Configuration file not found: {}", path.display());
186 }
187
188 let raw = std::fs::read_to_string(path).context("Failed to read fraiseql.toml")?;
189 let toml_content = expand_env_vars(&raw)?;
190
191 let config: TomlProjectConfig = toml::from_str(&toml_content)
192 .map_err(|e| anyhow::anyhow!("Failed to parse fraiseql.toml: {e}"))?;
193
194 Ok(config)
195 }
196
197 pub fn validate(&self) -> Result<()> {
205 info!("Validating configuration");
206 if let Some(target) = &self.project.database_target {
207 validate_database_target("[project]", target)?;
208 }
209 self.fraiseql.security.validate()?;
210 self.fraiseql.tenancy.validate()?;
211 self.server.validate()?;
212 self.database.validate()?;
213 Ok(())
214 }
215}
216
217pub(crate) fn validate_database_target(section: &str, target: &str) -> Result<()> {
227 let target = target.trim();
228 if target.eq_ignore_ascii_case("postgresql") {
229 return Ok(());
230 }
231 anyhow::bail!(
232 "{section} database_target = {target:?} is not supported: FraiseQL targets \
233 PostgreSQL only. The `mysql`, `sqlite` and `sqlserver` backends were removed in \
234 v2.15.0 (#374), so a schema declaring one would compile PostgreSQL SQL. Set \
235 `database_target = \"postgresql\"` or remove the key. See \
236 docs/database-compatibility.md."
237 )
238}
239
240#[allow(clippy::expect_used)] pub(crate) fn expand_env_vars(content: &str) -> Result<String> {
245 use std::sync::LazyLock;
246
247 static ENV_VAR_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
248 regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").expect("env var regex is valid")
249 });
250
251 let mut result = String::with_capacity(content.len());
252 let mut last_end = 0;
253
254 for cap in ENV_VAR_REGEX.captures_iter(content) {
255 let m = cap.get(0).expect("INVARIANT: Regex captures group 0 is always present");
258 result.push_str(&content[last_end..m.start()]);
259 let var_name = &cap[1];
260 match std::env::var(var_name) {
261 Ok(val) => {
262 validate_env_var_value(var_name, &val)?;
263 result.push_str(&val);
264 },
265 Err(_) => {
266 result.push_str(&format!("${{{}}}", var_name));
267 },
268 }
269 last_end = m.end();
270 }
271 result.push_str(&content[last_end..]);
272 Ok(result)
273}
274
275fn validate_env_var_value(var_name: &str, value: &str) -> Result<()> {
276 if value.contains('\n') {
277 anyhow::bail!("Environment variable {} contains newline character", var_name);
278 }
279 if value.contains('\r') {
280 anyhow::bail!("Environment variable {} contains carriage return character", var_name);
281 }
282 if value.contains('\0') {
283 anyhow::bail!("Environment variable {} contains null character", var_name);
284 }
285 if value.contains('"')
287 || value.contains('\'')
288 || value.contains('\\')
289 || value.contains(']')
290 || value.contains('[')
291 || value.contains('{')
292 || value.contains('}')
293 {
294 anyhow::bail!(
295 "Environment variable {} contains TOML metacharacter that could break TOML parsing",
296 var_name
297 );
298 }
299 Ok(())
300}
301
302#[cfg(test)]
303mod tests;