Skip to main content

fraiseql_cli/config/toml_schema/
mod.rs

1//! Complete TOML schema configuration supporting types, queries, mutations, federation, observers,
2//! caching
3//!
4//! This module extends FraiseQLConfig to support the full TOML-based schema definition.
5
6pub 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
21/// Format "Did you mean?" suggestions from `suggest_similar` results.
22fn 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/// Complete TOML schema configuration
57#[derive(Debug, Clone, Default, Deserialize, Serialize)]
58#[serde(default, deny_unknown_fields)]
59pub struct TomlSchema {
60    /// Schema metadata
61    #[serde(rename = "schema")]
62    pub schema: SchemaMetadata,
63
64    /// Database connection pool configuration (optional — all fields have defaults).
65    ///
66    /// Supports `${VAR}` environment variable interpolation in the `url` field.
67    #[serde(rename = "database")]
68    pub database: DatabaseRuntimeConfig,
69
70    /// HTTP server runtime configuration (optional — all fields have defaults).
71    ///
72    /// CLI flags (`--port`, `--bind`) take precedence over these settings.
73    #[serde(rename = "server")]
74    pub server: ServerRuntimeConfig,
75
76    /// Type definitions
77    #[serde(rename = "types")]
78    pub types: BTreeMap<String, TypeDefinition>,
79
80    /// Query definitions
81    #[serde(rename = "queries")]
82    pub queries: BTreeMap<String, QueryDefinition>,
83
84    /// Mutation definitions
85    #[serde(rename = "mutations")]
86    pub mutations: BTreeMap<String, MutationDefinition>,
87
88    /// Federation configuration
89    #[serde(rename = "federation")]
90    pub federation: FederationConfig,
91
92    /// Security configuration
93    #[serde(rename = "security")]
94    pub security: SecuritySettings,
95
96    /// Observers/event system configuration
97    #[serde(rename = "observers")]
98    pub observers: ObserversConfig,
99
100    /// Result caching configuration
101    #[serde(rename = "caching")]
102    pub caching: CachingConfig,
103
104    /// Analytics configuration
105    #[serde(rename = "analytics")]
106    pub analytics: AnalyticsConfig,
107
108    /// Observability configuration
109    #[serde(rename = "observability")]
110    pub observability: ObservabilityConfig,
111
112    /// Schema includes configuration for multi-file composition
113    #[serde(default)]
114    pub includes: SchemaIncludes,
115
116    /// Domain discovery configuration for domain-based organization
117    #[serde(default)]
118    pub domain_discovery: DomainDiscovery,
119
120    /// Global defaults for list-query auto-params.
121    ///
122    /// Provides project-wide defaults for `where`, `order_by`, `limit`, and `offset`
123    /// parameters on list queries. Per-query `auto_params` overrides are partial —
124    /// only the specified flags override the defaults. Relay queries and single-item
125    /// queries are never affected.
126    #[serde(default)]
127    pub query_defaults: QueryDefaults,
128
129    /// OAuth2 client identity for server-side PKCE flows.
130    ///
131    /// Required when `[security.pkce] enabled = true`.
132    /// Holds the OIDC provider discovery URL, client_id, and a reference to
133    /// the env var containing the client secret. Never stores the secret itself.
134    #[serde(default)]
135    pub auth: Option<OidcClientConfig>,
136
137    /// WebSocket subscription configuration (hooks, limits).
138    #[serde(default)]
139    pub subscriptions: SubscriptionsConfig,
140
141    /// Query validation limits (depth, complexity).
142    #[serde(default)]
143    pub validation: ValidationConfig,
144
145    /// Debug/development settings (database EXPLAIN, SQL exposure).
146    #[serde(default)]
147    pub debug: DebugConfig,
148
149    /// MCP (Model Context Protocol) server configuration.
150    #[serde(default)]
151    pub mcp: McpConfig,
152
153    /// Naming convention for GraphQL operation names.
154    ///
155    /// `"preserve"` (default) keeps names as authored (snake_case from Python SDKs).
156    /// `"camelCase"` converts operation names to standard GraphQL camelCase.
157    #[serde(default)]
158    pub naming_convention: NamingConvention,
159
160    /// CRUD function naming config for automatic `sql_source` resolution.
161    ///
162    /// When set, mutations that omit `sql_source` have their PostgreSQL function
163    /// name resolved at compile time using the configured template and the entity
164    /// name derived from `return_type`.
165    ///
166    /// Example:
167    /// ```toml
168    /// [crud]
169    /// function_schema = "app"
170    /// function_naming = "trinity"
171    /// ```
172    #[serde(default)]
173    pub crud: Option<CrudNamingConfig>,
174}
175
176impl TomlSchema {
177    /// Load schema from TOML file
178    ///
179    /// # Errors
180    ///
181    /// Returns an error if the file cannot be read or cannot be parsed as a
182    /// valid `TomlSchema`.
183    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    /// Parse schema from TOML string.
190    ///
191    /// Expands `${VAR}` environment variable placeholders before parsing.
192    ///
193    /// # Errors
194    ///
195    /// Returns an error if the TOML string cannot be deserialized into a
196    /// `TomlSchema`.
197    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    /// Validate schema
203    ///
204    /// # Errors
205    ///
206    /// Returns an error if any query or mutation references an undefined type,
207    /// if a field auth rule references an undefined policy, if a federation
208    /// entity references an undefined type, or if server/database/circuit-breaker
209    /// configuration values are invalid.
210    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        // Validate that all query return types exist
216        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        // Validate that all mutation return types exist
227        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        // Validate field auth rules reference existing policies
238        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        // Validate federation entities reference existing types
252        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        // Validate federation circuit breaker configuration
266        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            // Validate per-database overrides reference defined entity names
284            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    /// Convert to intermediate schema format (compatible with language-generated types.json)
322    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)] // Reason: test code, panics are acceptable
388#[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    // --- Issue #38: nats_url ---
428
429    #[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    // --- Issue #39: federation circuit breaker ---
474
475    #[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        // Defaults should apply
693        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}