fraiseql_cli/config/
mod.rs1pub mod runtime;
7pub mod security;
8pub mod toml_schema;
9
10use std::path::Path;
11
12use anyhow::{Context, Result};
13use fraiseql_core::schema::NamingConvention;
14pub use runtime::{DatabaseRuntimeConfig, ServerRuntimeConfig};
15pub use security::SecurityConfig;
16use serde::{Deserialize, Serialize};
17pub use toml_schema::TomlSchema;
18use tracing::info;
19
20#[derive(Debug, Clone, Default, Deserialize, Serialize)]
22#[serde(default, deny_unknown_fields)]
23pub struct TomlProjectConfig {
24 #[serde(rename = "project")]
26 pub project: ProjectConfig,
27
28 #[serde(rename = "fraiseql")]
30 pub fraiseql: FraiseQLSettings,
31
32 #[serde(default)]
34 pub server: ServerRuntimeConfig,
35
36 #[serde(default)]
38 pub database: DatabaseRuntimeConfig,
39}
40
41#[derive(Debug, Clone, Deserialize, Serialize)]
43#[serde(default, deny_unknown_fields)]
44pub struct ProjectConfig {
45 pub name: String,
47 pub version: String,
49 pub description: Option<String>,
51 pub database_target: Option<String>,
53}
54
55impl Default for ProjectConfig {
56 fn default() -> Self {
57 Self {
58 name: "my-fraiseql-app".to_string(),
59 version: "1.0.0".to_string(),
60 description: None,
61 database_target: None,
62 }
63 }
64}
65
66#[derive(Debug, Clone, Deserialize, Serialize)]
68#[serde(default, deny_unknown_fields)]
69pub struct FraiseQLSettings {
70 pub schema_file: String,
72 pub output_file: String,
74 #[serde(rename = "security")]
76 pub security: SecurityConfig,
77 #[serde(default)]
79 pub tenancy: security::TenancyTomlConfig,
80 #[serde(default)]
82 pub mutations: MutationsConfig,
83 #[serde(default)]
85 pub naming: NamingTomlConfig,
86 #[serde(default)]
90 pub cost_weights: std::collections::HashMap<String, usize>,
91 #[serde(default)]
100 pub session_variables: fraiseql_core::schema::SessionVariablesConfig,
101}
102
103impl Default for FraiseQLSettings {
104 fn default() -> Self {
105 Self {
106 schema_file: "schema.json".to_string(),
107 output_file: "schema.compiled.json".to_string(),
108 security: SecurityConfig::default(),
109 tenancy: security::TenancyTomlConfig::default(),
110 mutations: MutationsConfig::default(),
111 naming: NamingTomlConfig::default(),
112 cost_weights: std::collections::HashMap::new(),
113 session_variables: fraiseql_core::schema::SessionVariablesConfig::default(),
114 }
115 }
116}
117
118#[derive(Debug, Clone, Deserialize, Serialize)]
120#[serde(default, deny_unknown_fields)]
121pub struct NamingTomlConfig {
122 pub convention: NamingConvention,
131 pub acronyms: Vec<String>,
136}
137
138impl Default for NamingTomlConfig {
139 fn default() -> Self {
140 Self {
141 convention: NamingConvention::CamelCase,
144 acronyms: Vec::new(),
145 }
146 }
147}
148
149#[derive(Debug, Clone, Default, Deserialize, Serialize)]
151#[serde(default, deny_unknown_fields)]
152pub struct MutationsConfig {
153 pub auto_error_union: bool,
159}
160
161impl TomlProjectConfig {
162 pub fn from_file(path: &str) -> Result<Self> {
171 info!("Loading configuration from {path}");
172
173 let path = Path::new(path);
174 if !path.exists() {
175 anyhow::bail!("Configuration file not found: {}", path.display());
176 }
177
178 let raw = std::fs::read_to_string(path).context("Failed to read fraiseql.toml")?;
179 let toml_content = expand_env_vars(&raw)?;
180
181 let config: TomlProjectConfig = toml::from_str(&toml_content)
182 .map_err(|e| anyhow::anyhow!("Failed to parse fraiseql.toml: {e}"))?;
183
184 Ok(config)
185 }
186
187 pub fn validate(&self) -> Result<()> {
194 info!("Validating configuration");
195 self.fraiseql.security.validate()?;
196 self.fraiseql.tenancy.validate()?;
197 self.server.validate()?;
198 self.database.validate()?;
199 Ok(())
200 }
201}
202
203#[allow(clippy::expect_used)] pub(crate) fn expand_env_vars(content: &str) -> Result<String> {
208 use std::sync::LazyLock;
209
210 static ENV_VAR_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
211 regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").expect("env var regex is valid")
212 });
213
214 let mut result = String::with_capacity(content.len());
215 let mut last_end = 0;
216
217 for cap in ENV_VAR_REGEX.captures_iter(content) {
218 let m = cap.get(0).expect("INVARIANT: Regex captures group 0 is always present");
221 result.push_str(&content[last_end..m.start()]);
222 let var_name = &cap[1];
223 match std::env::var(var_name) {
224 Ok(val) => {
225 validate_env_var_value(var_name, &val)?;
226 result.push_str(&val);
227 },
228 Err(_) => {
229 result.push_str(&format!("${{{}}}", var_name));
230 },
231 }
232 last_end = m.end();
233 }
234 result.push_str(&content[last_end..]);
235 Ok(result)
236}
237
238fn validate_env_var_value(var_name: &str, value: &str) -> Result<()> {
239 if value.contains('\n') {
240 anyhow::bail!("Environment variable {} contains newline character", var_name);
241 }
242 if value.contains('\r') {
243 anyhow::bail!("Environment variable {} contains carriage return character", var_name);
244 }
245 if value.contains('\0') {
246 anyhow::bail!("Environment variable {} contains null character", var_name);
247 }
248 if value.contains('"')
250 || value.contains('\'')
251 || value.contains('\\')
252 || value.contains(']')
253 || value.contains('[')
254 || value.contains('{')
255 || value.contains('}')
256 {
257 anyhow::bail!(
258 "Environment variable {} contains TOML metacharacter that could break TOML parsing",
259 var_name
260 );
261 }
262 Ok(())
263}
264
265#[cfg(test)]
266mod tests;