Skip to main content

fraiseql_server/config/
validation.rs

1//! Configuration validation for `fraiseql.toml` settings.
2//!
3//! [`ConfigValidator`] checks a loaded [`RuntimeConfig`] for semantic errors
4//! (e.g. missing required environment variables, invalid combinations of
5//! settings) and collects all errors before returning so the developer sees
6//! every problem in one pass.
7
8use std::{collections::HashSet, env};
9
10use fraiseql_error::ConfigError;
11
12use crate::config::RuntimeConfig;
13
14/// Validation result with all errors collected
15pub struct ValidationResult {
16    /// Collected configuration errors; non-empty means the config is invalid.
17    pub errors:   Vec<ConfigError>,
18    /// Non-fatal warnings about potentially unintended settings.
19    pub warnings: Vec<String>,
20}
21
22impl ValidationResult {
23    /// Create an empty validation result.
24    #[must_use]
25    pub const fn new() -> Self {
26        Self {
27            errors:   Vec::new(),
28            warnings: Vec::new(),
29        }
30    }
31
32    /// Return `true` if no errors were collected.
33    #[must_use]
34    pub const fn is_ok(&self) -> bool {
35        self.errors.is_empty()
36    }
37
38    /// Return `true` if any errors were collected.
39    #[must_use]
40    pub const fn is_err(&self) -> bool {
41        !self.errors.is_empty()
42    }
43
44    /// Add a configuration error to the result.
45    pub fn add_error(&mut self, error: ConfigError) {
46        self.errors.push(error);
47    }
48
49    /// Add a non-fatal warning to the result.
50    pub fn add_warning(&mut self, warning: impl Into<String>) {
51        self.warnings.push(warning.into());
52    }
53
54    /// Convert the validation result into a standard `Result`.
55    ///
56    /// # Errors
57    ///
58    /// Returns the single `ConfigError` if exactly one error was collected.
59    /// Returns `ConfigError::MultipleErrors` if more than one error was collected.
60    ///
61    /// # Panics
62    ///
63    /// Cannot panic in practice — the `expect` on `into_iter().next()` is
64    /// guarded by a preceding `len() == 1` check.
65    pub fn into_result(self) -> Result<Vec<String>, ConfigError> {
66        if self.errors.is_empty() {
67            Ok(self.warnings)
68        } else if self.errors.len() == 1 {
69            Err(self.errors.into_iter().next().expect("errors.len() == 1 confirmed above"))
70        } else {
71            Err(ConfigError::MultipleErrors {
72                errors: self.errors,
73            })
74        }
75    }
76}
77
78impl Default for ValidationResult {
79    fn default() -> Self {
80        Self::new()
81    }
82}
83
84/// Comprehensive configuration validator
85pub struct ConfigValidator<'a> {
86    config:           &'a RuntimeConfig,
87    result:           ValidationResult,
88    checked_env_vars: HashSet<String>,
89}
90
91impl<'a> ConfigValidator<'a> {
92    /// Create a new validator bound to the given runtime configuration.
93    #[must_use]
94    pub fn new(config: &'a RuntimeConfig) -> Self {
95        Self {
96            config,
97            result: ValidationResult::new(),
98            checked_env_vars: HashSet::new(),
99        }
100    }
101
102    /// Run all validations
103    #[must_use]
104    pub fn validate(mut self) -> ValidationResult {
105        self.validate_server();
106        self.validate_database();
107        self.validate_webhooks();
108        self.validate_auth();
109        self.validate_files();
110        self.validate_cross_field();
111        self.validate_env_vars();
112        self.validate_placeholder_sections();
113        self.result
114    }
115
116    /// Error on config sections that are parsed but have no runtime effect.
117    ///
118    /// Silently-ignored config is a common source of operational incidents. By
119    /// refusing to start, we ensure operators know their configuration has no
120    /// effect and must be removed or replaced.
121    fn validate_placeholder_sections(&mut self) {
122        if self.config.notifications.is_some() {
123            self.result.add_error(ConfigError::ValidationError {
124                field:   "notifications".to_string(),
125                message: "config section 'notifications' is not yet implemented; \
126                          remove it from fraiseql.toml to proceed"
127                    .to_string(),
128            });
129        }
130        if self.config.logging.is_some() {
131            self.result.add_error(ConfigError::ValidationError {
132                field:   "logging".to_string(),
133                message: "config section 'logging' is not yet implemented; \
134                          use the 'tracing' section for observability"
135                    .to_string(),
136            });
137        }
138        if self.config.search.is_some() {
139            self.result.add_error(ConfigError::ValidationError {
140                field:   "search".to_string(),
141                message: "config section 'search' is not yet implemented; \
142                          remove it from fraiseql.toml to proceed"
143                    .to_string(),
144            });
145        }
146        if self.config.cache.is_some() {
147            self.result.add_error(ConfigError::ValidationError {
148                field:   "cache".to_string(),
149                message: "config section 'cache' is not yet implemented; \
150                          use fraiseql_core::cache::CacheConfig for query-result caching"
151                    .to_string(),
152            });
153        }
154        if self.config.queues.is_some() {
155            self.result.add_error(ConfigError::ValidationError {
156                field:   "queues".to_string(),
157                message: "config section 'queues' is not yet implemented; \
158                          remove it from fraiseql.toml to proceed"
159                    .to_string(),
160            });
161        }
162        if self.config.realtime.is_some() {
163            self.result.add_error(ConfigError::ValidationError {
164                field:   "realtime".to_string(),
165                message: "config section 'realtime' is not yet implemented; \
166                          use the 'subscriptions' feature for real-time updates"
167                    .to_string(),
168            });
169        }
170        if self.config.custom_endpoints.is_some() {
171            self.result.add_error(ConfigError::ValidationError {
172                field:   "custom_endpoints".to_string(),
173                message: "config section 'custom_endpoints' is not yet implemented; \
174                          remove it from fraiseql.toml to proceed"
175                    .to_string(),
176            });
177        }
178    }
179
180    fn validate_server(&mut self) {
181        // Port validation
182        if self.config.server.port == 0 {
183            self.result.add_error(ConfigError::ValidationError {
184                field:   "server.port".to_string(),
185                message: "Port cannot be 0".to_string(),
186            });
187        }
188
189        // Limits validation
190        if let Some(limits) = &self.config.server.limits {
191            if let Err(e) = crate::config::env::parse_size(&limits.max_request_size) {
192                self.result.add_error(ConfigError::ValidationError {
193                    field:   "server.limits.max_request_size".to_string(),
194                    message: format!("Invalid size format: {}", e),
195                });
196            }
197
198            if let Err(e) = crate::config::env::parse_duration(&limits.request_timeout) {
199                self.result.add_error(ConfigError::ValidationError {
200                    field:   "server.limits.request_timeout".to_string(),
201                    message: format!("Invalid duration format: {}", e),
202                });
203            }
204
205            if limits.max_concurrent_requests == 0 {
206                self.result.add_error(ConfigError::ValidationError {
207                    field:   "server.limits.max_concurrent_requests".to_string(),
208                    message: "Must be greater than 0".to_string(),
209                });
210            }
211        }
212
213        // TLS validation
214        if let Some(tls) = &self.config.server.tls {
215            if !tls.cert_file.exists() {
216                self.result.add_error(ConfigError::ValidationError {
217                    field:   "server.tls.cert_file".to_string(),
218                    message: format!("Certificate file not found: {}", tls.cert_file.display()),
219                });
220            }
221            if !tls.key_file.exists() {
222                self.result.add_error(ConfigError::ValidationError {
223                    field:   "server.tls.key_file".to_string(),
224                    message: format!("Key file not found: {}", tls.key_file.display()),
225                });
226            }
227        }
228    }
229
230    fn validate_database(&mut self) {
231        // Required env var
232        if self.config.database.url_env.is_empty() {
233            self.result.add_error(ConfigError::ValidationError {
234                field:   "database.url_env".to_string(),
235                message: "Database URL environment variable must be specified".to_string(),
236            });
237        } else {
238            self.checked_env_vars.insert(self.config.database.url_env.clone());
239        }
240
241        // Pool size
242        if self.config.database.pool_size == 0 {
243            self.result.add_error(ConfigError::ValidationError {
244                field:   "database.pool_size".to_string(),
245                message: "Pool size must be greater than 0".to_string(),
246            });
247        }
248
249        // Replica env vars
250        for (i, replica) in self.config.database.replicas.iter().enumerate() {
251            if replica.url_env.is_empty() {
252                self.result.add_error(ConfigError::ValidationError {
253                    field:   format!("database.replicas[{}].url_env", i),
254                    message: "Replica URL environment variable must be specified".to_string(),
255                });
256            } else {
257                self.checked_env_vars.insert(replica.url_env.clone());
258            }
259        }
260    }
261
262    fn validate_webhooks(&mut self) {
263        for (name, webhook) in &self.config.webhooks {
264            // Secret env var required
265            if webhook.secret_env.is_empty() {
266                self.result.add_error(ConfigError::ValidationError {
267                    field:   format!("webhooks.{}.secret_env", name),
268                    message: "Webhook secret environment variable must be specified".to_string(),
269                });
270            } else {
271                self.checked_env_vars.insert(webhook.secret_env.clone());
272            }
273
274            // Provider must be valid
275            let valid_providers = [
276                "stripe",
277                "github",
278                "shopify",
279                "twilio",
280                "sendgrid",
281                "paddle",
282                "slack",
283                "discord",
284                "linear",
285                "svix",
286                "clerk",
287                "supabase",
288                "novu",
289                "resend",
290                "generic_hmac",
291            ];
292            if !valid_providers.contains(&webhook.provider.as_str()) {
293                self.result.add_warning(format!(
294                    "Unknown webhook provider '{}' for webhook '{}'. Using generic_hmac.",
295                    webhook.provider, name
296                ));
297            }
298        }
299    }
300
301    fn validate_auth(&mut self) {
302        if let Some(auth) = &self.config.auth {
303            // JWT secret required if auth is enabled
304            if auth.jwt.secret_env.is_empty() {
305                self.result.add_error(ConfigError::ValidationError {
306                    field:   "auth.jwt.secret_env".to_string(),
307                    message: "JWT secret environment variable must be specified".to_string(),
308                });
309            } else {
310                self.checked_env_vars.insert(auth.jwt.secret_env.clone());
311            }
312
313            // Validate each provider
314            for (name, provider) in &auth.providers {
315                self.checked_env_vars.insert(provider.client_id_env.clone());
316                self.checked_env_vars.insert(provider.client_secret_env.clone());
317
318                // OIDC providers need issuer URL
319                if provider.provider_type == "oidc" && provider.issuer_url.is_none() {
320                    self.result.add_error(ConfigError::ValidationError {
321                        field:   format!("auth.providers.{}.issuer_url", name),
322                        message: "OIDC providers require issuer_url".to_string(),
323                    });
324                }
325            }
326
327            // Callback URL required if any OAuth provider is configured
328            if !auth.providers.is_empty() && auth.callback_base_url.is_none() {
329                self.result.add_error(ConfigError::ValidationError {
330                    field:   "auth.callback_base_url".to_string(),
331                    message: "callback_base_url is required when OAuth providers are configured"
332                        .to_string(),
333                });
334            }
335        }
336    }
337
338    fn validate_files(&mut self) {
339        for (name, file_config) in &self.config.files {
340            // Storage backend must be defined
341            if !self.config.storage.contains_key(&file_config.storage) {
342                self.result.add_error(ConfigError::ValidationError {
343                    field:   format!("files.{}.storage", name),
344                    message: format!(
345                        "Storage backend '{}' not found in storage configuration",
346                        file_config.storage
347                    ),
348                });
349            }
350
351            // Max size validation
352            if let Err(e) = crate::config::env::parse_size(&file_config.max_size) {
353                self.result.add_error(ConfigError::ValidationError {
354                    field:   format!("files.{}.max_size", name),
355                    message: format!("Invalid size format: {}", e),
356                });
357            }
358        }
359
360        // Validate storage backends
361        for (name, storage) in &self.config.storage {
362            match storage.backend.as_str() {
363                "s3" | "r2" | "gcs" => {
364                    if storage.bucket.is_none() {
365                        self.result.add_error(ConfigError::ValidationError {
366                            field:   format!("storage.{}.bucket", name),
367                            message: "Bucket name is required for cloud storage".to_string(),
368                        });
369                    }
370                },
371                "local" => {
372                    if storage.path.is_none() {
373                        self.result.add_error(ConfigError::ValidationError {
374                            field:   format!("storage.{}.path", name),
375                            message: "Path is required for local storage".to_string(),
376                        });
377                    }
378                },
379                _ => {
380                    self.result.add_error(ConfigError::ValidationError {
381                        field:   format!("storage.{}.backend", name),
382                        message: format!("Unknown storage backend: {}", storage.backend),
383                    });
384                },
385            }
386        }
387    }
388
389    fn validate_cross_field(&mut self) {
390        // Observers require notifications for email/slack actions
391        for (name, observer) in &self.config.observers {
392            for action in &observer.actions {
393                match action.action_type.as_str() {
394                    "email" | "slack" | "sms" | "push" => {
395                        if self.config.notifications.is_none() {
396                            self.result.add_error(ConfigError::ValidationError {
397                                field: format!("observers.{}.actions", name),
398                                message: format!(
399                                    "Observer '{}' uses '{}' action but notifications are not configured",
400                                    name, action.action_type
401                                ),
402                            });
403                        }
404                    },
405                    _ => {},
406                }
407            }
408        }
409
410        // Rate limiting with Redis backend requires cache config
411        if let Some(rate_limit) = &self.config.rate_limiting {
412            if rate_limit.backend == "redis" && self.config.cache.is_none() {
413                self.result.add_error(ConfigError::ValidationError {
414                    field:   "rate_limiting.backend".to_string(),
415                    message: "Redis rate limiting requires cache configuration. \
416                              Add a [cache] section to fraiseql.toml or change \
417                              [rate_limiting] backend from 'redis' to 'memory'."
418                        .to_string(),
419                });
420            }
421        }
422    }
423
424    fn validate_env_vars(&mut self) {
425        // Check all collected env vars exist
426        for var_name in &self.checked_env_vars {
427            if env::var(var_name).is_err() {
428                self.result.add_error(ConfigError::MissingEnvVar {
429                    name: var_name.clone(),
430                });
431            }
432        }
433    }
434}