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 FraiseQLConfig {
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 FraiseQLConfig {
89 pub fn from_file(path: &str) -> Result<Self> {
93 info!("Loading configuration from {path}");
94
95 let path = Path::new(path);
96 if !path.exists() {
97 anyhow::bail!("Configuration file not found: {}", path.display());
98 }
99
100 let raw = std::fs::read_to_string(path).context("Failed to read fraiseql.toml")?;
101 let toml_content = expand_env_vars(&raw);
102
103 let config: FraiseQLConfig = toml::from_str(&toml_content)
104 .map_err(|e| anyhow::anyhow!("Failed to parse fraiseql.toml: {e}"))?;
105
106 Ok(config)
107 }
108
109 pub fn validate(&self) -> Result<()> {
111 info!("Validating configuration");
112 self.fraiseql.security.validate()?;
113 self.server.validate()?;
114 self.database.validate()?;
115 Ok(())
116 }
117}
118
119#[allow(clippy::expect_used)] pub(crate) fn expand_env_vars(content: &str) -> String {
124 use std::sync::LazyLock;
125
126 static ENV_VAR_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
127 regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").expect("env var regex is valid")
128 });
129
130 ENV_VAR_REGEX
131 .replace_all(content, |caps: ®ex::Captures| {
132 std::env::var(&caps[1]).unwrap_or_else(|_| format!("${{{}}}", &caps[1]))
133 })
134 .into_owned()
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140
141 #[test]
142 fn test_default_config() {
143 let config = FraiseQLConfig::default();
144 assert_eq!(config.project.name, "my-fraiseql-app");
145 assert_eq!(config.fraiseql.schema_file, "schema.json");
146 }
147
148 #[test]
149 fn test_default_security_config() {
150 let config = FraiseQLConfig::default();
151 assert!(config.fraiseql.security.audit_logging.enabled);
152 assert!(config.fraiseql.security.rate_limiting.enabled);
153 }
154
155 #[test]
156 fn test_validation() {
157 let config = FraiseQLConfig::default();
158 assert!(config.validate().is_ok());
159 }
160
161 #[test]
162 fn test_role_definitions_default() {
163 let config = FraiseQLConfig::default();
164 assert!(config.fraiseql.security.role_definitions.is_empty());
165 assert!(config.fraiseql.security.default_role.is_none());
166 }
167
168 #[test]
169 fn test_parse_role_definitions_from_toml() {
170 let toml_str = r#"
171[project]
172name = "test-app"
173
174[fraiseql]
175schema_file = "schema.json"
176
177[[fraiseql.security.role_definitions]]
178name = "viewer"
179description = "Read-only access"
180scopes = ["read:*"]
181
182[[fraiseql.security.role_definitions]]
183name = "admin"
184description = "Full access"
185scopes = ["admin:*"]
186
187[fraiseql.security]
188default_role = "viewer"
189"#;
190
191 let config: FraiseQLConfig = toml::from_str(toml_str).expect("Failed to parse TOML");
192
193 assert_eq!(config.fraiseql.security.role_definitions.len(), 2);
194 assert_eq!(config.fraiseql.security.role_definitions[0].name, "viewer");
195 assert_eq!(config.fraiseql.security.role_definitions[0].scopes[0], "read:*");
196 assert_eq!(config.fraiseql.security.role_definitions[1].name, "admin");
197 assert_eq!(config.fraiseql.security.default_role, Some("viewer".to_string()));
198 }
199
200 #[test]
201 fn test_security_config_validation_empty_role_name() {
202 let toml_str = r#"
203[[fraiseql.security.role_definitions]]
204name = ""
205scopes = ["read:*"]
206"#;
207
208 let config: FraiseQLConfig = toml::from_str(toml_str).expect("Failed to parse TOML");
209 assert!(config.validate().is_err(), "Should fail with empty role name");
210 }
211
212 #[test]
213 fn test_security_config_validation_empty_scopes() {
214 let toml_str = r#"
215[[fraiseql.security.role_definitions]]
216name = "viewer"
217scopes = []
218"#;
219
220 let config: FraiseQLConfig = toml::from_str(toml_str).expect("Failed to parse TOML");
221 assert!(config.validate().is_err(), "Should fail with empty scopes");
222 }
223
224 #[test]
225 fn test_fraiseql_config_parses_server_section() {
226 let toml_str = r#"
227[server]
228host = "127.0.0.1"
229port = 9000
230
231[server.cors]
232origins = ["https://example.com"]
233"#;
234 let config: FraiseQLConfig = toml::from_str(toml_str).expect("Failed to parse TOML");
235 assert_eq!(config.server.host, "127.0.0.1");
236 assert_eq!(config.server.port, 9000);
237 assert_eq!(config.server.cors.origins, ["https://example.com"]);
238 }
239
240 #[test]
241 fn test_fraiseql_config_parses_database_section() {
242 let toml_str = r#"
243[database]
244url = "postgresql://localhost/testdb"
245pool_min = 3
246pool_max = 15
247ssl_mode = "require"
248"#;
249 let config: FraiseQLConfig = toml::from_str(toml_str).expect("Failed to parse TOML");
250 assert_eq!(config.database.url, Some("postgresql://localhost/testdb".to_string()));
251 assert_eq!(config.database.pool_min, 3);
252 assert_eq!(config.database.pool_max, 15);
253 assert_eq!(config.database.ssl_mode, "require");
254 }
255
256 #[test]
257 fn test_env_var_expansion_in_fraiseql_config() {
258 temp_env::with_var("TEST_DB_URL", Some("postgres://test/db"), || {
259 let toml_str = r#"
260[database]
261url = "${TEST_DB_URL}"
262"#;
263 let expanded = expand_env_vars(toml_str);
264 let config: FraiseQLConfig =
265 toml::from_str(&expanded).expect("Failed to parse TOML");
266 assert_eq!(config.database.url, Some("postgres://test/db".to_string()));
267 });
268 }
269
270 #[test]
271 fn test_env_var_expansion_unknown_var_passthrough() {
272 let toml_str = r#"url = "${NONEXISTENT_VAR_XYZ123}""#;
274 let expanded = expand_env_vars(toml_str);
275 assert_eq!(expanded, toml_str, "Unknown vars must be left unchanged");
276 }
277
278 #[test]
279 fn test_env_var_expansion_multiple_occurrences() {
280 temp_env::with_var("FRAISEQL_TEST_HOST", Some("db.example.com"), || {
281 let toml_str =
282 r#"primary = "${FRAISEQL_TEST_HOST}" replica = "${FRAISEQL_TEST_HOST}""#;
283 let expanded = expand_env_vars(toml_str);
284 assert_eq!(
285 expanded,
286 r#"primary = "db.example.com" replica = "db.example.com""#
287 );
288 });
289 }
290}