1use std::{collections::BTreeMap, path::PathBuf};
7
8use anyhow::{Context, Result};
9use serde::{Deserialize, Serialize};
10
11use super::runtime::{DatabaseRuntimeConfig, ServerRuntimeConfig};
12use super::expand_env_vars;
13
14#[derive(Debug, Clone, Default, Deserialize, Serialize)]
36#[serde(default, deny_unknown_fields)]
37pub struct DomainDiscovery {
38 pub enabled: bool,
40 pub root_dir: String,
42}
43
44#[derive(Debug, Clone)]
46pub struct Domain {
47 pub name: String,
49 pub path: PathBuf,
51}
52
53impl DomainDiscovery {
54 pub fn resolve_domains(&self) -> Result<Vec<Domain>> {
56 if !self.enabled {
57 return Ok(Vec::new());
58 }
59
60 let root = PathBuf::from(&self.root_dir);
61 if !root.is_dir() {
62 anyhow::bail!("Domain discovery root not found: {}", self.root_dir);
63 }
64
65 let mut domains = Vec::new();
66
67 for entry in std::fs::read_dir(&root)
68 .context(format!("Failed to read domain root: {}", self.root_dir))?
69 {
70 let entry = entry.context("Failed to read directory entry")?;
71 let path = entry.path();
72
73 if path.is_dir() {
74 let name = path
75 .file_name()
76 .and_then(|n| n.to_str())
77 .map(std::string::ToString::to_string)
78 .ok_or_else(|| anyhow::anyhow!("Invalid domain name: {}", path.display()))?;
79
80 domains.push(Domain { name, path });
81 }
82 }
83
84 domains.sort_by(|a, b| a.name.cmp(&b.name));
86
87 Ok(domains)
88 }
89}
90
91#[derive(Debug, Clone, Default, Deserialize, Serialize)]
101#[serde(default, deny_unknown_fields)]
102pub struct SchemaIncludes {
103 pub types: Vec<String>,
105 pub queries: Vec<String>,
107 pub mutations: Vec<String>,
109}
110
111impl SchemaIncludes {
112 pub fn is_empty(&self) -> bool {
114 self.types.is_empty() && self.queries.is_empty() && self.mutations.is_empty()
115 }
116
117 pub fn resolve_globs(&self) -> Result<ResolvedIncludes> {
122 use glob::glob as glob_pattern;
123
124 let mut type_paths = Vec::new();
125 let mut query_paths = Vec::new();
126 let mut mutation_paths = Vec::new();
127
128 for pattern in &self.types {
130 for entry in glob_pattern(pattern)
131 .context(format!("Invalid glob pattern for types: {pattern}"))?
132 {
133 match entry {
134 Ok(path) => type_paths.push(path),
135 Err(e) => {
136 anyhow::bail!("Error resolving type glob pattern '{pattern}': {e}");
137 },
138 }
139 }
140 }
141
142 for pattern in &self.queries {
144 for entry in glob_pattern(pattern)
145 .context(format!("Invalid glob pattern for queries: {pattern}"))?
146 {
147 match entry {
148 Ok(path) => query_paths.push(path),
149 Err(e) => {
150 anyhow::bail!("Error resolving query glob pattern '{pattern}': {e}");
151 },
152 }
153 }
154 }
155
156 for pattern in &self.mutations {
158 for entry in glob_pattern(pattern)
159 .context(format!("Invalid glob pattern for mutations: {pattern}"))?
160 {
161 match entry {
162 Ok(path) => mutation_paths.push(path),
163 Err(e) => {
164 anyhow::bail!("Error resolving mutation glob pattern '{pattern}': {e}");
165 },
166 }
167 }
168 }
169
170 type_paths.sort();
172 query_paths.sort();
173 mutation_paths.sort();
174
175 type_paths.dedup();
177 query_paths.dedup();
178 mutation_paths.dedup();
179
180 Ok(ResolvedIncludes {
181 types: type_paths,
182 queries: query_paths,
183 mutations: mutation_paths,
184 })
185 }
186}
187
188#[derive(Debug, Clone)]
190pub struct ResolvedIncludes {
191 pub types: Vec<PathBuf>,
193 pub queries: Vec<PathBuf>,
195 pub mutations: Vec<PathBuf>,
197}
198
199#[derive(Debug, Clone, Default, Deserialize, Serialize)]
201#[serde(default, deny_unknown_fields)]
202pub struct TomlSchema {
203 #[serde(rename = "schema")]
205 pub schema: SchemaMetadata,
206
207 #[serde(rename = "database")]
211 pub database: DatabaseRuntimeConfig,
212
213 #[serde(rename = "server")]
217 pub server: ServerRuntimeConfig,
218
219 #[serde(rename = "types")]
221 pub types: BTreeMap<String, TypeDefinition>,
222
223 #[serde(rename = "queries")]
225 pub queries: BTreeMap<String, QueryDefinition>,
226
227 #[serde(rename = "mutations")]
229 pub mutations: BTreeMap<String, MutationDefinition>,
230
231 #[serde(rename = "federation")]
233 pub federation: FederationConfig,
234
235 #[serde(rename = "security")]
237 pub security: SecuritySettings,
238
239 #[serde(rename = "observers")]
241 pub observers: ObserversConfig,
242
243 #[serde(rename = "caching")]
245 pub caching: CachingConfig,
246
247 #[serde(rename = "analytics")]
249 pub analytics: AnalyticsConfig,
250
251 #[serde(rename = "observability")]
253 pub observability: ObservabilityConfig,
254
255 #[serde(default)]
257 pub includes: SchemaIncludes,
258
259 #[serde(default)]
261 pub domain_discovery: DomainDiscovery,
262}
263
264#[derive(Debug, Clone, Deserialize, Serialize)]
266#[serde(default, deny_unknown_fields)]
267pub struct SchemaMetadata {
268 pub name: String,
270 pub version: String,
272 pub description: Option<String>,
274 pub database_target: String,
276}
277
278impl Default for SchemaMetadata {
279 fn default() -> Self {
280 Self {
281 name: "myapp".to_string(),
282 version: "1.0.0".to_string(),
283 description: None,
284 database_target: "postgresql".to_string(),
285 }
286 }
287}
288
289#[derive(Debug, Clone, Deserialize, Serialize)]
291#[serde(default, deny_unknown_fields)]
292pub struct TypeDefinition {
293 pub sql_source: String,
295 pub description: Option<String>,
297 pub fields: BTreeMap<String, FieldDefinition>,
299}
300
301impl Default for TypeDefinition {
302 fn default() -> Self {
303 Self {
304 sql_source: "v_entity".to_string(),
305 description: None,
306 fields: BTreeMap::new(),
307 }
308 }
309}
310
311#[derive(Debug, Clone, Deserialize, Serialize)]
313#[serde(deny_unknown_fields)]
314pub struct FieldDefinition {
315 #[serde(rename = "type")]
317 pub field_type: String,
318 #[serde(default)]
320 pub nullable: bool,
321 pub description: Option<String>,
323}
324
325#[derive(Debug, Clone, Deserialize, Serialize)]
327#[serde(default, deny_unknown_fields)]
328pub struct QueryDefinition {
329 pub return_type: String,
331 #[serde(default)]
333 pub return_array: bool,
334 pub sql_source: String,
336 pub description: Option<String>,
338 pub args: Vec<ArgumentDefinition>,
340}
341
342impl Default for QueryDefinition {
343 fn default() -> Self {
344 Self {
345 return_type: "String".to_string(),
346 return_array: false,
347 sql_source: "v_entity".to_string(),
348 description: None,
349 args: vec![],
350 }
351 }
352}
353
354#[derive(Debug, Clone, Deserialize, Serialize)]
356#[serde(default, deny_unknown_fields)]
357pub struct MutationDefinition {
358 pub return_type: String,
360 pub sql_source: String,
362 pub operation: String,
364 pub description: Option<String>,
366 pub args: Vec<ArgumentDefinition>,
368}
369
370impl Default for MutationDefinition {
371 fn default() -> Self {
372 Self {
373 return_type: "String".to_string(),
374 sql_source: "fn_operation".to_string(),
375 operation: "CREATE".to_string(),
376 description: None,
377 args: vec![],
378 }
379 }
380}
381
382#[derive(Debug, Clone, Deserialize, Serialize)]
384#[serde(deny_unknown_fields)]
385pub struct ArgumentDefinition {
386 pub name: String,
388 #[serde(rename = "type")]
390 pub arg_type: String,
391 #[serde(default)]
393 pub required: bool,
394 pub default: Option<serde_json::Value>,
396 pub description: Option<String>,
398}
399
400#[derive(Debug, Clone, Deserialize, Serialize)]
402#[serde(deny_unknown_fields)]
403pub struct PerDatabaseCircuitBreakerOverride {
404 pub database: String,
406 pub failure_threshold: Option<u32>,
408 pub recovery_timeout_secs: Option<u64>,
410 pub success_threshold: Option<u32>,
412}
413
414#[derive(Debug, Clone, Deserialize, Serialize)]
416#[serde(default, deny_unknown_fields)]
417pub struct FederationCircuitBreakerConfig {
418 pub enabled: bool,
420 pub failure_threshold: u32,
422 pub recovery_timeout_secs: u64,
424 pub success_threshold: u32,
426 pub per_database: Vec<PerDatabaseCircuitBreakerOverride>,
428}
429
430impl Default for FederationCircuitBreakerConfig {
431 fn default() -> Self {
432 Self {
433 enabled: true,
434 failure_threshold: 5,
435 recovery_timeout_secs: 30,
436 success_threshold: 2,
437 per_database: vec![],
438 }
439 }
440}
441
442#[derive(Debug, Clone, Deserialize, Serialize)]
444#[serde(default, deny_unknown_fields)]
445pub struct FederationConfig {
446 #[serde(default)]
448 pub enabled: bool,
449 pub apollo_version: Option<u32>,
451 pub entities: Vec<FederationEntity>,
453 pub circuit_breaker: Option<FederationCircuitBreakerConfig>,
455}
456
457impl Default for FederationConfig {
458 fn default() -> Self {
459 Self {
460 enabled: false,
461 apollo_version: Some(2),
462 entities: vec![],
463 circuit_breaker: None,
464 }
465 }
466}
467
468#[derive(Debug, Clone, Deserialize, Serialize)]
470#[serde(deny_unknown_fields)]
471pub struct FederationEntity {
472 pub name: String,
474 pub key_fields: Vec<String>,
476}
477
478#[derive(Debug, Clone, Deserialize, Serialize)]
480#[serde(default, deny_unknown_fields)]
481pub struct SecuritySettings {
482 pub default_policy: Option<String>,
484 pub rules: Vec<AuthorizationRule>,
486 pub policies: Vec<AuthorizationPolicy>,
488 pub field_auth: Vec<FieldAuthRule>,
490 pub enterprise: EnterpriseSecurityConfig,
492}
493
494impl Default for SecuritySettings {
495 fn default() -> Self {
496 Self {
497 default_policy: Some("authenticated".to_string()),
498 rules: vec![],
499 policies: vec![],
500 field_auth: vec![],
501 enterprise: EnterpriseSecurityConfig::default(),
502 }
503 }
504}
505
506#[derive(Debug, Clone, Deserialize, Serialize)]
508#[serde(deny_unknown_fields)]
509pub struct AuthorizationRule {
510 pub name: String,
512 pub rule: String,
514 pub description: Option<String>,
516 #[serde(default)]
518 pub cacheable: bool,
519 pub cache_ttl_seconds: Option<u32>,
521}
522
523#[derive(Debug, Clone, Deserialize, Serialize)]
525#[serde(deny_unknown_fields)]
526pub struct AuthorizationPolicy {
527 pub name: String,
529 #[serde(rename = "type")]
531 pub policy_type: String,
532 pub rule: Option<String>,
534 pub roles: Vec<String>,
536 pub strategy: Option<String>,
538 #[serde(default)]
540 pub attributes: Vec<String>,
541 pub description: Option<String>,
543 pub cache_ttl_seconds: Option<u32>,
545}
546
547#[derive(Debug, Clone, Deserialize, Serialize)]
549#[serde(deny_unknown_fields)]
550pub struct FieldAuthRule {
551 pub type_name: String,
553 pub field_name: String,
555 pub policy: String,
557}
558
559#[derive(Debug, Clone, Deserialize, Serialize)]
561#[serde(default, deny_unknown_fields)]
562pub struct EnterpriseSecurityConfig {
563 pub rate_limiting_enabled: bool,
565 pub auth_endpoint_max_requests: u32,
567 pub auth_endpoint_window_seconds: u64,
569 pub audit_logging_enabled: bool,
571 pub audit_log_backend: String,
573 pub audit_retention_days: u32,
575 pub error_sanitization: bool,
577 pub hide_implementation_details: bool,
579 pub constant_time_comparison: bool,
581 pub pkce_enabled: bool,
583}
584
585impl Default for EnterpriseSecurityConfig {
586 fn default() -> Self {
587 Self {
588 rate_limiting_enabled: true,
589 auth_endpoint_max_requests: 100,
590 auth_endpoint_window_seconds: 60,
591 audit_logging_enabled: true,
592 audit_log_backend: "postgresql".to_string(),
593 audit_retention_days: 365,
594 error_sanitization: true,
595 hide_implementation_details: true,
596 constant_time_comparison: true,
597 pkce_enabled: true,
598 }
599 }
600}
601
602#[derive(Debug, Clone, Deserialize, Serialize)]
604#[serde(default, deny_unknown_fields)]
605pub struct ObserversConfig {
606 #[serde(default)]
608 pub enabled: bool,
609 pub backend: String,
611 pub redis_url: Option<String>,
613 pub nats_url: Option<String>,
618 pub handlers: Vec<EventHandler>,
620}
621
622impl Default for ObserversConfig {
623 fn default() -> Self {
624 Self {
625 enabled: false,
626 backend: "redis".to_string(),
627 redis_url: None,
628 nats_url: None,
629 handlers: vec![],
630 }
631 }
632}
633
634#[derive(Debug, Clone, Deserialize, Serialize)]
636#[serde(deny_unknown_fields)]
637pub struct EventHandler {
638 pub name: String,
640 pub event: String,
642 pub action: String,
644 pub webhook_url: Option<String>,
646 pub retry_strategy: Option<String>,
648 pub max_retries: Option<u32>,
650 pub description: Option<String>,
652}
653
654#[derive(Debug, Clone, Deserialize, Serialize)]
656#[serde(default, deny_unknown_fields)]
657pub struct CachingConfig {
658 #[serde(default)]
660 pub enabled: bool,
661 pub backend: String,
663 pub redis_url: Option<String>,
665 pub rules: Vec<CacheRule>,
667}
668
669impl Default for CachingConfig {
670 fn default() -> Self {
671 Self {
672 enabled: false,
673 backend: "redis".to_string(),
674 redis_url: None,
675 rules: vec![],
676 }
677 }
678}
679
680#[derive(Debug, Clone, Deserialize, Serialize)]
682#[serde(deny_unknown_fields)]
683pub struct CacheRule {
684 pub query: String,
686 pub ttl_seconds: u32,
688 pub invalidation_triggers: Vec<String>,
690}
691
692#[derive(Debug, Clone, Default, Deserialize, Serialize)]
694#[serde(default, deny_unknown_fields)]
695pub struct AnalyticsConfig {
696 #[serde(default)]
698 pub enabled: bool,
699 pub queries: Vec<AnalyticsQuery>,
701}
702
703#[derive(Debug, Clone, Deserialize, Serialize)]
705#[serde(deny_unknown_fields)]
706pub struct AnalyticsQuery {
707 pub name: String,
709 pub sql_source: String,
711 pub description: Option<String>,
713}
714
715#[derive(Debug, Clone, Deserialize, Serialize)]
717#[serde(default, deny_unknown_fields)]
718pub struct ObservabilityConfig {
719 pub prometheus_enabled: bool,
721 pub prometheus_port: u16,
723 pub otel_enabled: bool,
725 pub otel_exporter: String,
727 pub otel_jaeger_endpoint: Option<String>,
729 pub health_check_enabled: bool,
731 pub health_check_interval_seconds: u32,
733 pub log_level: String,
735 pub log_format: String,
737}
738
739impl Default for ObservabilityConfig {
740 fn default() -> Self {
741 Self {
742 prometheus_enabled: false,
743 prometheus_port: 9090,
744 otel_enabled: false,
745 otel_exporter: "jaeger".to_string(),
746 otel_jaeger_endpoint: None,
747 health_check_enabled: true,
748 health_check_interval_seconds: 30,
749 log_level: "info".to_string(),
750 log_format: "json".to_string(),
751 }
752 }
753}
754
755impl TomlSchema {
756 pub fn from_file(path: &str) -> Result<Self> {
758 let content =
759 std::fs::read_to_string(path).context(format!("Failed to read TOML file: {path}"))?;
760 Self::parse_toml(&content)
761 }
762
763 pub fn parse_toml(content: &str) -> Result<Self> {
767 let expanded = expand_env_vars(content);
768 toml::from_str(&expanded).context("Failed to parse TOML schema")
769 }
770
771 pub fn validate(&self) -> Result<()> {
773 for (query_name, query_def) in &self.queries {
775 if !self.types.contains_key(&query_def.return_type) {
776 anyhow::bail!(
777 "Query '{query_name}' references undefined type '{}'",
778 query_def.return_type
779 );
780 }
781 }
782
783 for (mut_name, mut_def) in &self.mutations {
785 if !self.types.contains_key(&mut_def.return_type) {
786 anyhow::bail!(
787 "Mutation '{mut_name}' references undefined type '{}'",
788 mut_def.return_type
789 );
790 }
791 }
792
793 for field_auth in &self.security.field_auth {
795 let policy_exists = self.security.policies.iter().any(|p| p.name == field_auth.policy);
796 if !policy_exists {
797 anyhow::bail!("Field auth references undefined policy '{}'", field_auth.policy);
798 }
799 }
800
801 for entity in &self.federation.entities {
803 if !self.types.contains_key(&entity.name) {
804 anyhow::bail!("Federation entity '{}' references undefined type", entity.name);
805 }
806 }
807
808 self.server.validate()?;
809 self.database.validate()?;
810
811 if let Some(cb) = &self.federation.circuit_breaker {
813 if cb.failure_threshold == 0 {
814 anyhow::bail!(
815 "federation.circuit_breaker.failure_threshold must be greater than 0"
816 );
817 }
818 if cb.recovery_timeout_secs == 0 {
819 anyhow::bail!(
820 "federation.circuit_breaker.recovery_timeout_secs must be greater than 0"
821 );
822 }
823 if cb.success_threshold == 0 {
824 anyhow::bail!(
825 "federation.circuit_breaker.success_threshold must be greater than 0"
826 );
827 }
828
829 let entity_names: std::collections::HashSet<&str> =
831 self.federation.entities.iter().map(|e| e.name.as_str()).collect();
832 for override_cfg in &cb.per_database {
833 if !entity_names.contains(override_cfg.database.as_str()) {
834 anyhow::bail!(
835 "federation.circuit_breaker.per_database entry '{}' does not match \
836 any defined federation entity",
837 override_cfg.database
838 );
839 }
840 if override_cfg.failure_threshold == Some(0) {
841 anyhow::bail!(
842 "federation.circuit_breaker.per_database['{}'].failure_threshold \
843 must be greater than 0",
844 override_cfg.database
845 );
846 }
847 if override_cfg.recovery_timeout_secs == Some(0) {
848 anyhow::bail!(
849 "federation.circuit_breaker.per_database['{}'].recovery_timeout_secs \
850 must be greater than 0",
851 override_cfg.database
852 );
853 }
854 if override_cfg.success_threshold == Some(0) {
855 anyhow::bail!(
856 "federation.circuit_breaker.per_database['{}'].success_threshold \
857 must be greater than 0",
858 override_cfg.database
859 );
860 }
861 }
862 }
863
864 Ok(())
865 }
866
867 pub fn to_intermediate_schema(&self) -> serde_json::Value {
869 let mut types_json = serde_json::Map::new();
870
871 for (type_name, type_def) in &self.types {
872 let mut fields_json = serde_json::Map::new();
873
874 for (field_name, field_def) in &type_def.fields {
875 fields_json.insert(
876 field_name.clone(),
877 serde_json::json!({
878 "type": field_def.field_type,
879 "nullable": field_def.nullable,
880 "description": field_def.description,
881 }),
882 );
883 }
884
885 types_json.insert(
886 type_name.clone(),
887 serde_json::json!({
888 "name": type_name,
889 "sql_source": type_def.sql_source,
890 "description": type_def.description,
891 "fields": fields_json,
892 }),
893 );
894 }
895
896 let mut queries_json = serde_json::Map::new();
897
898 for (query_name, query_def) in &self.queries {
899 let args: Vec<serde_json::Value> = query_def
900 .args
901 .iter()
902 .map(|arg| {
903 serde_json::json!({
904 "name": arg.name,
905 "type": arg.arg_type,
906 "required": arg.required,
907 "default": arg.default,
908 "description": arg.description,
909 })
910 })
911 .collect();
912
913 queries_json.insert(
914 query_name.clone(),
915 serde_json::json!({
916 "name": query_name,
917 "return_type": query_def.return_type,
918 "return_array": query_def.return_array,
919 "sql_source": query_def.sql_source,
920 "description": query_def.description,
921 "args": args,
922 }),
923 );
924 }
925
926 serde_json::json!({
927 "types": types_json,
928 "queries": queries_json,
929 })
930 }
931}
932
933#[cfg(test)]
934mod tests {
935 use super::*;
936
937 #[test]
938 fn test_parse_toml_schema() {
939 let toml = r#"
940[schema]
941name = "myapp"
942version = "1.0.0"
943database_target = "postgresql"
944
945[types.User]
946sql_source = "v_user"
947
948[types.User.fields.id]
949type = "ID"
950nullable = false
951
952[types.User.fields.name]
953type = "String"
954nullable = false
955
956[queries.users]
957return_type = "User"
958return_array = true
959sql_source = "v_user"
960"#;
961 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
962 assert_eq!(schema.schema.name, "myapp");
963 assert!(schema.types.contains_key("User"));
964 }
965
966 #[test]
967 fn test_validate_schema() {
968 let schema = TomlSchema::default();
969 assert!(schema.validate().is_ok());
970 }
971
972 #[test]
975 fn test_observers_config_nats_url_round_trip() {
976 let toml = r#"
977[schema]
978name = "myapp"
979version = "1.0.0"
980database_target = "postgresql"
981
982[observers]
983enabled = true
984backend = "nats"
985nats_url = "nats://localhost:4222"
986"#;
987 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
988 assert_eq!(schema.observers.backend, "nats");
989 assert_eq!(
990 schema.observers.nats_url.as_deref(),
991 Some("nats://localhost:4222")
992 );
993 assert!(schema.observers.redis_url.is_none());
994 }
995
996 #[test]
997 fn test_observers_config_redis_url_unchanged() {
998 let toml = r#"
999[schema]
1000name = "myapp"
1001version = "1.0.0"
1002database_target = "postgresql"
1003
1004[observers]
1005enabled = true
1006backend = "redis"
1007redis_url = "redis://localhost:6379"
1008"#;
1009 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1010 assert_eq!(schema.observers.backend, "redis");
1011 assert_eq!(
1012 schema.observers.redis_url.as_deref(),
1013 Some("redis://localhost:6379")
1014 );
1015 assert!(schema.observers.nats_url.is_none());
1016 }
1017
1018 #[test]
1019 fn test_observers_config_nats_url_default_is_none() {
1020 let config = ObserversConfig::default();
1021 assert!(config.nats_url.is_none());
1022 }
1023
1024 #[test]
1027 fn test_federation_circuit_breaker_round_trip() {
1028 let toml = r#"
1029[schema]
1030name = "myapp"
1031version = "1.0.0"
1032database_target = "postgresql"
1033
1034[types.Product]
1035sql_source = "v_product"
1036
1037[federation]
1038enabled = true
1039apollo_version = 2
1040
1041[[federation.entities]]
1042name = "Product"
1043key_fields = ["id"]
1044
1045[federation.circuit_breaker]
1046enabled = true
1047failure_threshold = 3
1048recovery_timeout_secs = 60
1049success_threshold = 1
1050"#;
1051 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1052 let cb = schema.federation.circuit_breaker.as_ref().expect("Expected circuit_breaker");
1053 assert!(cb.enabled);
1054 assert_eq!(cb.failure_threshold, 3);
1055 assert_eq!(cb.recovery_timeout_secs, 60);
1056 assert_eq!(cb.success_threshold, 1);
1057 assert!(cb.per_database.is_empty());
1058 }
1059
1060 #[test]
1061 fn test_federation_circuit_breaker_zero_failure_threshold_rejected() {
1062 let toml = r#"
1063[schema]
1064name = "myapp"
1065version = "1.0.0"
1066database_target = "postgresql"
1067
1068[federation]
1069enabled = true
1070
1071[federation.circuit_breaker]
1072enabled = true
1073failure_threshold = 0
1074recovery_timeout_secs = 30
1075success_threshold = 2
1076"#;
1077 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1078 let err = schema.validate().unwrap_err();
1079 assert!(err.to_string().contains("failure_threshold"), "{err}");
1080 }
1081
1082 #[test]
1083 fn test_federation_circuit_breaker_zero_recovery_timeout_rejected() {
1084 let toml = r#"
1085[schema]
1086name = "myapp"
1087version = "1.0.0"
1088database_target = "postgresql"
1089
1090[federation]
1091enabled = true
1092
1093[federation.circuit_breaker]
1094enabled = true
1095failure_threshold = 5
1096recovery_timeout_secs = 0
1097success_threshold = 2
1098"#;
1099 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1100 let err = schema.validate().unwrap_err();
1101 assert!(err.to_string().contains("recovery_timeout_secs"), "{err}");
1102 }
1103
1104 #[test]
1105 fn test_federation_circuit_breaker_per_database_unknown_entity_rejected() {
1106 let toml = r#"
1107[schema]
1108name = "myapp"
1109version = "1.0.0"
1110database_target = "postgresql"
1111
1112[types.Product]
1113sql_source = "v_product"
1114
1115[federation]
1116enabled = true
1117
1118[[federation.entities]]
1119name = "Product"
1120key_fields = ["id"]
1121
1122[federation.circuit_breaker]
1123enabled = true
1124failure_threshold = 5
1125recovery_timeout_secs = 30
1126success_threshold = 2
1127
1128[[federation.circuit_breaker.per_database]]
1129database = "NonExistentEntity"
1130failure_threshold = 3
1131"#;
1132 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1133 let err = schema.validate().unwrap_err();
1134 assert!(err.to_string().contains("NonExistentEntity"), "{err}");
1135 }
1136
1137 #[test]
1138 fn test_federation_circuit_breaker_per_database_valid() {
1139 let toml = r#"
1140[schema]
1141name = "myapp"
1142version = "1.0.0"
1143database_target = "postgresql"
1144
1145[types.Product]
1146sql_source = "v_product"
1147
1148[federation]
1149enabled = true
1150
1151[[federation.entities]]
1152name = "Product"
1153key_fields = ["id"]
1154
1155[federation.circuit_breaker]
1156enabled = true
1157failure_threshold = 5
1158recovery_timeout_secs = 30
1159success_threshold = 2
1160
1161[[federation.circuit_breaker.per_database]]
1162database = "Product"
1163failure_threshold = 3
1164recovery_timeout_secs = 15
1165"#;
1166 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1167 assert!(schema.validate().is_ok());
1168 let cb = schema.federation.circuit_breaker.as_ref().unwrap();
1169 assert_eq!(cb.per_database.len(), 1);
1170 assert_eq!(cb.per_database[0].database, "Product");
1171 assert_eq!(cb.per_database[0].failure_threshold, Some(3));
1172 assert_eq!(cb.per_database[0].recovery_timeout_secs, Some(15));
1173 }
1174
1175 #[test]
1176 fn test_toml_schema_parses_server_section() {
1177 let toml = r#"
1178[schema]
1179name = "myapp"
1180version = "1.0.0"
1181database_target = "postgresql"
1182
1183[server]
1184host = "127.0.0.1"
1185port = 9999
1186
1187[server.cors]
1188origins = ["https://example.com"]
1189"#;
1190 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1191 assert_eq!(schema.server.host, "127.0.0.1");
1192 assert_eq!(schema.server.port, 9999);
1193 assert_eq!(schema.server.cors.origins, ["https://example.com"]);
1194 }
1195
1196 #[test]
1197 fn test_toml_schema_database_uses_runtime_config() {
1198 let toml = r#"
1199[schema]
1200name = "myapp"
1201version = "1.0.0"
1202database_target = "postgresql"
1203
1204[database]
1205url = "postgresql://localhost/mydb"
1206pool_min = 5
1207pool_max = 30
1208ssl_mode = "require"
1209"#;
1210 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1211 assert_eq!(schema.database.url, Some("postgresql://localhost/mydb".to_string()));
1212 assert_eq!(schema.database.pool_min, 5);
1213 assert_eq!(schema.database.pool_max, 30);
1214 assert_eq!(schema.database.ssl_mode, "require");
1215 }
1216
1217 #[test]
1218 fn test_env_var_expansion_in_toml_schema() {
1219 temp_env::with_var("SCHEMA_TEST_DB_URL", Some("postgres://test/fraiseql"), || {
1220 let toml = r#"
1221[schema]
1222name = "myapp"
1223version = "1.0.0"
1224database_target = "postgresql"
1225
1226[database]
1227url = "${SCHEMA_TEST_DB_URL}"
1228"#;
1229 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1230 assert_eq!(
1231 schema.database.url,
1232 Some("postgres://test/fraiseql".to_string())
1233 );
1234 });
1235 }
1236
1237 #[test]
1238 fn test_toml_schema_defaults_without_server_section() {
1239 let toml = r#"
1240[schema]
1241name = "myapp"
1242version = "1.0.0"
1243database_target = "postgresql"
1244"#;
1245 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1246 assert_eq!(schema.server.host, "0.0.0.0");
1248 assert_eq!(schema.server.port, 8080);
1249 assert_eq!(schema.database.pool_min, 2);
1250 assert_eq!(schema.database.pool_max, 20);
1251 assert!(schema.database.url.is_none());
1252 }
1253}