Skip to main content

fraiseql_cli/config/
toml_schema.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
6use 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/// Domain-based schema organization
15///
16/// Automatically discovers schema files in domain directories:
17/// ```toml
18/// [schema.domain_discovery]
19/// enabled = true
20/// root_dir = "schema"
21/// ```
22///
23/// Expects structure:
24/// ```text
25/// schema/
26/// ├── auth/
27/// │   ├── types.json
28/// │   ├── queries.json
29/// │   └── mutations.json
30/// ├── products/
31/// │   ├── types.json
32/// │   ├── queries.json
33/// │   └── mutations.json
34/// ```
35#[derive(Debug, Clone, Default, Deserialize, Serialize)]
36#[serde(default, deny_unknown_fields)]
37pub struct DomainDiscovery {
38    /// Enable automatic domain discovery
39    pub enabled:  bool,
40    /// Root directory containing domains
41    pub root_dir: String,
42}
43
44/// Represents a discovered domain
45#[derive(Debug, Clone)]
46pub struct Domain {
47    /// Domain name (directory name)
48    pub name: String,
49    /// Path to domain root
50    pub path: PathBuf,
51}
52
53impl DomainDiscovery {
54    /// Discover all domains in root_dir
55    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        // Sort for deterministic ordering
85        domains.sort_by(|a, b| a.name.cmp(&b.name));
86
87        Ok(domains)
88    }
89}
90
91/// Schema includes for multi-file composition (glob patterns)
92///
93/// Supports glob patterns for flexible file inclusion:
94/// ```toml
95/// [schema.includes]
96/// types = ["schema/types/**/*.json"]
97/// queries = ["schema/queries/**/*.json"]
98/// mutations = ["schema/mutations/**/*.json"]
99/// ```
100#[derive(Debug, Clone, Default, Deserialize, Serialize)]
101#[serde(default, deny_unknown_fields)]
102pub struct SchemaIncludes {
103    /// Glob patterns for type files
104    pub types:     Vec<String>,
105    /// Glob patterns for query files
106    pub queries:   Vec<String>,
107    /// Glob patterns for mutation files
108    pub mutations: Vec<String>,
109}
110
111impl SchemaIncludes {
112    /// Check if any includes are specified
113    pub fn is_empty(&self) -> bool {
114        self.types.is_empty() && self.queries.is_empty() && self.mutations.is_empty()
115    }
116
117    /// Resolve glob patterns to actual file paths
118    ///
119    /// # Returns
120    /// ResolvedIncludes with expanded file paths, or error if resolution fails
121    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        // Resolve type globs
129        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        // Resolve query globs
143        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        // Resolve mutation globs
157        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        // Sort for deterministic ordering
171        type_paths.sort();
172        query_paths.sort();
173        mutation_paths.sort();
174
175        // Remove duplicates
176        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/// Resolved glob patterns to actual file paths
189#[derive(Debug, Clone)]
190pub struct ResolvedIncludes {
191    /// Resolved type file paths
192    pub types:     Vec<PathBuf>,
193    /// Resolved query file paths
194    pub queries:   Vec<PathBuf>,
195    /// Resolved mutation file paths
196    pub mutations: Vec<PathBuf>,
197}
198
199/// Complete TOML schema configuration
200#[derive(Debug, Clone, Default, Deserialize, Serialize)]
201#[serde(default, deny_unknown_fields)]
202pub struct TomlSchema {
203    /// Schema metadata
204    #[serde(rename = "schema")]
205    pub schema: SchemaMetadata,
206
207    /// Database connection pool configuration (optional — all fields have defaults).
208    ///
209    /// Supports `${VAR}` environment variable interpolation in the `url` field.
210    #[serde(rename = "database")]
211    pub database: DatabaseRuntimeConfig,
212
213    /// HTTP server runtime configuration (optional — all fields have defaults).
214    ///
215    /// CLI flags (`--port`, `--bind`) take precedence over these settings.
216    #[serde(rename = "server")]
217    pub server: ServerRuntimeConfig,
218
219    /// Type definitions
220    #[serde(rename = "types")]
221    pub types: BTreeMap<String, TypeDefinition>,
222
223    /// Query definitions
224    #[serde(rename = "queries")]
225    pub queries: BTreeMap<String, QueryDefinition>,
226
227    /// Mutation definitions
228    #[serde(rename = "mutations")]
229    pub mutations: BTreeMap<String, MutationDefinition>,
230
231    /// Federation configuration
232    #[serde(rename = "federation")]
233    pub federation: FederationConfig,
234
235    /// Security configuration
236    #[serde(rename = "security")]
237    pub security: SecuritySettings,
238
239    /// Observers/event system configuration
240    #[serde(rename = "observers")]
241    pub observers: ObserversConfig,
242
243    /// Result caching configuration
244    #[serde(rename = "caching")]
245    pub caching: CachingConfig,
246
247    /// Analytics configuration
248    #[serde(rename = "analytics")]
249    pub analytics: AnalyticsConfig,
250
251    /// Observability configuration
252    #[serde(rename = "observability")]
253    pub observability: ObservabilityConfig,
254
255    /// Schema includes configuration for multi-file composition
256    #[serde(default)]
257    pub includes: SchemaIncludes,
258
259    /// Domain discovery configuration for domain-based organization
260    #[serde(default)]
261    pub domain_discovery: DomainDiscovery,
262}
263
264/// Schema metadata
265#[derive(Debug, Clone, Deserialize, Serialize)]
266#[serde(default, deny_unknown_fields)]
267pub struct SchemaMetadata {
268    /// Schema name
269    pub name:            String,
270    /// Schema version
271    pub version:         String,
272    /// Optional schema description
273    pub description:     Option<String>,
274    /// Target database (postgresql, mysql, sqlite, sqlserver)
275    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/// Type definition in TOML
290#[derive(Debug, Clone, Deserialize, Serialize)]
291#[serde(default, deny_unknown_fields)]
292pub struct TypeDefinition {
293    /// SQL source table or view
294    pub sql_source:  String,
295    /// Human-readable type description
296    pub description: Option<String>,
297    /// Field definitions
298    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/// Field definition
312#[derive(Debug, Clone, Deserialize, Serialize)]
313#[serde(deny_unknown_fields)]
314pub struct FieldDefinition {
315    /// GraphQL field type (ID, String, Int, Boolean, DateTime, etc.)
316    #[serde(rename = "type")]
317    pub field_type:  String,
318    /// Whether field can be null
319    #[serde(default)]
320    pub nullable:    bool,
321    /// Field description
322    pub description: Option<String>,
323}
324
325/// Query definition in TOML
326#[derive(Debug, Clone, Deserialize, Serialize)]
327#[serde(default, deny_unknown_fields)]
328pub struct QueryDefinition {
329    /// Return type name
330    pub return_type:  String,
331    /// Whether query returns an array
332    #[serde(default)]
333    pub return_array: bool,
334    /// SQL source for the query
335    pub sql_source:   String,
336    /// Query description
337    pub description:  Option<String>,
338    /// Query arguments
339    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/// Mutation definition in TOML
355#[derive(Debug, Clone, Deserialize, Serialize)]
356#[serde(default, deny_unknown_fields)]
357pub struct MutationDefinition {
358    /// Return type name
359    pub return_type: String,
360    /// SQL function or procedure source
361    pub sql_source:  String,
362    /// Operation type (CREATE, UPDATE, DELETE)
363    pub operation:   String,
364    /// Mutation description
365    pub description: Option<String>,
366    /// Mutation arguments
367    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/// Argument definition
383#[derive(Debug, Clone, Deserialize, Serialize)]
384#[serde(deny_unknown_fields)]
385pub struct ArgumentDefinition {
386    /// Argument name
387    pub name:        String,
388    /// Argument type
389    #[serde(rename = "type")]
390    pub arg_type:    String,
391    /// Whether argument is required
392    #[serde(default)]
393    pub required:    bool,
394    /// Default value if not provided
395    pub default:     Option<serde_json::Value>,
396    /// Argument description
397    pub description: Option<String>,
398}
399
400/// Circuit breaker configuration for a specific federated database/service
401#[derive(Debug, Clone, Deserialize, Serialize)]
402#[serde(deny_unknown_fields)]
403pub struct PerDatabaseCircuitBreakerOverride {
404    /// Database or service name matching a federation entity
405    pub database:             String,
406    /// Override: number of consecutive failures before opening (must be > 0)
407    pub failure_threshold:    Option<u32>,
408    /// Override: seconds to wait before attempting recovery (must be > 0)
409    pub recovery_timeout_secs: Option<u64>,
410    /// Override: successes required in half-open state to close the breaker (must be > 0)
411    pub success_threshold:    Option<u32>,
412}
413
414/// Circuit breaker configuration for Apollo Federation fan-out requests
415#[derive(Debug, Clone, Deserialize, Serialize)]
416#[serde(default, deny_unknown_fields)]
417pub struct FederationCircuitBreakerConfig {
418    /// Enable circuit breaker protection on federation fan-out
419    pub enabled:              bool,
420    /// Consecutive failures before the breaker opens (default: 5, must be > 0)
421    pub failure_threshold:    u32,
422    /// Seconds to wait before attempting a probe request (default: 30, must be > 0)
423    pub recovery_timeout_secs: u64,
424    /// Probe successes needed to transition from half-open to closed (default: 2, must be > 0)
425    pub success_threshold:    u32,
426    /// Per-database overrides (database name must match a defined federation entity)
427    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/// Federation configuration
443#[derive(Debug, Clone, Deserialize, Serialize)]
444#[serde(default, deny_unknown_fields)]
445pub struct FederationConfig {
446    /// Enable Apollo federation
447    #[serde(default)]
448    pub enabled:         bool,
449    /// Apollo federation version
450    pub apollo_version:  Option<u32>,
451    /// Federated entities
452    pub entities:        Vec<FederationEntity>,
453    /// Circuit breaker configuration for federation fan-out requests
454    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/// Federation entity
469#[derive(Debug, Clone, Deserialize, Serialize)]
470#[serde(deny_unknown_fields)]
471pub struct FederationEntity {
472    /// Entity name
473    pub name:       String,
474    /// Key fields for entity resolution
475    pub key_fields: Vec<String>,
476}
477
478/// Security configuration
479#[derive(Debug, Clone, Deserialize, Serialize)]
480#[serde(default, deny_unknown_fields)]
481pub struct SecuritySettings {
482    /// Default policy to apply if none specified
483    pub default_policy: Option<String>,
484    /// Custom authorization rules
485    pub rules:          Vec<AuthorizationRule>,
486    /// Authorization policies
487    pub policies:       Vec<AuthorizationPolicy>,
488    /// Field-level authorization rules
489    pub field_auth:     Vec<FieldAuthRule>,
490    /// Enterprise security configuration
491    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/// Authorization rule (custom expressions)
507#[derive(Debug, Clone, Deserialize, Serialize)]
508#[serde(deny_unknown_fields)]
509pub struct AuthorizationRule {
510    /// Rule name
511    pub name:              String,
512    /// Rule expression or condition
513    pub rule:              String,
514    /// Rule description
515    pub description:       Option<String>,
516    /// Whether rule result can be cached
517    #[serde(default)]
518    pub cacheable:         bool,
519    /// Cache time-to-live in seconds
520    pub cache_ttl_seconds: Option<u32>,
521}
522
523/// Authorization policy (RBAC/ABAC)
524#[derive(Debug, Clone, Deserialize, Serialize)]
525#[serde(deny_unknown_fields)]
526pub struct AuthorizationPolicy {
527    /// Policy name
528    pub name:              String,
529    /// Policy type (RBAC, ABAC, CUSTOM, HYBRID)
530    #[serde(rename = "type")]
531    pub policy_type:       String,
532    /// Optional rule expression
533    pub rule:              Option<String>,
534    /// Roles this policy applies to
535    pub roles:             Vec<String>,
536    /// Combination strategy (ANY, ALL, EXACTLY)
537    pub strategy:          Option<String>,
538    /// Attributes for attribute-based access control
539    #[serde(default)]
540    pub attributes:        Vec<String>,
541    /// Policy description
542    pub description:       Option<String>,
543    /// Cache time-to-live in seconds
544    pub cache_ttl_seconds: Option<u32>,
545}
546
547/// Field-level authorization rule
548#[derive(Debug, Clone, Deserialize, Serialize)]
549#[serde(deny_unknown_fields)]
550pub struct FieldAuthRule {
551    /// Type name this rule applies to
552    pub type_name:  String,
553    /// Field name this rule applies to
554    pub field_name: String,
555    /// Policy to enforce
556    pub policy:     String,
557}
558
559/// Enterprise security configuration
560#[derive(Debug, Clone, Deserialize, Serialize)]
561#[serde(default, deny_unknown_fields)]
562pub struct EnterpriseSecurityConfig {
563    /// Enable rate limiting
564    pub rate_limiting_enabled:        bool,
565    /// Max requests per auth endpoint
566    pub auth_endpoint_max_requests:   u32,
567    /// Rate limit window in seconds
568    pub auth_endpoint_window_seconds: u64,
569    /// Enable audit logging
570    pub audit_logging_enabled:        bool,
571    /// Audit log backend service
572    pub audit_log_backend:            String,
573    /// Audit log retention in days
574    pub audit_retention_days:         u32,
575    /// Enable error sanitization
576    pub error_sanitization:           bool,
577    /// Hide implementation details in errors
578    pub hide_implementation_details:  bool,
579    /// Enable constant-time token comparison
580    pub constant_time_comparison:     bool,
581    /// Enable PKCE for OAuth flows
582    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/// Observers/event system configuration
603#[derive(Debug, Clone, Deserialize, Serialize)]
604#[serde(default, deny_unknown_fields)]
605pub struct ObserversConfig {
606    /// Enable observers system
607    #[serde(default)]
608    pub enabled:   bool,
609    /// Backend service (redis, nats, postgresql, mysql, in-memory)
610    pub backend:   String,
611    /// Redis connection URL (required when backend = "redis")
612    pub redis_url: Option<String>,
613    /// NATS connection URL (required when backend = "nats")
614    ///
615    /// Example: `nats://localhost:4222`
616    /// Can be overridden at runtime via the `FRAISEQL_NATS_URL` environment variable.
617    pub nats_url:  Option<String>,
618    /// Event handlers
619    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/// Event handler configuration
635#[derive(Debug, Clone, Deserialize, Serialize)]
636#[serde(deny_unknown_fields)]
637pub struct EventHandler {
638    /// Handler name
639    pub name:           String,
640    /// Event type to handle
641    pub event:          String,
642    /// Action to perform (slack, email, sms, webhook, push, etc.)
643    pub action:         String,
644    /// Webhook URL for webhook actions
645    pub webhook_url:    Option<String>,
646    /// Retry strategy
647    pub retry_strategy: Option<String>,
648    /// Maximum retry attempts
649    pub max_retries:    Option<u32>,
650    /// Handler description
651    pub description:    Option<String>,
652}
653
654/// Caching configuration
655#[derive(Debug, Clone, Deserialize, Serialize)]
656#[serde(default, deny_unknown_fields)]
657pub struct CachingConfig {
658    /// Enable caching
659    #[serde(default)]
660    pub enabled:   bool,
661    /// Cache backend (redis, memory, postgresql)
662    pub backend:   String,
663    /// Redis connection URL
664    pub redis_url: Option<String>,
665    /// Cache invalidation rules
666    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/// Cache invalidation rule
681#[derive(Debug, Clone, Deserialize, Serialize)]
682#[serde(deny_unknown_fields)]
683pub struct CacheRule {
684    /// Query pattern to cache
685    pub query:                 String,
686    /// Time-to-live in seconds
687    pub ttl_seconds:           u32,
688    /// Events that trigger cache invalidation
689    pub invalidation_triggers: Vec<String>,
690}
691
692/// Analytics configuration
693#[derive(Debug, Clone, Default, Deserialize, Serialize)]
694#[serde(default, deny_unknown_fields)]
695pub struct AnalyticsConfig {
696    /// Enable analytics
697    #[serde(default)]
698    pub enabled: bool,
699    /// Analytics queries
700    pub queries: Vec<AnalyticsQuery>,
701}
702
703/// Analytics query definition
704#[derive(Debug, Clone, Deserialize, Serialize)]
705#[serde(deny_unknown_fields)]
706pub struct AnalyticsQuery {
707    /// Query name
708    pub name:        String,
709    /// SQL source for the query
710    pub sql_source:  String,
711    /// Query description
712    pub description: Option<String>,
713}
714
715/// Observability configuration
716#[derive(Debug, Clone, Deserialize, Serialize)]
717#[serde(default, deny_unknown_fields)]
718pub struct ObservabilityConfig {
719    /// Enable Prometheus metrics
720    pub prometheus_enabled:            bool,
721    /// Port for Prometheus metrics endpoint
722    pub prometheus_port:               u16,
723    /// Enable OpenTelemetry tracing
724    pub otel_enabled:                  bool,
725    /// OpenTelemetry exporter type
726    pub otel_exporter:                 String,
727    /// Jaeger endpoint for trace collection
728    pub otel_jaeger_endpoint:          Option<String>,
729    /// Enable health check endpoint
730    pub health_check_enabled:          bool,
731    /// Health check interval in seconds
732    pub health_check_interval_seconds: u32,
733    /// Log level threshold
734    pub log_level:                     String,
735    /// Log output format (json, text)
736    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    /// Load schema from TOML file
757    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    /// Parse schema from TOML string.
764    ///
765    /// Expands `${VAR}` environment variable placeholders before parsing.
766    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    /// Validate schema
772    pub fn validate(&self) -> Result<()> {
773        // Validate that all query return types exist
774        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        // Validate that all mutation return types exist
784        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        // Validate field auth rules reference existing policies
794        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        // Validate federation entities reference existing types
802        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        // Validate federation circuit breaker configuration
812        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            // Validate per-database overrides reference defined entity names
830            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    /// Convert to intermediate schema format (compatible with language-generated types.json)
868    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    // --- Issue #38: nats_url ---
973
974    #[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    // --- Issue #39: federation circuit breaker ---
1025
1026    #[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        // Defaults should apply
1247        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}