fraiseql_cli/config/
mod.rs1pub mod runtime;
7pub mod security;
8pub mod toml_schema;
9
10use std::path::Path;
11
12use anyhow::{Context, Result};
13pub use runtime::{DatabaseRuntimeConfig, ServerRuntimeConfig};
14pub use security::SecurityConfig;
15use serde::{Deserialize, Serialize};
16pub use toml_schema::TomlSchema;
17use tracing::info;
18
19#[derive(Debug, Clone, Default, Deserialize, Serialize)]
21#[serde(default, deny_unknown_fields)]
22pub struct TomlProjectConfig {
23 #[serde(rename = "project")]
25 pub project: ProjectConfig,
26
27 #[serde(rename = "fraiseql")]
29 pub fraiseql: FraiseQLSettings,
30
31 #[serde(default)]
33 pub server: ServerRuntimeConfig,
34
35 #[serde(default)]
37 pub database: DatabaseRuntimeConfig,
38}
39
40#[derive(Debug, Clone, Deserialize, Serialize)]
42#[serde(default, deny_unknown_fields)]
43pub struct ProjectConfig {
44 pub name: String,
46 pub version: String,
48 pub description: Option<String>,
50 pub database_target: Option<String>,
52}
53
54impl Default for ProjectConfig {
55 fn default() -> Self {
56 Self {
57 name: "my-fraiseql-app".to_string(),
58 version: "1.0.0".to_string(),
59 description: None,
60 database_target: None,
61 }
62 }
63}
64
65#[derive(Debug, Clone, Deserialize, Serialize)]
67#[serde(default, deny_unknown_fields)]
68pub struct FraiseQLSettings {
69 pub schema_file: String,
71 pub output_file: String,
73 #[serde(rename = "security")]
75 pub security: SecurityConfig,
76}
77
78impl Default for FraiseQLSettings {
79 fn default() -> Self {
80 Self {
81 schema_file: "schema.json".to_string(),
82 output_file: "schema.compiled.json".to_string(),
83 security: SecurityConfig::default(),
84 }
85 }
86}
87
88impl TomlProjectConfig {
89 pub fn from_file(path: &str) -> Result<Self> {
98 info!("Loading configuration from {path}");
99
100 let path = Path::new(path);
101 if !path.exists() {
102 anyhow::bail!("Configuration file not found: {}", path.display());
103 }
104
105 let raw = std::fs::read_to_string(path).context("Failed to read fraiseql.toml")?;
106 let toml_content = expand_env_vars(&raw);
107
108 let config: TomlProjectConfig = toml::from_str(&toml_content)
109 .map_err(|e| anyhow::anyhow!("Failed to parse fraiseql.toml: {e}"))?;
110
111 Ok(config)
112 }
113
114 pub fn validate(&self) -> Result<()> {
121 info!("Validating configuration");
122 self.fraiseql.security.validate()?;
123 self.server.validate()?;
124 self.database.validate()?;
125 Ok(())
126 }
127}
128
129#[allow(clippy::expect_used)] pub(crate) fn expand_env_vars(content: &str) -> String {
134 use std::sync::LazyLock;
135
136 static ENV_VAR_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
137 regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").expect("env var regex is valid")
138 });
139
140 ENV_VAR_REGEX
141 .replace_all(content, |caps: ®ex::Captures| {
142 std::env::var(&caps[1]).unwrap_or_else(|_| format!("${{{}}}", &caps[1]))
143 })
144 .into_owned()
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150
151 #[test]
152 fn test_default_config() {
153 let config = TomlProjectConfig::default();
154 assert_eq!(config.project.name, "my-fraiseql-app");
155 assert_eq!(config.fraiseql.schema_file, "schema.json");
156 }
157
158 #[test]
159 fn test_default_security_config() {
160 let config = TomlProjectConfig::default();
161 assert!(config.fraiseql.security.audit_logging.enabled);
162 assert!(config.fraiseql.security.rate_limiting.enabled);
163 }
164
165 #[test]
166 fn test_validation() {
167 let config = TomlProjectConfig::default();
168 config.validate().unwrap_or_else(|e| panic!("expected Ok from validate: {e:?}"));
169 }
170
171 #[test]
172 fn test_role_definitions_default() {
173 let config = TomlProjectConfig::default();
174 assert!(config.fraiseql.security.role_definitions.is_empty());
175 assert!(config.fraiseql.security.default_role.is_none());
176 }
177
178 #[test]
179 fn test_parse_role_definitions_from_toml() {
180 let toml_str = r#"
181[project]
182name = "test-app"
183
184[fraiseql]
185schema_file = "schema.json"
186
187[[fraiseql.security.role_definitions]]
188name = "viewer"
189description = "Read-only access"
190scopes = ["read:*"]
191
192[[fraiseql.security.role_definitions]]
193name = "admin"
194description = "Full access"
195scopes = ["admin:*"]
196
197[fraiseql.security]
198default_role = "viewer"
199"#;
200
201 let config: TomlProjectConfig = toml::from_str(toml_str).expect("Failed to parse TOML");
202
203 assert_eq!(config.fraiseql.security.role_definitions.len(), 2);
204 assert_eq!(config.fraiseql.security.role_definitions[0].name, "viewer");
205 assert_eq!(config.fraiseql.security.role_definitions[0].scopes[0], "read:*");
206 assert_eq!(config.fraiseql.security.role_definitions[1].name, "admin");
207 assert_eq!(config.fraiseql.security.default_role, Some("viewer".to_string()));
208 }
209
210 #[test]
211 fn test_security_config_validation_empty_role_name() {
212 let toml_str = r#"
213[[fraiseql.security.role_definitions]]
214name = ""
215scopes = ["read:*"]
216"#;
217
218 let config: TomlProjectConfig = toml::from_str(toml_str).expect("Failed to parse TOML");
219 assert!(config.validate().is_err(), "Should fail with empty role name");
220 }
221
222 #[test]
223 fn test_security_config_validation_empty_scopes() {
224 let toml_str = r#"
225[[fraiseql.security.role_definitions]]
226name = "viewer"
227scopes = []
228"#;
229
230 let config: TomlProjectConfig = toml::from_str(toml_str).expect("Failed to parse TOML");
231 assert!(config.validate().is_err(), "Should fail with empty scopes");
232 }
233
234 #[test]
235 fn test_fraiseql_config_parses_server_section() {
236 let toml_str = r#"
237[server]
238host = "127.0.0.1"
239port = 9000
240
241[server.cors]
242origins = ["https://example.com"]
243"#;
244 let config: TomlProjectConfig = toml::from_str(toml_str).expect("Failed to parse TOML");
245 assert_eq!(config.server.host, "127.0.0.1");
246 assert_eq!(config.server.port, 9000);
247 assert_eq!(config.server.cors.origins, ["https://example.com"]);
248 }
249
250 #[test]
251 fn test_fraiseql_config_parses_database_section() {
252 let toml_str = r#"
253[database]
254url = "postgresql://localhost/testdb"
255pool_min = 3
256pool_max = 15
257ssl_mode = "require"
258"#;
259 let config: TomlProjectConfig = toml::from_str(toml_str).expect("Failed to parse TOML");
260 assert_eq!(config.database.url, Some("postgresql://localhost/testdb".to_string()));
261 assert_eq!(config.database.pool_min, 3);
262 assert_eq!(config.database.pool_max, 15);
263 assert_eq!(config.database.ssl_mode, "require");
264 }
265
266 #[test]
267 fn test_env_var_expansion_in_fraiseql_config() {
268 temp_env::with_var("TEST_DB_URL", Some("postgres://test/db"), || {
269 let toml_str = r#"
270[database]
271url = "${TEST_DB_URL}"
272"#;
273 let expanded = expand_env_vars(toml_str);
274 let config: TomlProjectConfig =
275 toml::from_str(&expanded).expect("Failed to parse TOML");
276 assert_eq!(config.database.url, Some("postgres://test/db".to_string()));
277 });
278 }
279
280 #[test]
281 fn test_env_var_expansion_unknown_var_passthrough() {
282 let toml_str = r#"url = "${NONEXISTENT_VAR_XYZ123}""#;
284 let expanded = expand_env_vars(toml_str);
285 assert_eq!(expanded, toml_str, "Unknown vars must be left unchanged");
286 }
287
288 #[test]
289 fn test_env_var_expansion_multiple_occurrences() {
290 temp_env::with_var("FRAISEQL_TEST_HOST", Some("db.example.com"), || {
291 let toml_str = r#"primary = "${FRAISEQL_TEST_HOST}" replica = "${FRAISEQL_TEST_HOST}""#;
292 let expanded = expand_env_vars(toml_str);
293 assert_eq!(expanded, r#"primary = "db.example.com" replica = "db.example.com""#);
294 });
295 }
296}