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    pub const fn new() -> Self {
25        Self {
26            errors:   Vec::new(),
27            warnings: Vec::new(),
28        }
29    }
30
31    /// Return `true` if no errors were collected.
32    pub const fn is_ok(&self) -> bool {
33        self.errors.is_empty()
34    }
35
36    /// Return `true` if any errors were collected.
37    pub const fn is_err(&self) -> bool {
38        !self.errors.is_empty()
39    }
40
41    /// Add a configuration error to the result.
42    pub fn add_error(&mut self, error: ConfigError) {
43        self.errors.push(error);
44    }
45
46    /// Add a non-fatal warning to the result.
47    pub fn add_warning(&mut self, warning: impl Into<String>) {
48        self.warnings.push(warning.into());
49    }
50
51    /// Convert the validation result into a standard `Result`.
52    ///
53    /// # Errors
54    ///
55    /// Returns the single `ConfigError` if exactly one error was collected.
56    /// Returns `ConfigError::MultipleErrors` if more than one error was collected.
57    ///
58    /// # Panics
59    ///
60    /// Cannot panic in practice — the `expect` on `into_iter().next()` is
61    /// guarded by a preceding `len() == 1` check.
62    pub fn into_result(self) -> Result<Vec<String>, ConfigError> {
63        if self.errors.is_empty() {
64            Ok(self.warnings)
65        } else if self.errors.len() == 1 {
66            Err(self.errors.into_iter().next().expect("errors.len() == 1 confirmed above"))
67        } else {
68            Err(ConfigError::MultipleErrors {
69                errors: self.errors,
70            })
71        }
72    }
73}
74
75impl Default for ValidationResult {
76    fn default() -> Self {
77        Self::new()
78    }
79}
80
81/// Comprehensive configuration validator
82pub struct ConfigValidator<'a> {
83    config:           &'a RuntimeConfig,
84    result:           ValidationResult,
85    checked_env_vars: HashSet<String>,
86}
87
88impl<'a> ConfigValidator<'a> {
89    /// Create a new validator bound to the given runtime configuration.
90    pub fn new(config: &'a RuntimeConfig) -> Self {
91        Self {
92            config,
93            result: ValidationResult::new(),
94            checked_env_vars: HashSet::new(),
95        }
96    }
97
98    /// Run all validations
99    pub fn validate(mut self) -> ValidationResult {
100        self.validate_server();
101        self.validate_database();
102        self.validate_webhooks();
103        self.validate_auth();
104        self.validate_files();
105        self.validate_cross_field();
106        self.validate_env_vars();
107        self.validate_placeholder_sections();
108        self.result
109    }
110
111    /// Error on config sections that are parsed but have no runtime effect.
112    ///
113    /// Silently-ignored config is a common source of operational incidents. By
114    /// refusing to start, we ensure operators know their configuration has no
115    /// effect and must be removed or replaced.
116    fn validate_placeholder_sections(&mut self) {
117        if self.config.notifications.is_some() {
118            self.result.add_error(ConfigError::ValidationError {
119                field:   "notifications".to_string(),
120                message: "config section 'notifications' is not yet implemented; \
121                          remove it from fraiseql.toml to proceed"
122                    .to_string(),
123            });
124        }
125        if self.config.logging.is_some() {
126            self.result.add_error(ConfigError::ValidationError {
127                field:   "logging".to_string(),
128                message: "config section 'logging' is not yet implemented; \
129                          use the 'tracing' section for observability"
130                    .to_string(),
131            });
132        }
133        if self.config.search.is_some() {
134            self.result.add_error(ConfigError::ValidationError {
135                field:   "search".to_string(),
136                message: "config section 'search' is not yet implemented; \
137                          remove it from fraiseql.toml to proceed"
138                    .to_string(),
139            });
140        }
141        if self.config.cache.is_some() {
142            self.result.add_error(ConfigError::ValidationError {
143                field:   "cache".to_string(),
144                message: "config section 'cache' is not yet implemented; \
145                          use fraiseql_core::cache::CacheConfig for query-result caching"
146                    .to_string(),
147            });
148        }
149        if self.config.queues.is_some() {
150            self.result.add_error(ConfigError::ValidationError {
151                field:   "queues".to_string(),
152                message: "config section 'queues' is not yet implemented; \
153                          remove it from fraiseql.toml to proceed"
154                    .to_string(),
155            });
156        }
157        if self.config.realtime.is_some() {
158            self.result.add_error(ConfigError::ValidationError {
159                field:   "realtime".to_string(),
160                message: "config section 'realtime' is not yet implemented; \
161                          use the 'subscriptions' feature for real-time updates"
162                    .to_string(),
163            });
164        }
165        if self.config.custom_endpoints.is_some() {
166            self.result.add_error(ConfigError::ValidationError {
167                field:   "custom_endpoints".to_string(),
168                message: "config section 'custom_endpoints' is not yet implemented; \
169                          remove it from fraiseql.toml to proceed"
170                    .to_string(),
171            });
172        }
173    }
174
175    fn validate_server(&mut self) {
176        // Port validation
177        if self.config.server.port == 0 {
178            self.result.add_error(ConfigError::ValidationError {
179                field:   "server.port".to_string(),
180                message: "Port cannot be 0".to_string(),
181            });
182        }
183
184        // Limits validation
185        if let Some(limits) = &self.config.server.limits {
186            if let Err(e) = crate::config::env::parse_size(&limits.max_request_size) {
187                self.result.add_error(ConfigError::ValidationError {
188                    field:   "server.limits.max_request_size".to_string(),
189                    message: format!("Invalid size format: {}", e),
190                });
191            }
192
193            if let Err(e) = crate::config::env::parse_duration(&limits.request_timeout) {
194                self.result.add_error(ConfigError::ValidationError {
195                    field:   "server.limits.request_timeout".to_string(),
196                    message: format!("Invalid duration format: {}", e),
197                });
198            }
199
200            if limits.max_concurrent_requests == 0 {
201                self.result.add_error(ConfigError::ValidationError {
202                    field:   "server.limits.max_concurrent_requests".to_string(),
203                    message: "Must be greater than 0".to_string(),
204                });
205            }
206        }
207
208        // TLS validation
209        if let Some(tls) = &self.config.server.tls {
210            if !tls.cert_file.exists() {
211                self.result.add_error(ConfigError::ValidationError {
212                    field:   "server.tls.cert_file".to_string(),
213                    message: format!("Certificate file not found: {}", tls.cert_file.display()),
214                });
215            }
216            if !tls.key_file.exists() {
217                self.result.add_error(ConfigError::ValidationError {
218                    field:   "server.tls.key_file".to_string(),
219                    message: format!("Key file not found: {}", tls.key_file.display()),
220                });
221            }
222        }
223    }
224
225    fn validate_database(&mut self) {
226        // Required env var
227        if self.config.database.url_env.is_empty() {
228            self.result.add_error(ConfigError::ValidationError {
229                field:   "database.url_env".to_string(),
230                message: "Database URL environment variable must be specified".to_string(),
231            });
232        } else {
233            self.checked_env_vars.insert(self.config.database.url_env.clone());
234        }
235
236        // Pool size
237        if self.config.database.pool_size == 0 {
238            self.result.add_error(ConfigError::ValidationError {
239                field:   "database.pool_size".to_string(),
240                message: "Pool size must be greater than 0".to_string(),
241            });
242        }
243
244        // Replica env vars
245        for (i, replica) in self.config.database.replicas.iter().enumerate() {
246            if replica.url_env.is_empty() {
247                self.result.add_error(ConfigError::ValidationError {
248                    field:   format!("database.replicas[{}].url_env", i),
249                    message: "Replica URL environment variable must be specified".to_string(),
250                });
251            } else {
252                self.checked_env_vars.insert(replica.url_env.clone());
253            }
254        }
255    }
256
257    fn validate_webhooks(&mut self) {
258        for (name, webhook) in &self.config.webhooks {
259            // Secret env var required
260            if webhook.secret_env.is_empty() {
261                self.result.add_error(ConfigError::ValidationError {
262                    field:   format!("webhooks.{}.secret_env", name),
263                    message: "Webhook secret environment variable must be specified".to_string(),
264                });
265            } else {
266                self.checked_env_vars.insert(webhook.secret_env.clone());
267            }
268
269            // Provider must be valid
270            let valid_providers = [
271                "stripe",
272                "github",
273                "shopify",
274                "twilio",
275                "sendgrid",
276                "paddle",
277                "slack",
278                "discord",
279                "linear",
280                "svix",
281                "clerk",
282                "supabase",
283                "novu",
284                "resend",
285                "generic_hmac",
286            ];
287            if !valid_providers.contains(&webhook.provider.as_str()) {
288                self.result.add_warning(format!(
289                    "Unknown webhook provider '{}' for webhook '{}'. Using generic_hmac.",
290                    webhook.provider, name
291                ));
292            }
293        }
294    }
295
296    fn validate_auth(&mut self) {
297        if let Some(auth) = &self.config.auth {
298            // JWT secret required if auth is enabled
299            if auth.jwt.secret_env.is_empty() {
300                self.result.add_error(ConfigError::ValidationError {
301                    field:   "auth.jwt.secret_env".to_string(),
302                    message: "JWT secret environment variable must be specified".to_string(),
303                });
304            } else {
305                self.checked_env_vars.insert(auth.jwt.secret_env.clone());
306            }
307
308            // Validate each provider
309            for (name, provider) in &auth.providers {
310                self.checked_env_vars.insert(provider.client_id_env.clone());
311                self.checked_env_vars.insert(provider.client_secret_env.clone());
312
313                // OIDC providers need issuer URL
314                if provider.provider_type == "oidc" && provider.issuer_url.is_none() {
315                    self.result.add_error(ConfigError::ValidationError {
316                        field:   format!("auth.providers.{}.issuer_url", name),
317                        message: "OIDC providers require issuer_url".to_string(),
318                    });
319                }
320            }
321
322            // Callback URL required if any OAuth provider is configured
323            if !auth.providers.is_empty() && auth.callback_base_url.is_none() {
324                self.result.add_error(ConfigError::ValidationError {
325                    field:   "auth.callback_base_url".to_string(),
326                    message: "callback_base_url is required when OAuth providers are configured"
327                        .to_string(),
328                });
329            }
330        }
331    }
332
333    fn validate_files(&mut self) {
334        for (name, file_config) in &self.config.files {
335            // Storage backend must be defined
336            if !self.config.storage.contains_key(&file_config.storage) {
337                self.result.add_error(ConfigError::ValidationError {
338                    field:   format!("files.{}.storage", name),
339                    message: format!(
340                        "Storage backend '{}' not found in storage configuration",
341                        file_config.storage
342                    ),
343                });
344            }
345
346            // Max size validation
347            if let Err(e) = crate::config::env::parse_size(&file_config.max_size) {
348                self.result.add_error(ConfigError::ValidationError {
349                    field:   format!("files.{}.max_size", name),
350                    message: format!("Invalid size format: {}", e),
351                });
352            }
353        }
354
355        // Validate storage backends
356        for (name, storage) in &self.config.storage {
357            match storage.backend.as_str() {
358                "s3" | "r2" | "gcs" => {
359                    if storage.bucket.is_none() {
360                        self.result.add_error(ConfigError::ValidationError {
361                            field:   format!("storage.{}.bucket", name),
362                            message: "Bucket name is required for cloud storage".to_string(),
363                        });
364                    }
365                },
366                "local" => {
367                    if storage.path.is_none() {
368                        self.result.add_error(ConfigError::ValidationError {
369                            field:   format!("storage.{}.path", name),
370                            message: "Path is required for local storage".to_string(),
371                        });
372                    }
373                },
374                _ => {
375                    self.result.add_error(ConfigError::ValidationError {
376                        field:   format!("storage.{}.backend", name),
377                        message: format!("Unknown storage backend: {}", storage.backend),
378                    });
379                },
380            }
381        }
382    }
383
384    fn validate_cross_field(&mut self) {
385        // Observers require notifications for email/slack actions
386        for (name, observer) in &self.config.observers {
387            for action in &observer.actions {
388                match action.action_type.as_str() {
389                    "email" | "slack" | "sms" | "push" => {
390                        if self.config.notifications.is_none() {
391                            self.result.add_error(ConfigError::ValidationError {
392                                field: format!("observers.{}.actions", name),
393                                message: format!(
394                                    "Observer '{}' uses '{}' action but notifications are not configured",
395                                    name, action.action_type
396                                ),
397                            });
398                        }
399                    },
400                    _ => {},
401                }
402            }
403        }
404
405        // Rate limiting with Redis backend requires cache config
406        if let Some(rate_limit) = &self.config.rate_limiting {
407            if rate_limit.backend == "redis" && self.config.cache.is_none() {
408                self.result.add_error(ConfigError::ValidationError {
409                    field:   "rate_limiting.backend".to_string(),
410                    message: "Redis rate limiting requires cache configuration. \
411                              Add a [cache] section to fraiseql.toml or change \
412                              [rate_limiting] backend from 'redis' to 'memory'."
413                        .to_string(),
414                });
415            }
416        }
417    }
418
419    fn validate_env_vars(&mut self) {
420        // Check all collected env vars exist
421        for var_name in &self.checked_env_vars {
422            if env::var(var_name).is_err() {
423                self.result.add_error(ConfigError::MissingEnvVar {
424                    name: var_name.clone(),
425                });
426            }
427        }
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    #![allow(clippy::unwrap_used)] // Reason: test code, panics acceptable
434
435    use super::*;
436
437    // ─── ValidationResult ───────────────────────────────────────────────────
438
439    #[test]
440    fn empty_result_is_ok() {
441        let result = ValidationResult::new();
442        assert!(result.is_ok());
443        assert!(!result.is_err());
444    }
445
446    #[test]
447    fn result_with_error_is_err() {
448        let mut result = ValidationResult::new();
449        result.add_error(ConfigError::ValidationError {
450            field:   "test".into(),
451            message: "bad".into(),
452        });
453        assert!(result.is_err());
454        assert!(!result.is_ok());
455    }
456
457    #[test]
458    fn result_with_only_warnings_is_ok() {
459        let mut result = ValidationResult::new();
460        result.add_warning("heads up");
461        assert!(result.is_ok());
462    }
463
464    #[test]
465    fn into_result_single_error() {
466        let mut result = ValidationResult::new();
467        result.add_error(ConfigError::ValidationError {
468            field:   "port".into(),
469            message: "invalid".into(),
470        });
471        let err = result.into_result().unwrap_err();
472        assert!(
473            matches!(err, ConfigError::ValidationError { ref field, .. } if field == "port"),
474            "single error must be unwrapped, not wrapped in MultipleErrors"
475        );
476    }
477
478    #[test]
479    fn into_result_multiple_errors() {
480        let mut result = ValidationResult::new();
481        result.add_error(ConfigError::ValidationError {
482            field:   "a".into(),
483            message: "bad a".into(),
484        });
485        result.add_error(ConfigError::ValidationError {
486            field:   "b".into(),
487            message: "bad b".into(),
488        });
489        let err = result.into_result().unwrap_err();
490        assert!(
491            matches!(err, ConfigError::MultipleErrors { ref errors } if errors.len() == 2),
492            "multiple errors must be wrapped in MultipleErrors"
493        );
494    }
495
496    #[test]
497    fn into_result_ok_returns_warnings() {
498        let mut result = ValidationResult::new();
499        result.add_warning("warn1");
500        result.add_warning("warn2");
501        let warnings = result.into_result().unwrap();
502        assert_eq!(warnings.len(), 2);
503    }
504
505    // ─── ConfigValidator — server validation ────────────────────────────────
506
507    /// Minimal valid TOML for constructing a `RuntimeConfig`.
508    fn minimal_config(toml_override: &str) -> RuntimeConfig {
509        let toml = format!(
510            r#"
511            [server]
512            port = 4000
513
514            [database]
515            url_env = "DATABASE_URL"
516
517            {toml_override}
518            "#
519        );
520        toml::from_str(&toml).unwrap()
521    }
522
523    #[test]
524    fn valid_minimal_config_passes_validation() {
525        temp_env::with_var("DATABASE_URL", Some("postgres://localhost/test"), || {
526            let config = minimal_config("");
527            let result = ConfigValidator::new(&config).validate();
528            assert!(result.is_ok(), "valid minimal config must pass: {:?}", result.errors);
529        });
530    }
531
532    #[test]
533    fn port_zero_fails_validation() {
534        temp_env::with_var("DATABASE_URL", Some("postgres://localhost/test"), || {
535            let toml = r#"
536                [server]
537                port = 0
538
539                [database]
540                url_env = "DATABASE_URL"
541            "#;
542            let config: RuntimeConfig = toml::from_str(toml).unwrap();
543            let result = ConfigValidator::new(&config).validate();
544            assert!(result.is_err(), "port=0 must fail validation");
545            assert!(
546                result.errors.iter().any(|e| {
547                    matches!(e, ConfigError::ValidationError { ref field, .. } if field.contains("port"))
548                }),
549                "error must reference port field"
550            );
551        });
552    }
553
554    #[test]
555    fn pool_size_zero_fails_validation() {
556        temp_env::with_var("DATABASE_URL", Some("postgres://localhost/test"), || {
557            let toml = r#"
558                [server]
559                port = 4000
560
561                [database]
562                url_env = "DATABASE_URL"
563                pool_size = 0
564            "#;
565            let config: RuntimeConfig = toml::from_str(toml).unwrap();
566            let result = ConfigValidator::new(&config).validate();
567            assert!(result.is_err(), "pool_size=0 must fail validation");
568            assert!(
569                result.errors.iter().any(|e| {
570                    matches!(e, ConfigError::ValidationError { ref field, .. } if field.contains("pool_size"))
571                }),
572                "error must reference pool_size field"
573            );
574        });
575    }
576
577    #[test]
578    fn empty_database_url_env_fails_validation() {
579        let toml = r#"
580            [server]
581            port = 4000
582
583            [database]
584            url_env = ""
585        "#;
586        let config: RuntimeConfig = toml::from_str(toml).unwrap();
587        let result = ConfigValidator::new(&config).validate();
588        assert!(result.is_err(), "empty url_env must fail validation");
589    }
590
591    #[test]
592    fn placeholder_section_notifications_fails() {
593        temp_env::with_var("DATABASE_URL", Some("postgres://localhost/test"), || {
594            let toml = r#"
595                [server]
596                port = 4000
597
598                [database]
599                url_env = "DATABASE_URL"
600
601                [notifications]
602                enabled = true
603            "#;
604            let config: RuntimeConfig = toml::from_str(toml).unwrap();
605            let result = ConfigValidator::new(&config).validate();
606            assert!(
607                result.errors.iter().any(|e| {
608                    matches!(e, ConfigError::ValidationError { ref field, .. } if field == "notifications")
609                }),
610                "placeholder 'notifications' section must be rejected"
611            );
612        });
613    }
614
615    #[test]
616    fn placeholder_section_logging_fails() {
617        temp_env::with_var("DATABASE_URL", Some("postgres://localhost/test"), || {
618            let toml = r#"
619                [server]
620                port = 4000
621
622                [database]
623                url_env = "DATABASE_URL"
624
625                [logging]
626                level = "debug"
627            "#;
628            let config: RuntimeConfig = toml::from_str(toml).unwrap();
629            let result = ConfigValidator::new(&config).validate();
630            assert!(
631                result.errors.iter().any(|e| {
632                    matches!(e, ConfigError::ValidationError { ref field, .. } if field == "logging")
633                }),
634                "placeholder 'logging' section must be rejected"
635            );
636        });
637    }
638
639    #[test]
640    fn invalid_max_request_size_fails_validation() {
641        temp_env::with_var("DATABASE_URL", Some("postgres://localhost/test"), || {
642            let toml = r#"
643                [server]
644                port = 4000
645
646                [server.limits]
647                max_request_size = "not-a-size"
648
649                [database]
650                url_env = "DATABASE_URL"
651            "#;
652            let config: RuntimeConfig = toml::from_str(toml).unwrap();
653            let result = ConfigValidator::new(&config).validate();
654            assert!(
655                result.errors.iter().any(|e| {
656                    matches!(e, ConfigError::ValidationError { ref field, .. }
657                        if field.contains("max_request_size"))
658                }),
659                "invalid max_request_size must fail validation"
660            );
661        });
662    }
663
664    #[test]
665    fn zero_max_concurrent_requests_fails_validation() {
666        temp_env::with_var("DATABASE_URL", Some("postgres://localhost/test"), || {
667            let toml = r#"
668                [server]
669                port = 4000
670
671                [server.limits]
672                max_concurrent_requests = 0
673
674                [database]
675                url_env = "DATABASE_URL"
676            "#;
677            let config: RuntimeConfig = toml::from_str(toml).unwrap();
678            let result = ConfigValidator::new(&config).validate();
679            assert!(
680                result.errors.iter().any(|e| {
681                    matches!(e, ConfigError::ValidationError { ref field, .. }
682                        if field.contains("max_concurrent_requests"))
683                }),
684                "max_concurrent_requests=0 must fail validation"
685            );
686        });
687    }
688
689    #[test]
690    fn redis_rate_limiting_without_cache_error_references_fraiseql_toml() {
691        temp_env::with_var("DATABASE_URL", Some("postgres://localhost/test"), || {
692            let toml = r#"
693                [server]
694                port = 4000
695
696                [database]
697                url_env = "DATABASE_URL"
698
699                [rate_limiting]
700                default = "100/minute"
701                backend = "redis"
702            "#;
703            let config: RuntimeConfig = toml::from_str(toml).unwrap();
704            let result = ConfigValidator::new(&config).validate();
705            let has_toml_ref = result.errors.iter().any(|e| {
706                matches!(e, ConfigError::ValidationError { ref message, .. }
707                    if message.contains("fraiseql.toml"))
708            });
709            assert!(
710                has_toml_ref,
711                "error message must reference fraiseql.toml; errors: {:?}",
712                result.errors
713            );
714        });
715    }
716
717    #[test]
718    fn multiple_errors_collected_in_one_pass() {
719        let toml = r#"
720            [server]
721            port = 0
722
723            [database]
724            url_env = ""
725            pool_size = 0
726        "#;
727        let config: RuntimeConfig = toml::from_str(toml).unwrap();
728        let result = ConfigValidator::new(&config).validate();
729        assert!(
730            result.errors.len() >= 3,
731            "validator must collect all errors in one pass, got {} errors: {:?}",
732            result.errors.len(),
733            result.errors
734        );
735    }
736}