1use std::{collections::HashSet, env};
9
10use fraiseql_error::ConfigError;
11
12use crate::config::RuntimeConfig;
13
14pub struct ValidationResult {
16 pub errors: Vec<ConfigError>,
18 pub warnings: Vec<String>,
20}
21
22impl ValidationResult {
23 #[must_use]
25 pub const fn new() -> Self {
26 Self {
27 errors: Vec::new(),
28 warnings: Vec::new(),
29 }
30 }
31
32 #[must_use]
34 pub const fn is_ok(&self) -> bool {
35 self.errors.is_empty()
36 }
37
38 #[must_use]
40 pub const fn is_err(&self) -> bool {
41 !self.errors.is_empty()
42 }
43
44 pub fn add_error(&mut self, error: ConfigError) {
46 self.errors.push(error);
47 }
48
49 pub fn add_warning(&mut self, warning: impl Into<String>) {
51 self.warnings.push(warning.into());
52 }
53
54 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
84pub struct ConfigValidator<'a> {
86 config: &'a RuntimeConfig,
87 result: ValidationResult,
88 checked_env_vars: HashSet<String>,
89}
90
91impl<'a> ConfigValidator<'a> {
92 #[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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}