fraiseql_cli/config/toml_schema/
mod.rs1pub mod caching;
7pub mod domain;
8pub mod federation;
9pub mod observability;
10pub mod observers;
11pub mod operations;
12pub mod security;
13pub mod server_settings;
14pub mod subscriptions;
15pub mod types;
16
17use std::collections::BTreeMap;
18
19use anyhow::{Context, Result};
20
21fn format_suggestions(suggestions: Vec<&str>) -> String {
23 if suggestions.is_empty() {
24 String::new()
25 } else {
26 format!(". Did you mean: {}?", suggestions.join(", "))
27 }
28}
29pub use caching::{AnalyticsConfig, AnalyticsQuery, CacheRule, CachingConfig};
30pub use domain::{Domain, DomainDiscovery, ResolvedIncludes, SchemaIncludes};
31pub use federation::{
32 FederationCircuitBreakerConfig, FederationConfig, FederationEntity,
33 PerDatabaseCircuitBreakerOverride,
34};
35use fraiseql_core::schema::{CrudNamingConfig, NamingConvention};
36pub use observability::ObservabilityConfig;
37pub use observers::{EventHandler, ObserversConfig};
38pub use operations::{MutationDefinition, QueryDefaults, QueryDefinition, SchemaMetadata};
39pub use security::{
40 ApiKeySecurityConfig, AuthorizationPolicy, AuthorizationRule, CodeChallengeMethod,
41 EncryptionAlgorithm, EnterpriseSecurityConfig, ErrorSanitizationTomlConfig, FieldAuthRule,
42 KeySource, OidcClientConfig, PkceConfig, RateLimitingSecurityConfig, SecuritySettings,
43 StateEncryptionConfig, StaticApiKeyEntry, TokenRevocationSecurityConfig, TrustedDocumentMode,
44 TrustedDocumentsConfig,
45};
46use serde::{Deserialize, Serialize};
47pub use server_settings::{DebugConfig, McpConfig, ValidationConfig};
48pub use subscriptions::{SubscriptionHooksConfig, SubscriptionsConfig};
49pub use types::{ArgumentDefinition, FieldDefinition, TypeDefinition};
50
51use super::{
52 expand_env_vars,
53 runtime::{DatabaseRuntimeConfig, ServerRuntimeConfig},
54};
55
56#[derive(Debug, Clone, Default, Deserialize, Serialize)]
58#[serde(default, deny_unknown_fields)]
59pub struct TomlSchema {
60 #[serde(rename = "schema")]
62 pub schema: SchemaMetadata,
63
64 #[serde(rename = "database")]
68 pub database: DatabaseRuntimeConfig,
69
70 #[serde(rename = "server")]
74 pub server: ServerRuntimeConfig,
75
76 #[serde(rename = "types")]
78 pub types: BTreeMap<String, TypeDefinition>,
79
80 #[serde(rename = "queries")]
82 pub queries: BTreeMap<String, QueryDefinition>,
83
84 #[serde(rename = "mutations")]
86 pub mutations: BTreeMap<String, MutationDefinition>,
87
88 #[serde(rename = "federation")]
90 pub federation: FederationConfig,
91
92 #[serde(rename = "security")]
94 pub security: SecuritySettings,
95
96 #[serde(rename = "observers")]
98 pub observers: ObserversConfig,
99
100 #[serde(rename = "caching")]
102 pub caching: CachingConfig,
103
104 #[serde(rename = "analytics")]
106 pub analytics: AnalyticsConfig,
107
108 #[serde(rename = "observability")]
110 pub observability: ObservabilityConfig,
111
112 #[serde(default)]
114 pub includes: SchemaIncludes,
115
116 #[serde(default)]
118 pub domain_discovery: DomainDiscovery,
119
120 #[serde(default)]
127 pub query_defaults: QueryDefaults,
128
129 #[serde(default)]
135 pub auth: Option<OidcClientConfig>,
136
137 #[serde(default)]
139 pub subscriptions: SubscriptionsConfig,
140
141 #[serde(default)]
143 pub validation: ValidationConfig,
144
145 #[serde(default)]
147 pub debug: DebugConfig,
148
149 #[serde(default)]
151 pub mcp: McpConfig,
152
153 #[serde(default)]
158 pub naming_convention: NamingConvention,
159
160 #[serde(default)]
173 pub crud: Option<CrudNamingConfig>,
174}
175
176impl TomlSchema {
177 pub fn from_file(path: &str) -> Result<Self> {
184 let content =
185 std::fs::read_to_string(path).context(format!("Failed to read TOML file: {path}"))?;
186 Self::parse_toml(&content)
187 }
188
189 pub fn parse_toml(content: &str) -> Result<Self> {
198 let expanded = expand_env_vars(content);
199 toml::from_str(&expanded).context("Failed to parse TOML schema")
200 }
201
202 pub fn validate(&self) -> Result<()> {
211 use fraiseql_core::runtime::suggest_similar;
212
213 let type_names: Vec<&str> = self.types.keys().map(String::as_str).collect();
214
215 for (query_name, query_def) in &self.queries {
217 if !self.types.contains_key(&query_def.return_type) {
218 let hint = format_suggestions(suggest_similar(&query_def.return_type, &type_names));
219 anyhow::bail!(
220 "Query '{query_name}' references undefined type '{}'{hint}",
221 query_def.return_type
222 );
223 }
224 }
225
226 for (mut_name, mut_def) in &self.mutations {
228 if !self.types.contains_key(&mut_def.return_type) {
229 let hint = format_suggestions(suggest_similar(&mut_def.return_type, &type_names));
230 anyhow::bail!(
231 "Mutation '{mut_name}' references undefined type '{}'{hint}",
232 mut_def.return_type
233 );
234 }
235 }
236
237 for field_auth in &self.security.field_auth {
239 let policy_exists = self.security.policies.iter().any(|p| p.name == field_auth.policy);
240 if !policy_exists {
241 let policy_names: Vec<&str> =
242 self.security.policies.iter().map(|p| p.name.as_str()).collect();
243 let hint = format_suggestions(suggest_similar(&field_auth.policy, &policy_names));
244 anyhow::bail!(
245 "Field auth references undefined policy '{}'{hint}",
246 field_auth.policy
247 );
248 }
249 }
250
251 for entity in &self.federation.entities {
253 if !self.types.contains_key(&entity.name) {
254 let hint = format_suggestions(suggest_similar(&entity.name, &type_names));
255 anyhow::bail!(
256 "Federation entity '{}' references undefined type{hint}",
257 entity.name
258 );
259 }
260 }
261
262 self.server.validate()?;
263 self.database.validate()?;
264
265 if let Some(cb) = &self.federation.circuit_breaker {
267 if cb.failure_threshold == 0 {
268 anyhow::bail!(
269 "federation.circuit_breaker.failure_threshold must be greater than 0"
270 );
271 }
272 if cb.recovery_timeout_secs == 0 {
273 anyhow::bail!(
274 "federation.circuit_breaker.recovery_timeout_secs must be greater than 0"
275 );
276 }
277 if cb.success_threshold == 0 {
278 anyhow::bail!(
279 "federation.circuit_breaker.success_threshold must be greater than 0"
280 );
281 }
282
283 let entity_names: std::collections::HashSet<&str> =
285 self.federation.entities.iter().map(|e| e.name.as_str()).collect();
286 for override_cfg in &cb.per_database {
287 if !entity_names.contains(override_cfg.database.as_str()) {
288 anyhow::bail!(
289 "federation.circuit_breaker.per_database entry '{}' does not match \
290 any defined federation entity",
291 override_cfg.database
292 );
293 }
294 if override_cfg.failure_threshold == Some(0) {
295 anyhow::bail!(
296 "federation.circuit_breaker.per_database['{}'].failure_threshold \
297 must be greater than 0",
298 override_cfg.database
299 );
300 }
301 if override_cfg.recovery_timeout_secs == Some(0) {
302 anyhow::bail!(
303 "federation.circuit_breaker.per_database['{}'].recovery_timeout_secs \
304 must be greater than 0",
305 override_cfg.database
306 );
307 }
308 if override_cfg.success_threshold == Some(0) {
309 anyhow::bail!(
310 "federation.circuit_breaker.per_database['{}'].success_threshold \
311 must be greater than 0",
312 override_cfg.database
313 );
314 }
315 }
316 }
317
318 Ok(())
319 }
320
321 pub fn to_intermediate_schema(&self) -> serde_json::Value {
323 let mut types_json = serde_json::Map::new();
324
325 for (type_name, type_def) in &self.types {
326 let mut fields_json = serde_json::Map::new();
327
328 for (field_name, field_def) in &type_def.fields {
329 fields_json.insert(
330 field_name.clone(),
331 serde_json::json!({
332 "type": field_def.field_type,
333 "nullable": field_def.nullable,
334 "description": field_def.description,
335 }),
336 );
337 }
338
339 types_json.insert(
340 type_name.clone(),
341 serde_json::json!({
342 "name": type_name,
343 "sql_source": type_def.sql_source,
344 "description": type_def.description,
345 "fields": fields_json,
346 }),
347 );
348 }
349
350 let mut queries_json = serde_json::Map::new();
351
352 for (query_name, query_def) in &self.queries {
353 let args: Vec<serde_json::Value> = query_def
354 .args
355 .iter()
356 .map(|arg| {
357 serde_json::json!({
358 "name": arg.name,
359 "type": arg.arg_type,
360 "required": arg.required,
361 "default": arg.default,
362 "description": arg.description,
363 })
364 })
365 .collect();
366
367 queries_json.insert(
368 query_name.clone(),
369 serde_json::json!({
370 "name": query_name,
371 "return_type": query_def.return_type,
372 "return_array": query_def.return_array,
373 "sql_source": query_def.sql_source,
374 "description": query_def.description,
375 "args": args,
376 }),
377 );
378 }
379
380 serde_json::json!({
381 "types": types_json,
382 "queries": queries_json,
383 })
384 }
385}
386
387#[allow(clippy::unwrap_used)] #[cfg(test)]
389mod tests {
390 use super::*;
391
392 #[test]
393 fn test_parse_toml_schema() {
394 let toml = r#"
395[schema]
396name = "myapp"
397version = "1.0.0"
398database_target = "postgresql"
399
400[types.User]
401sql_source = "v_user"
402
403[types.User.fields.id]
404type = "ID"
405nullable = false
406
407[types.User.fields.name]
408type = "String"
409nullable = false
410
411[queries.users]
412return_type = "User"
413return_array = true
414sql_source = "v_user"
415"#;
416 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
417 assert_eq!(schema.schema.name, "myapp");
418 assert!(schema.types.contains_key("User"));
419 }
420
421 #[test]
422 fn test_validate_schema() {
423 let schema = TomlSchema::default();
424 schema.validate().unwrap_or_else(|e| panic!("expected Ok from validate: {e:?}"));
425 }
426
427 #[test]
430 fn test_observers_config_nats_url_round_trip() {
431 let toml = r#"
432[schema]
433name = "myapp"
434version = "1.0.0"
435database_target = "postgresql"
436
437[observers]
438enabled = true
439backend = "nats"
440nats_url = "nats://localhost:4222"
441"#;
442 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
443 assert_eq!(schema.observers.backend, "nats");
444 assert_eq!(schema.observers.nats_url.as_deref(), Some("nats://localhost:4222"));
445 assert!(schema.observers.redis_url.is_none());
446 }
447
448 #[test]
449 fn test_observers_config_redis_url_unchanged() {
450 let toml = r#"
451[schema]
452name = "myapp"
453version = "1.0.0"
454database_target = "postgresql"
455
456[observers]
457enabled = true
458backend = "redis"
459redis_url = "redis://localhost:6379"
460"#;
461 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
462 assert_eq!(schema.observers.backend, "redis");
463 assert_eq!(schema.observers.redis_url.as_deref(), Some("redis://localhost:6379"));
464 assert!(schema.observers.nats_url.is_none());
465 }
466
467 #[test]
468 fn test_observers_config_nats_url_default_is_none() {
469 let config = ObserversConfig::default();
470 assert!(config.nats_url.is_none());
471 }
472
473 #[test]
476 fn test_federation_circuit_breaker_round_trip() {
477 let toml = r#"
478[schema]
479name = "myapp"
480version = "1.0.0"
481database_target = "postgresql"
482
483[types.Product]
484sql_source = "v_product"
485
486[federation]
487enabled = true
488apollo_version = 2
489
490[[federation.entities]]
491name = "Product"
492key_fields = ["id"]
493
494[federation.circuit_breaker]
495enabled = true
496failure_threshold = 3
497recovery_timeout_secs = 60
498success_threshold = 1
499"#;
500 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
501 let cb = schema.federation.circuit_breaker.as_ref().expect("Expected circuit_breaker");
502 assert!(cb.enabled);
503 assert_eq!(cb.failure_threshold, 3);
504 assert_eq!(cb.recovery_timeout_secs, 60);
505 assert_eq!(cb.success_threshold, 1);
506 assert!(cb.per_database.is_empty());
507 }
508
509 #[test]
510 fn test_federation_circuit_breaker_zero_failure_threshold_rejected() {
511 let toml = r#"
512[schema]
513name = "myapp"
514version = "1.0.0"
515database_target = "postgresql"
516
517[federation]
518enabled = true
519
520[federation.circuit_breaker]
521enabled = true
522failure_threshold = 0
523recovery_timeout_secs = 30
524success_threshold = 2
525"#;
526 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
527 let err = schema.validate().unwrap_err();
528 assert!(err.to_string().contains("failure_threshold"), "{err}");
529 }
530
531 #[test]
532 fn test_federation_circuit_breaker_zero_recovery_timeout_rejected() {
533 let toml = r#"
534[schema]
535name = "myapp"
536version = "1.0.0"
537database_target = "postgresql"
538
539[federation]
540enabled = true
541
542[federation.circuit_breaker]
543enabled = true
544failure_threshold = 5
545recovery_timeout_secs = 0
546success_threshold = 2
547"#;
548 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
549 let err = schema.validate().unwrap_err();
550 assert!(err.to_string().contains("recovery_timeout_secs"), "{err}");
551 }
552
553 #[test]
554 fn test_federation_circuit_breaker_per_database_unknown_entity_rejected() {
555 let toml = r#"
556[schema]
557name = "myapp"
558version = "1.0.0"
559database_target = "postgresql"
560
561[types.Product]
562sql_source = "v_product"
563
564[federation]
565enabled = true
566
567[[federation.entities]]
568name = "Product"
569key_fields = ["id"]
570
571[federation.circuit_breaker]
572enabled = true
573failure_threshold = 5
574recovery_timeout_secs = 30
575success_threshold = 2
576
577[[federation.circuit_breaker.per_database]]
578database = "NonExistentEntity"
579failure_threshold = 3
580"#;
581 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
582 let err = schema.validate().unwrap_err();
583 assert!(err.to_string().contains("NonExistentEntity"), "{err}");
584 }
585
586 #[test]
587 fn test_federation_circuit_breaker_per_database_valid() {
588 let toml = r#"
589[schema]
590name = "myapp"
591version = "1.0.0"
592database_target = "postgresql"
593
594[types.Product]
595sql_source = "v_product"
596
597[federation]
598enabled = true
599
600[[federation.entities]]
601name = "Product"
602key_fields = ["id"]
603
604[federation.circuit_breaker]
605enabled = true
606failure_threshold = 5
607recovery_timeout_secs = 30
608success_threshold = 2
609
610[[federation.circuit_breaker.per_database]]
611database = "Product"
612failure_threshold = 3
613recovery_timeout_secs = 15
614"#;
615 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
616 schema.validate().unwrap_or_else(|e| panic!("expected Ok from validate: {e:?}"));
617 let cb = schema.federation.circuit_breaker.as_ref().unwrap();
618 assert_eq!(cb.per_database.len(), 1);
619 assert_eq!(cb.per_database[0].database, "Product");
620 assert_eq!(cb.per_database[0].failure_threshold, Some(3));
621 assert_eq!(cb.per_database[0].recovery_timeout_secs, Some(15));
622 }
623
624 #[test]
625 fn test_toml_schema_parses_server_section() {
626 let toml = r#"
627[schema]
628name = "myapp"
629version = "1.0.0"
630database_target = "postgresql"
631
632[server]
633host = "127.0.0.1"
634port = 9999
635
636[server.cors]
637origins = ["https://example.com"]
638"#;
639 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
640 assert_eq!(schema.server.host, "127.0.0.1");
641 assert_eq!(schema.server.port, 9999);
642 assert_eq!(schema.server.cors.origins, ["https://example.com"]);
643 }
644
645 #[test]
646 fn test_toml_schema_database_uses_runtime_config() {
647 let toml = r#"
648[schema]
649name = "myapp"
650version = "1.0.0"
651database_target = "postgresql"
652
653[database]
654url = "postgresql://localhost/mydb"
655pool_min = 5
656pool_max = 30
657ssl_mode = "require"
658"#;
659 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
660 assert_eq!(schema.database.url, Some("postgresql://localhost/mydb".to_string()));
661 assert_eq!(schema.database.pool_min, 5);
662 assert_eq!(schema.database.pool_max, 30);
663 assert_eq!(schema.database.ssl_mode, "require");
664 }
665
666 #[test]
667 fn test_env_var_expansion_in_toml_schema() {
668 temp_env::with_var("SCHEMA_TEST_DB_URL", Some("postgres://test/fraiseql"), || {
669 let toml = r#"
670[schema]
671name = "myapp"
672version = "1.0.0"
673database_target = "postgresql"
674
675[database]
676url = "${SCHEMA_TEST_DB_URL}"
677"#;
678 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
679 assert_eq!(schema.database.url, Some("postgres://test/fraiseql".to_string()));
680 });
681 }
682
683 #[test]
684 fn test_toml_schema_defaults_without_server_section() {
685 let toml = r#"
686[schema]
687name = "myapp"
688version = "1.0.0"
689database_target = "postgresql"
690"#;
691 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
692 assert_eq!(schema.server.host, "0.0.0.0");
694 assert_eq!(schema.server.port, 8080);
695 assert_eq!(schema.database.pool_min, 2);
696 assert_eq!(schema.database.pool_max, 20);
697 assert!(schema.database.url.is_none());
698 }
699
700 #[test]
701 fn test_rate_limiting_config_parses_per_user_rps() {
702 let toml = r"
703[security.rate_limiting]
704enabled = true
705requests_per_second = 100
706requests_per_second_per_user = 250
707";
708 let schema: TomlSchema = toml::from_str(toml).unwrap();
709 let rl = schema.security.rate_limiting.unwrap();
710 assert_eq!(rl.requests_per_second_per_user, Some(250));
711 }
712
713 #[test]
714 fn test_rate_limiting_config_per_user_rps_defaults_to_none() {
715 let toml = r"
716[security.rate_limiting]
717enabled = true
718requests_per_second = 50
719";
720 let schema: TomlSchema = toml::from_str(toml).unwrap();
721 let rl = schema.security.rate_limiting.unwrap();
722 assert_eq!(rl.requests_per_second_per_user, None);
723 }
724
725 #[test]
726 fn test_validation_config_parses_limits() {
727 let toml = r"
728[validation]
729max_query_depth = 5
730max_query_complexity = 50
731";
732 let schema: TomlSchema = toml::from_str(toml).unwrap();
733 assert_eq!(schema.validation.max_query_depth, Some(5));
734 assert_eq!(schema.validation.max_query_complexity, Some(50));
735 }
736
737 #[test]
738 fn test_validation_config_defaults_to_none() {
739 let toml = "";
740 let schema: TomlSchema = toml::from_str(toml).unwrap();
741 assert_eq!(schema.validation.max_query_depth, None);
742 assert_eq!(schema.validation.max_query_complexity, None);
743 }
744
745 #[test]
746 fn test_validation_config_partial() {
747 let toml = r"
748[validation]
749max_query_depth = 3
750";
751 let schema: TomlSchema = toml::from_str(toml).unwrap();
752 assert_eq!(schema.validation.max_query_depth, Some(3));
753 assert_eq!(schema.validation.max_query_complexity, None);
754 }
755}