1use std::{collections::BTreeMap, fmt, 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, Deserialize, Serialize)]
212#[serde(default, deny_unknown_fields)]
213pub struct QueryDefaults {
214 #[serde(rename = "where", default = "default_true")]
216 pub where_clause: bool,
217 #[serde(default = "default_true")]
219 pub order_by: bool,
220 #[serde(default = "default_true")]
222 pub limit: bool,
223 #[serde(default = "default_true")]
225 pub offset: bool,
226}
227
228impl Default for QueryDefaults {
229 fn default() -> Self {
230 Self { where_clause: true, order_by: true, limit: true, offset: true }
231 }
232}
233
234fn default_true() -> bool {
235 true
236}
237
238#[derive(Debug, Clone, Default, Deserialize, Serialize)]
240#[serde(default, deny_unknown_fields)]
241pub struct TomlSchema {
242 #[serde(rename = "schema")]
244 pub schema: SchemaMetadata,
245
246 #[serde(rename = "database")]
250 pub database: DatabaseRuntimeConfig,
251
252 #[serde(rename = "server")]
256 pub server: ServerRuntimeConfig,
257
258 #[serde(rename = "types")]
260 pub types: BTreeMap<String, TypeDefinition>,
261
262 #[serde(rename = "queries")]
264 pub queries: BTreeMap<String, QueryDefinition>,
265
266 #[serde(rename = "mutations")]
268 pub mutations: BTreeMap<String, MutationDefinition>,
269
270 #[serde(rename = "federation")]
272 pub federation: FederationConfig,
273
274 #[serde(rename = "security")]
276 pub security: SecuritySettings,
277
278 #[serde(rename = "observers")]
280 pub observers: ObserversConfig,
281
282 #[serde(rename = "caching")]
284 pub caching: CachingConfig,
285
286 #[serde(rename = "analytics")]
288 pub analytics: AnalyticsConfig,
289
290 #[serde(rename = "observability")]
292 pub observability: ObservabilityConfig,
293
294 #[serde(default)]
296 pub includes: SchemaIncludes,
297
298 #[serde(default)]
300 pub domain_discovery: DomainDiscovery,
301
302 #[serde(default)]
309 pub query_defaults: QueryDefaults,
310
311 #[serde(default)]
317 pub auth: Option<OidcClientConfig>,
318
319 #[serde(default)]
321 pub subscriptions: SubscriptionsConfig,
322
323 #[serde(default)]
325 pub validation: ValidationConfig,
326
327 #[serde(default)]
329 pub debug: DebugConfig,
330
331 #[serde(default)]
333 pub mcp: McpConfig,
334}
335
336#[derive(Debug, Clone, Deserialize, Serialize)]
341#[serde(default, deny_unknown_fields)]
342pub struct McpConfig {
343 pub enabled: bool,
345 pub transport: String,
347 pub path: String,
349 pub require_auth: bool,
351 #[serde(default)]
353 pub include: Vec<String>,
354 #[serde(default)]
356 pub exclude: Vec<String>,
357}
358
359impl Default for McpConfig {
360 fn default() -> Self {
361 Self {
362 enabled: false,
363 transport: "http".to_string(),
364 path: "/mcp".to_string(),
365 require_auth: true,
366 include: Vec::new(),
367 exclude: Vec::new(),
368 }
369 }
370}
371
372#[derive(Debug, Clone, Deserialize, Serialize)]
374#[serde(default, deny_unknown_fields)]
375pub struct SchemaMetadata {
376 pub name: String,
378 pub version: String,
380 pub description: Option<String>,
382 pub database_target: String,
384}
385
386impl Default for SchemaMetadata {
387 fn default() -> Self {
388 Self {
389 name: "myapp".to_string(),
390 version: "1.0.0".to_string(),
391 description: None,
392 database_target: "postgresql".to_string(),
393 }
394 }
395}
396
397#[derive(Debug, Clone, Deserialize, Serialize)]
399#[serde(default, deny_unknown_fields)]
400pub struct TypeDefinition {
401 pub sql_source: String,
403 pub description: Option<String>,
405 pub fields: BTreeMap<String, FieldDefinition>,
407}
408
409impl Default for TypeDefinition {
410 fn default() -> Self {
411 Self {
412 sql_source: "v_entity".to_string(),
413 description: None,
414 fields: BTreeMap::new(),
415 }
416 }
417}
418
419#[derive(Debug, Clone, Deserialize, Serialize)]
421#[serde(deny_unknown_fields)]
422pub struct FieldDefinition {
423 #[serde(rename = "type")]
425 pub field_type: String,
426 #[serde(default)]
428 pub nullable: bool,
429 pub description: Option<String>,
431}
432
433#[derive(Debug, Clone, Deserialize, Serialize)]
435#[serde(default, deny_unknown_fields)]
436pub struct QueryDefinition {
437 pub return_type: String,
439 #[serde(default)]
441 pub return_array: bool,
442 pub sql_source: String,
444 pub description: Option<String>,
446 pub args: Vec<ArgumentDefinition>,
448}
449
450impl Default for QueryDefinition {
451 fn default() -> Self {
452 Self {
453 return_type: "String".to_string(),
454 return_array: false,
455 sql_source: "v_entity".to_string(),
456 description: None,
457 args: vec![],
458 }
459 }
460}
461
462#[derive(Debug, Clone, Deserialize, Serialize)]
464#[serde(default, deny_unknown_fields)]
465pub struct MutationDefinition {
466 pub return_type: String,
468 pub sql_source: String,
470 pub operation: String,
472 pub description: Option<String>,
474 pub args: Vec<ArgumentDefinition>,
476}
477
478impl Default for MutationDefinition {
479 fn default() -> Self {
480 Self {
481 return_type: "String".to_string(),
482 sql_source: "fn_operation".to_string(),
483 operation: "CREATE".to_string(),
484 description: None,
485 args: vec![],
486 }
487 }
488}
489
490#[derive(Debug, Clone, Deserialize, Serialize)]
492#[serde(deny_unknown_fields)]
493pub struct ArgumentDefinition {
494 pub name: String,
496 #[serde(rename = "type")]
498 pub arg_type: String,
499 #[serde(default)]
501 pub required: bool,
502 pub default: Option<serde_json::Value>,
504 pub description: Option<String>,
506}
507
508#[derive(Debug, Clone, Deserialize, Serialize)]
510#[serde(deny_unknown_fields)]
511pub struct PerDatabaseCircuitBreakerOverride {
512 pub database: String,
514 pub failure_threshold: Option<u32>,
516 pub recovery_timeout_secs: Option<u64>,
518 pub success_threshold: Option<u32>,
520}
521
522#[derive(Debug, Clone, Deserialize, Serialize)]
524#[serde(default, deny_unknown_fields)]
525pub struct FederationCircuitBreakerConfig {
526 pub enabled: bool,
528 pub failure_threshold: u32,
530 pub recovery_timeout_secs: u64,
532 pub success_threshold: u32,
534 pub per_database: Vec<PerDatabaseCircuitBreakerOverride>,
536}
537
538impl Default for FederationCircuitBreakerConfig {
539 fn default() -> Self {
540 Self {
541 enabled: true,
542 failure_threshold: 5,
543 recovery_timeout_secs: 30,
544 success_threshold: 2,
545 per_database: vec![],
546 }
547 }
548}
549
550#[derive(Debug, Clone, Deserialize, Serialize)]
552#[serde(default, deny_unknown_fields)]
553pub struct FederationConfig {
554 #[serde(default)]
556 pub enabled: bool,
557 pub apollo_version: Option<u32>,
559 pub entities: Vec<FederationEntity>,
561 pub circuit_breaker: Option<FederationCircuitBreakerConfig>,
563}
564
565impl Default for FederationConfig {
566 fn default() -> Self {
567 Self {
568 enabled: false,
569 apollo_version: Some(2),
570 entities: vec![],
571 circuit_breaker: None,
572 }
573 }
574}
575
576#[derive(Debug, Clone, Deserialize, Serialize)]
578#[serde(deny_unknown_fields)]
579pub struct FederationEntity {
580 pub name: String,
582 pub key_fields: Vec<String>,
584}
585
586#[derive(Debug, Clone, Deserialize, Serialize)]
588#[serde(default, deny_unknown_fields)]
589pub struct SecuritySettings {
590 pub default_policy: Option<String>,
592 pub rules: Vec<AuthorizationRule>,
594 pub policies: Vec<AuthorizationPolicy>,
596 pub field_auth: Vec<FieldAuthRule>,
598 pub enterprise: EnterpriseSecurityConfig,
600 pub error_sanitization: Option<ErrorSanitizationTomlConfig>,
602 pub rate_limiting: Option<RateLimitingSecurityConfig>,
604 pub state_encryption: Option<StateEncryptionConfig>,
606 pub pkce: Option<PkceConfig>,
608 pub api_keys: Option<ApiKeySecurityConfig>,
610 pub token_revocation: Option<TokenRevocationSecurityConfig>,
612 pub trusted_documents: Option<TrustedDocumentsConfig>,
614}
615
616impl Default for SecuritySettings {
617 fn default() -> Self {
618 Self {
619 default_policy: Some("authenticated".to_string()),
620 rules: vec![],
621 policies: vec![],
622 field_auth: vec![],
623 enterprise: EnterpriseSecurityConfig::default(),
624 error_sanitization: None,
625 rate_limiting: None,
626 state_encryption: None,
627 pkce: None,
628 api_keys: None,
629 token_revocation: None,
630 trusted_documents: None,
631 }
632 }
633}
634
635#[derive(Debug, Clone, Deserialize, Serialize)]
637#[serde(deny_unknown_fields)]
638pub struct AuthorizationRule {
639 pub name: String,
641 pub rule: String,
643 pub description: Option<String>,
645 #[serde(default)]
647 pub cacheable: bool,
648 pub cache_ttl_seconds: Option<u32>,
650}
651
652#[derive(Debug, Clone, Deserialize, Serialize)]
654#[serde(deny_unknown_fields)]
655pub struct AuthorizationPolicy {
656 pub name: String,
658 #[serde(rename = "type")]
660 pub policy_type: String,
661 pub rule: Option<String>,
663 pub roles: Vec<String>,
665 pub strategy: Option<String>,
667 #[serde(default)]
669 pub attributes: Vec<String>,
670 pub description: Option<String>,
672 pub cache_ttl_seconds: Option<u32>,
674}
675
676#[derive(Debug, Clone, Deserialize, Serialize)]
678#[serde(deny_unknown_fields)]
679pub struct FieldAuthRule {
680 pub type_name: String,
682 pub field_name: String,
684 pub policy: String,
686}
687
688#[derive(Debug, Clone, Deserialize, Serialize)]
690#[serde(default, deny_unknown_fields)]
691pub struct EnterpriseSecurityConfig {
692 pub rate_limiting_enabled: bool,
694 pub auth_endpoint_max_requests: u32,
696 pub auth_endpoint_window_seconds: u64,
698 pub audit_logging_enabled: bool,
700 pub audit_log_backend: String,
702 pub audit_retention_days: u32,
704 pub error_sanitization: bool,
706 pub hide_implementation_details: bool,
708 pub constant_time_comparison: bool,
710 pub pkce_enabled: bool,
712}
713
714impl Default for EnterpriseSecurityConfig {
715 fn default() -> Self {
716 Self {
717 rate_limiting_enabled: true,
718 auth_endpoint_max_requests: 100,
719 auth_endpoint_window_seconds: 60,
720 audit_logging_enabled: true,
721 audit_log_backend: "postgresql".to_string(),
722 audit_retention_days: 365,
723 error_sanitization: true,
724 hide_implementation_details: true,
725 constant_time_comparison: true,
726 pkce_enabled: true,
727 }
728 }
729}
730
731#[derive(Debug, Clone, Deserialize, Serialize)]
737#[serde(default, deny_unknown_fields)]
738pub struct ErrorSanitizationTomlConfig {
739 pub enabled: bool,
741 #[serde(default = "default_true")]
743 pub hide_implementation_details: bool,
744 #[serde(default = "default_true")]
746 pub sanitize_database_errors: bool,
747 pub custom_error_message: Option<String>,
749}
750
751impl Default for ErrorSanitizationTomlConfig {
752 fn default() -> Self {
753 Self {
754 enabled: false,
755 hide_implementation_details: true,
756 sanitize_database_errors: true,
757 custom_error_message: None,
758 }
759 }
760}
761
762#[derive(Debug, Clone, Deserialize, Serialize)]
764#[serde(default, deny_unknown_fields)]
765pub struct RateLimitingSecurityConfig {
766 pub enabled: bool,
768 pub requests_per_second: u32,
770 pub burst_size: u32,
772 pub auth_start_max_requests: u32,
774 pub auth_start_window_secs: u64,
776 pub auth_callback_max_requests: u32,
778 pub auth_callback_window_secs: u64,
780 pub auth_refresh_max_requests: u32,
782 pub auth_refresh_window_secs: u64,
784 pub auth_logout_max_requests: u32,
786 pub auth_logout_window_secs: u64,
788 pub failed_login_max_attempts: u32,
790 pub failed_login_lockout_secs: u64,
792 #[serde(default, skip_serializing_if = "Option::is_none")]
795 pub requests_per_second_per_user: Option<u32>,
796 pub redis_url: Option<String>,
798 #[serde(default)]
804 pub trust_proxy_headers: bool,
805}
806
807impl Default for RateLimitingSecurityConfig {
808 fn default() -> Self {
809 Self {
810 enabled: false,
811 requests_per_second: 100,
812 requests_per_second_per_user: None,
813 burst_size: 200,
814 auth_start_max_requests: 5,
815 auth_start_window_secs: 60,
816 auth_callback_max_requests: 10,
817 auth_callback_window_secs: 60,
818 auth_refresh_max_requests: 20,
819 auth_refresh_window_secs: 300,
820 auth_logout_max_requests: 30,
821 auth_logout_window_secs: 60,
822 failed_login_max_attempts: 10,
823 failed_login_lockout_secs: 900,
824 redis_url: None,
825 trust_proxy_headers: false,
826 }
827 }
828}
829
830#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
832pub enum EncryptionAlgorithm {
833 #[default]
835 #[serde(rename = "chacha20-poly1305")]
836 Chacha20Poly1305,
837 #[serde(rename = "aes-256-gcm")]
839 Aes256Gcm,
840}
841
842impl fmt::Display for EncryptionAlgorithm {
843 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
844 match self {
845 Self::Chacha20Poly1305 => f.write_str("chacha20-poly1305"),
846 Self::Aes256Gcm => f.write_str("aes-256-gcm"),
847 }
848 }
849}
850
851#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
853#[serde(rename_all = "lowercase")]
854pub enum KeySource {
855 #[default]
857 Env,
858}
859
860#[derive(Debug, Clone, Deserialize, Serialize)]
862#[serde(default, deny_unknown_fields)]
863pub struct StateEncryptionConfig {
864 pub enabled: bool,
866 pub algorithm: EncryptionAlgorithm,
868 pub key_source: KeySource,
870 pub key_env: Option<String>,
872}
873
874impl Default for StateEncryptionConfig {
875 fn default() -> Self {
876 Self {
877 enabled: false,
878 algorithm: EncryptionAlgorithm::default(),
879 key_source: KeySource::Env,
880 key_env: Some("STATE_ENCRYPTION_KEY".to_string()),
881 }
882 }
883}
884
885#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
887pub enum CodeChallengeMethod {
888 #[default]
890 #[serde(rename = "S256")]
891 S256,
892 #[serde(rename = "plain")]
894 Plain,
895}
896
897#[derive(Debug, Clone, Deserialize, Serialize)]
900#[serde(default, deny_unknown_fields)]
901pub struct PkceConfig {
902 pub enabled: bool,
904 pub code_challenge_method: CodeChallengeMethod,
906 pub state_ttl_secs: u64,
908 #[serde(skip_serializing_if = "Option::is_none")]
917 pub redis_url: Option<String>,
918}
919
920impl Default for PkceConfig {
921 fn default() -> Self {
922 Self {
923 enabled: false,
924 code_challenge_method: CodeChallengeMethod::S256,
925 state_ttl_secs: 600,
926 redis_url: None,
927 }
928 }
929}
930
931#[derive(Debug, Clone, Deserialize, Serialize)]
946#[serde(default, deny_unknown_fields)]
947pub struct ApiKeySecurityConfig {
948 pub enabled: bool,
950 pub header: String,
952 pub hash_algorithm: String,
954 pub storage: String,
956 #[serde(default, rename = "static")]
958 pub static_keys: Vec<StaticApiKeyEntry>,
959}
960
961impl Default for ApiKeySecurityConfig {
962 fn default() -> Self {
963 Self {
964 enabled: false,
965 header: "X-API-Key".to_string(),
966 hash_algorithm: "sha256".to_string(),
967 storage: "env".to_string(),
968 static_keys: vec![],
969 }
970 }
971}
972
973#[derive(Debug, Clone, Deserialize, Serialize)]
975#[serde(deny_unknown_fields)]
976pub struct StaticApiKeyEntry {
977 pub key_hash: String,
979 #[serde(default)]
981 pub scopes: Vec<String>,
982 pub name: String,
984}
985
986#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
988#[serde(rename_all = "lowercase")]
989pub enum TrustedDocumentMode {
990 Strict,
992 #[default]
994 Permissive,
995}
996
997#[derive(Debug, Clone, Deserialize, Serialize)]
1007#[serde(default, deny_unknown_fields)]
1008pub struct TrustedDocumentsConfig {
1009 pub enabled: bool,
1011 pub mode: TrustedDocumentMode,
1013 #[serde(skip_serializing_if = "Option::is_none")]
1015 pub manifest_path: Option<String>,
1016 #[serde(skip_serializing_if = "Option::is_none")]
1018 pub manifest_url: Option<String>,
1019 #[serde(default)]
1021 pub reload_interval_secs: u64,
1022}
1023
1024impl Default for TrustedDocumentsConfig {
1025 fn default() -> Self {
1026 Self {
1027 enabled: false,
1028 mode: TrustedDocumentMode::Permissive,
1029 manifest_path: None,
1030 manifest_url: None,
1031 reload_interval_secs: 0,
1032 }
1033 }
1034}
1035
1036#[derive(Debug, Clone, Deserialize, Serialize)]
1046#[serde(default, deny_unknown_fields)]
1047pub struct TokenRevocationSecurityConfig {
1048 pub enabled: bool,
1050 pub backend: String,
1052 #[serde(default = "default_true")]
1054 pub require_jti: bool,
1055 #[serde(default)]
1057 pub fail_open: bool,
1058 #[serde(skip_serializing_if = "Option::is_none")]
1060 pub redis_url: Option<String>,
1061}
1062
1063impl Default for TokenRevocationSecurityConfig {
1064 fn default() -> Self {
1065 Self {
1066 enabled: false,
1067 backend: "memory".to_string(),
1068 require_jti: true,
1069 fail_open: false,
1070 redis_url: None,
1071 }
1072 }
1073}
1074
1075#[derive(Debug, Clone, Deserialize, Serialize)]
1088#[serde(deny_unknown_fields)]
1089pub struct OidcClientConfig {
1090 pub discovery_url: String,
1093 pub client_id: String,
1095 pub client_secret_env: String,
1098 pub server_redirect_uri: String,
1101}
1102
1103#[derive(Debug, Clone, Deserialize, Serialize)]
1105#[serde(default, deny_unknown_fields)]
1106pub struct ObserversConfig {
1107 #[serde(default)]
1109 pub enabled: bool,
1110 pub backend: String,
1112 pub redis_url: Option<String>,
1114 pub nats_url: Option<String>,
1119 pub handlers: Vec<EventHandler>,
1121}
1122
1123impl Default for ObserversConfig {
1124 fn default() -> Self {
1125 Self {
1126 enabled: false,
1127 backend: "redis".to_string(),
1128 redis_url: None,
1129 nats_url: None,
1130 handlers: vec![],
1131 }
1132 }
1133}
1134
1135#[derive(Debug, Clone, Deserialize, Serialize)]
1137#[serde(deny_unknown_fields)]
1138pub struct EventHandler {
1139 pub name: String,
1141 pub event: String,
1143 pub action: String,
1145 pub webhook_url: Option<String>,
1147 pub retry_strategy: Option<String>,
1149 pub max_retries: Option<u32>,
1151 pub description: Option<String>,
1153}
1154
1155#[derive(Debug, Clone, Deserialize, Serialize)]
1157#[serde(default, deny_unknown_fields)]
1158pub struct CachingConfig {
1159 #[serde(default)]
1161 pub enabled: bool,
1162 pub backend: String,
1164 pub redis_url: Option<String>,
1166 pub rules: Vec<CacheRule>,
1168}
1169
1170impl Default for CachingConfig {
1171 fn default() -> Self {
1172 Self {
1173 enabled: false,
1174 backend: "redis".to_string(),
1175 redis_url: None,
1176 rules: vec![],
1177 }
1178 }
1179}
1180
1181#[derive(Debug, Clone, Deserialize, Serialize)]
1183#[serde(deny_unknown_fields)]
1184pub struct CacheRule {
1185 pub query: String,
1187 pub ttl_seconds: u32,
1189 pub invalidation_triggers: Vec<String>,
1191}
1192
1193#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1195#[serde(default, deny_unknown_fields)]
1196pub struct AnalyticsConfig {
1197 #[serde(default)]
1199 pub enabled: bool,
1200 pub queries: Vec<AnalyticsQuery>,
1202}
1203
1204#[derive(Debug, Clone, Deserialize, Serialize)]
1206#[serde(deny_unknown_fields)]
1207pub struct AnalyticsQuery {
1208 pub name: String,
1210 pub sql_source: String,
1212 pub description: Option<String>,
1214}
1215
1216#[derive(Debug, Clone, Deserialize, Serialize)]
1218#[serde(default, deny_unknown_fields)]
1219pub struct ObservabilityConfig {
1220 pub prometheus_enabled: bool,
1222 pub prometheus_port: u16,
1224 pub otel_enabled: bool,
1226 pub otel_exporter: String,
1228 pub otel_jaeger_endpoint: Option<String>,
1230 pub health_check_enabled: bool,
1232 pub health_check_interval_seconds: u32,
1234 pub log_level: String,
1236 pub log_format: String,
1238}
1239
1240impl Default for ObservabilityConfig {
1241 fn default() -> Self {
1242 Self {
1243 prometheus_enabled: false,
1244 prometheus_port: 9090,
1245 otel_enabled: false,
1246 otel_exporter: "jaeger".to_string(),
1247 otel_jaeger_endpoint: None,
1248 health_check_enabled: true,
1249 health_check_interval_seconds: 30,
1250 log_level: "info".to_string(),
1251 log_format: "json".to_string(),
1252 }
1253 }
1254}
1255
1256impl TomlSchema {
1257 pub fn from_file(path: &str) -> Result<Self> {
1259 let content =
1260 std::fs::read_to_string(path).context(format!("Failed to read TOML file: {path}"))?;
1261 Self::parse_toml(&content)
1262 }
1263
1264 pub fn parse_toml(content: &str) -> Result<Self> {
1268 let expanded = expand_env_vars(content);
1269 toml::from_str(&expanded).context("Failed to parse TOML schema")
1270 }
1271
1272 pub fn validate(&self) -> Result<()> {
1274 for (query_name, query_def) in &self.queries {
1276 if !self.types.contains_key(&query_def.return_type) {
1277 anyhow::bail!(
1278 "Query '{query_name}' references undefined type '{}'",
1279 query_def.return_type
1280 );
1281 }
1282 }
1283
1284 for (mut_name, mut_def) in &self.mutations {
1286 if !self.types.contains_key(&mut_def.return_type) {
1287 anyhow::bail!(
1288 "Mutation '{mut_name}' references undefined type '{}'",
1289 mut_def.return_type
1290 );
1291 }
1292 }
1293
1294 for field_auth in &self.security.field_auth {
1296 let policy_exists = self.security.policies.iter().any(|p| p.name == field_auth.policy);
1297 if !policy_exists {
1298 anyhow::bail!("Field auth references undefined policy '{}'", field_auth.policy);
1299 }
1300 }
1301
1302 for entity in &self.federation.entities {
1304 if !self.types.contains_key(&entity.name) {
1305 anyhow::bail!("Federation entity '{}' references undefined type", entity.name);
1306 }
1307 }
1308
1309 self.server.validate()?;
1310 self.database.validate()?;
1311
1312 if let Some(cb) = &self.federation.circuit_breaker {
1314 if cb.failure_threshold == 0 {
1315 anyhow::bail!(
1316 "federation.circuit_breaker.failure_threshold must be greater than 0"
1317 );
1318 }
1319 if cb.recovery_timeout_secs == 0 {
1320 anyhow::bail!(
1321 "federation.circuit_breaker.recovery_timeout_secs must be greater than 0"
1322 );
1323 }
1324 if cb.success_threshold == 0 {
1325 anyhow::bail!(
1326 "federation.circuit_breaker.success_threshold must be greater than 0"
1327 );
1328 }
1329
1330 let entity_names: std::collections::HashSet<&str> =
1332 self.federation.entities.iter().map(|e| e.name.as_str()).collect();
1333 for override_cfg in &cb.per_database {
1334 if !entity_names.contains(override_cfg.database.as_str()) {
1335 anyhow::bail!(
1336 "federation.circuit_breaker.per_database entry '{}' does not match \
1337 any defined federation entity",
1338 override_cfg.database
1339 );
1340 }
1341 if override_cfg.failure_threshold == Some(0) {
1342 anyhow::bail!(
1343 "federation.circuit_breaker.per_database['{}'].failure_threshold \
1344 must be greater than 0",
1345 override_cfg.database
1346 );
1347 }
1348 if override_cfg.recovery_timeout_secs == Some(0) {
1349 anyhow::bail!(
1350 "federation.circuit_breaker.per_database['{}'].recovery_timeout_secs \
1351 must be greater than 0",
1352 override_cfg.database
1353 );
1354 }
1355 if override_cfg.success_threshold == Some(0) {
1356 anyhow::bail!(
1357 "federation.circuit_breaker.per_database['{}'].success_threshold \
1358 must be greater than 0",
1359 override_cfg.database
1360 );
1361 }
1362 }
1363 }
1364
1365 Ok(())
1366 }
1367
1368 pub fn to_intermediate_schema(&self) -> serde_json::Value {
1370 let mut types_json = serde_json::Map::new();
1371
1372 for (type_name, type_def) in &self.types {
1373 let mut fields_json = serde_json::Map::new();
1374
1375 for (field_name, field_def) in &type_def.fields {
1376 fields_json.insert(
1377 field_name.clone(),
1378 serde_json::json!({
1379 "type": field_def.field_type,
1380 "nullable": field_def.nullable,
1381 "description": field_def.description,
1382 }),
1383 );
1384 }
1385
1386 types_json.insert(
1387 type_name.clone(),
1388 serde_json::json!({
1389 "name": type_name,
1390 "sql_source": type_def.sql_source,
1391 "description": type_def.description,
1392 "fields": fields_json,
1393 }),
1394 );
1395 }
1396
1397 let mut queries_json = serde_json::Map::new();
1398
1399 for (query_name, query_def) in &self.queries {
1400 let args: Vec<serde_json::Value> = query_def
1401 .args
1402 .iter()
1403 .map(|arg| {
1404 serde_json::json!({
1405 "name": arg.name,
1406 "type": arg.arg_type,
1407 "required": arg.required,
1408 "default": arg.default,
1409 "description": arg.description,
1410 })
1411 })
1412 .collect();
1413
1414 queries_json.insert(
1415 query_name.clone(),
1416 serde_json::json!({
1417 "name": query_name,
1418 "return_type": query_def.return_type,
1419 "return_array": query_def.return_array,
1420 "sql_source": query_def.sql_source,
1421 "description": query_def.description,
1422 "args": args,
1423 }),
1424 );
1425 }
1426
1427 serde_json::json!({
1428 "types": types_json,
1429 "queries": queries_json,
1430 })
1431 }
1432}
1433
1434#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1447#[serde(default, deny_unknown_fields)]
1448pub struct SubscriptionsConfig {
1449 #[serde(default, skip_serializing_if = "Option::is_none")]
1452 pub max_subscriptions_per_connection: Option<u32>,
1453
1454 #[serde(default, skip_serializing_if = "Option::is_none")]
1456 pub hooks: Option<SubscriptionHooksConfig>,
1457}
1458
1459#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1461#[serde(default, deny_unknown_fields)]
1462pub struct SubscriptionHooksConfig {
1463 #[serde(default, skip_serializing_if = "Option::is_none")]
1465 pub on_connect: Option<String>,
1466
1467 #[serde(default, skip_serializing_if = "Option::is_none")]
1469 pub on_disconnect: Option<String>,
1470
1471 #[serde(default, skip_serializing_if = "Option::is_none")]
1473 pub on_subscribe: Option<String>,
1474
1475 #[serde(default = "default_hook_timeout_ms")]
1477 pub timeout_ms: u64,
1478}
1479
1480fn default_hook_timeout_ms() -> u64 {
1481 500
1482}
1483
1484#[derive(Debug, Clone, Default, Deserialize, Serialize)]
1492#[serde(default, deny_unknown_fields)]
1493pub struct ValidationConfig {
1494 #[serde(default, skip_serializing_if = "Option::is_none")]
1496 pub max_query_depth: Option<u32>,
1497
1498 #[serde(default, skip_serializing_if = "Option::is_none")]
1500 pub max_query_complexity: Option<u32>,
1501}
1502
1503#[derive(Debug, Clone, Deserialize, Serialize)]
1515#[serde(default, deny_unknown_fields)]
1516pub struct DebugConfig {
1517 pub enabled: bool,
1519
1520 pub database_explain: bool,
1523
1524 pub expose_sql: bool,
1528}
1529
1530impl Default for DebugConfig {
1531 fn default() -> Self {
1532 Self {
1533 enabled: false,
1534 database_explain: false,
1535 expose_sql: true,
1536 }
1537 }
1538}
1539
1540#[cfg(test)]
1541mod tests {
1542 use super::*;
1543
1544 #[test]
1545 fn test_parse_toml_schema() {
1546 let toml = r#"
1547[schema]
1548name = "myapp"
1549version = "1.0.0"
1550database_target = "postgresql"
1551
1552[types.User]
1553sql_source = "v_user"
1554
1555[types.User.fields.id]
1556type = "ID"
1557nullable = false
1558
1559[types.User.fields.name]
1560type = "String"
1561nullable = false
1562
1563[queries.users]
1564return_type = "User"
1565return_array = true
1566sql_source = "v_user"
1567"#;
1568 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1569 assert_eq!(schema.schema.name, "myapp");
1570 assert!(schema.types.contains_key("User"));
1571 }
1572
1573 #[test]
1574 fn test_validate_schema() {
1575 let schema = TomlSchema::default();
1576 assert!(schema.validate().is_ok());
1577 }
1578
1579 #[test]
1582 fn test_observers_config_nats_url_round_trip() {
1583 let toml = r#"
1584[schema]
1585name = "myapp"
1586version = "1.0.0"
1587database_target = "postgresql"
1588
1589[observers]
1590enabled = true
1591backend = "nats"
1592nats_url = "nats://localhost:4222"
1593"#;
1594 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1595 assert_eq!(schema.observers.backend, "nats");
1596 assert_eq!(
1597 schema.observers.nats_url.as_deref(),
1598 Some("nats://localhost:4222")
1599 );
1600 assert!(schema.observers.redis_url.is_none());
1601 }
1602
1603 #[test]
1604 fn test_observers_config_redis_url_unchanged() {
1605 let toml = r#"
1606[schema]
1607name = "myapp"
1608version = "1.0.0"
1609database_target = "postgresql"
1610
1611[observers]
1612enabled = true
1613backend = "redis"
1614redis_url = "redis://localhost:6379"
1615"#;
1616 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1617 assert_eq!(schema.observers.backend, "redis");
1618 assert_eq!(
1619 schema.observers.redis_url.as_deref(),
1620 Some("redis://localhost:6379")
1621 );
1622 assert!(schema.observers.nats_url.is_none());
1623 }
1624
1625 #[test]
1626 fn test_observers_config_nats_url_default_is_none() {
1627 let config = ObserversConfig::default();
1628 assert!(config.nats_url.is_none());
1629 }
1630
1631 #[test]
1634 fn test_federation_circuit_breaker_round_trip() {
1635 let toml = r#"
1636[schema]
1637name = "myapp"
1638version = "1.0.0"
1639database_target = "postgresql"
1640
1641[types.Product]
1642sql_source = "v_product"
1643
1644[federation]
1645enabled = true
1646apollo_version = 2
1647
1648[[federation.entities]]
1649name = "Product"
1650key_fields = ["id"]
1651
1652[federation.circuit_breaker]
1653enabled = true
1654failure_threshold = 3
1655recovery_timeout_secs = 60
1656success_threshold = 1
1657"#;
1658 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1659 let cb = schema.federation.circuit_breaker.as_ref().expect("Expected circuit_breaker");
1660 assert!(cb.enabled);
1661 assert_eq!(cb.failure_threshold, 3);
1662 assert_eq!(cb.recovery_timeout_secs, 60);
1663 assert_eq!(cb.success_threshold, 1);
1664 assert!(cb.per_database.is_empty());
1665 }
1666
1667 #[test]
1668 fn test_federation_circuit_breaker_zero_failure_threshold_rejected() {
1669 let toml = r#"
1670[schema]
1671name = "myapp"
1672version = "1.0.0"
1673database_target = "postgresql"
1674
1675[federation]
1676enabled = true
1677
1678[federation.circuit_breaker]
1679enabled = true
1680failure_threshold = 0
1681recovery_timeout_secs = 30
1682success_threshold = 2
1683"#;
1684 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1685 let err = schema.validate().unwrap_err();
1686 assert!(err.to_string().contains("failure_threshold"), "{err}");
1687 }
1688
1689 #[test]
1690 fn test_federation_circuit_breaker_zero_recovery_timeout_rejected() {
1691 let toml = r#"
1692[schema]
1693name = "myapp"
1694version = "1.0.0"
1695database_target = "postgresql"
1696
1697[federation]
1698enabled = true
1699
1700[federation.circuit_breaker]
1701enabled = true
1702failure_threshold = 5
1703recovery_timeout_secs = 0
1704success_threshold = 2
1705"#;
1706 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1707 let err = schema.validate().unwrap_err();
1708 assert!(err.to_string().contains("recovery_timeout_secs"), "{err}");
1709 }
1710
1711 #[test]
1712 fn test_federation_circuit_breaker_per_database_unknown_entity_rejected() {
1713 let toml = r#"
1714[schema]
1715name = "myapp"
1716version = "1.0.0"
1717database_target = "postgresql"
1718
1719[types.Product]
1720sql_source = "v_product"
1721
1722[federation]
1723enabled = true
1724
1725[[federation.entities]]
1726name = "Product"
1727key_fields = ["id"]
1728
1729[federation.circuit_breaker]
1730enabled = true
1731failure_threshold = 5
1732recovery_timeout_secs = 30
1733success_threshold = 2
1734
1735[[federation.circuit_breaker.per_database]]
1736database = "NonExistentEntity"
1737failure_threshold = 3
1738"#;
1739 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1740 let err = schema.validate().unwrap_err();
1741 assert!(err.to_string().contains("NonExistentEntity"), "{err}");
1742 }
1743
1744 #[test]
1745 fn test_federation_circuit_breaker_per_database_valid() {
1746 let toml = r#"
1747[schema]
1748name = "myapp"
1749version = "1.0.0"
1750database_target = "postgresql"
1751
1752[types.Product]
1753sql_source = "v_product"
1754
1755[federation]
1756enabled = true
1757
1758[[federation.entities]]
1759name = "Product"
1760key_fields = ["id"]
1761
1762[federation.circuit_breaker]
1763enabled = true
1764failure_threshold = 5
1765recovery_timeout_secs = 30
1766success_threshold = 2
1767
1768[[federation.circuit_breaker.per_database]]
1769database = "Product"
1770failure_threshold = 3
1771recovery_timeout_secs = 15
1772"#;
1773 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1774 assert!(schema.validate().is_ok());
1775 let cb = schema.federation.circuit_breaker.as_ref().unwrap();
1776 assert_eq!(cb.per_database.len(), 1);
1777 assert_eq!(cb.per_database[0].database, "Product");
1778 assert_eq!(cb.per_database[0].failure_threshold, Some(3));
1779 assert_eq!(cb.per_database[0].recovery_timeout_secs, Some(15));
1780 }
1781
1782 #[test]
1783 fn test_toml_schema_parses_server_section() {
1784 let toml = r#"
1785[schema]
1786name = "myapp"
1787version = "1.0.0"
1788database_target = "postgresql"
1789
1790[server]
1791host = "127.0.0.1"
1792port = 9999
1793
1794[server.cors]
1795origins = ["https://example.com"]
1796"#;
1797 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1798 assert_eq!(schema.server.host, "127.0.0.1");
1799 assert_eq!(schema.server.port, 9999);
1800 assert_eq!(schema.server.cors.origins, ["https://example.com"]);
1801 }
1802
1803 #[test]
1804 fn test_toml_schema_database_uses_runtime_config() {
1805 let toml = r#"
1806[schema]
1807name = "myapp"
1808version = "1.0.0"
1809database_target = "postgresql"
1810
1811[database]
1812url = "postgresql://localhost/mydb"
1813pool_min = 5
1814pool_max = 30
1815ssl_mode = "require"
1816"#;
1817 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1818 assert_eq!(schema.database.url, Some("postgresql://localhost/mydb".to_string()));
1819 assert_eq!(schema.database.pool_min, 5);
1820 assert_eq!(schema.database.pool_max, 30);
1821 assert_eq!(schema.database.ssl_mode, "require");
1822 }
1823
1824 #[test]
1825 fn test_env_var_expansion_in_toml_schema() {
1826 temp_env::with_var("SCHEMA_TEST_DB_URL", Some("postgres://test/fraiseql"), || {
1827 let toml = r#"
1828[schema]
1829name = "myapp"
1830version = "1.0.0"
1831database_target = "postgresql"
1832
1833[database]
1834url = "${SCHEMA_TEST_DB_URL}"
1835"#;
1836 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1837 assert_eq!(
1838 schema.database.url,
1839 Some("postgres://test/fraiseql".to_string())
1840 );
1841 });
1842 }
1843
1844 #[test]
1845 fn test_toml_schema_defaults_without_server_section() {
1846 let toml = r#"
1847[schema]
1848name = "myapp"
1849version = "1.0.0"
1850database_target = "postgresql"
1851"#;
1852 let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
1853 assert_eq!(schema.server.host, "0.0.0.0");
1855 assert_eq!(schema.server.port, 8080);
1856 assert_eq!(schema.database.pool_min, 2);
1857 assert_eq!(schema.database.pool_max, 20);
1858 assert!(schema.database.url.is_none());
1859 }
1860
1861 #[test]
1862 fn test_rate_limiting_config_parses_per_user_rps() {
1863 let toml = r"
1864[security.rate_limiting]
1865enabled = true
1866requests_per_second = 100
1867requests_per_second_per_user = 250
1868";
1869 let schema: TomlSchema = toml::from_str(toml).unwrap();
1870 let rl = schema.security.rate_limiting.unwrap();
1871 assert_eq!(rl.requests_per_second_per_user, Some(250));
1872 }
1873
1874 #[test]
1875 fn test_rate_limiting_config_per_user_rps_defaults_to_none() {
1876 let toml = r"
1877[security.rate_limiting]
1878enabled = true
1879requests_per_second = 50
1880";
1881 let schema: TomlSchema = toml::from_str(toml).unwrap();
1882 let rl = schema.security.rate_limiting.unwrap();
1883 assert_eq!(rl.requests_per_second_per_user, None);
1884 }
1885
1886 #[test]
1887 fn test_validation_config_parses_limits() {
1888 let toml = r"
1889[validation]
1890max_query_depth = 5
1891max_query_complexity = 50
1892";
1893 let schema: TomlSchema = toml::from_str(toml).unwrap();
1894 assert_eq!(schema.validation.max_query_depth, Some(5));
1895 assert_eq!(schema.validation.max_query_complexity, Some(50));
1896 }
1897
1898 #[test]
1899 fn test_validation_config_defaults_to_none() {
1900 let toml = "";
1901 let schema: TomlSchema = toml::from_str(toml).unwrap();
1902 assert_eq!(schema.validation.max_query_depth, None);
1903 assert_eq!(schema.validation.max_query_complexity, None);
1904 }
1905
1906 #[test]
1907 fn test_validation_config_partial() {
1908 let toml = r"
1909[validation]
1910max_query_depth = 3
1911";
1912 let schema: TomlSchema = toml::from_str(toml).unwrap();
1913 assert_eq!(schema.validation.max_query_depth, Some(3));
1914 assert_eq!(schema.validation.max_query_complexity, None);
1915 }
1916}