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 rest;
13pub mod security;
14pub mod server_settings;
15pub mod subscriptions;
16pub mod types;
17
18use std::collections::BTreeMap;
19
20use anyhow::{Context, Result};
21
22/// Format "Did you mean?" suggestions from `suggest_similar` results.
23fn format_suggestions(suggestions: Vec<&str>) -> String {
24    if suggestions.is_empty() {
25        String::new()
26    } else {
27        format!(". Did you mean: {}?", suggestions.join(", "))
28    }
29}
30pub use caching::{AnalyticsConfig, AnalyticsQuery, CacheRule, CachingConfig};
31pub use domain::{Domain, DomainDiscovery, ResolvedIncludes, SchemaIncludes};
32pub use federation::{
33    FederationCircuitBreakerConfig, FederationConfig, FederationEntity,
34    PerDatabaseCircuitBreakerOverride,
35};
36use fraiseql_core::schema::{CrudNamingConfig, NamingConvention};
37pub use observability::ObservabilityConfig;
38pub use observers::{EventHandler, ObserversConfig};
39pub use operations::{MutationDefinition, QueryDefaults, QueryDefinition, SchemaMetadata};
40use rest::RestTomlConfig;
41pub use security::{
42    ApiKeySecurityConfig, AuthorizationPolicy, AuthorizationRule, CodeChallengeMethod,
43    EncryptionAlgorithm, EnterpriseSecurityConfig, ErrorSanitizationTomlConfig, FieldAuthRule,
44    KeySource, OidcClientConfig, PkceConfig, RateLimitingSecurityConfig, SecuritySettings,
45    StateEncryptionConfig, StaticApiKeyEntry, TokenRevocationSecurityConfig, TrustedDocumentMode,
46    TrustedDocumentsConfig,
47};
48use serde::{Deserialize, Serialize};
49pub use server_settings::{DebugConfig, McpConfig, ValidationConfig};
50pub use subscriptions::{SubscriptionHooksConfig, SubscriptionsConfig};
51pub use types::{ArgumentDefinition, FieldDefinition, TypeDefinition};
52
53use super::{
54    expand_env_vars,
55    runtime::{DatabaseRuntimeConfig, ServerRuntimeConfig},
56};
57
58/// Complete TOML schema configuration
59#[derive(Debug, Clone, Default, Deserialize, Serialize)]
60#[serde(default, deny_unknown_fields)]
61pub struct TomlSchema {
62    /// Schema metadata
63    #[serde(rename = "schema")]
64    pub schema: SchemaMetadata,
65
66    /// Database connection pool configuration (optional — all fields have defaults).
67    ///
68    /// Supports `${VAR}` environment variable interpolation in the `url` field.
69    #[serde(rename = "database")]
70    pub database: DatabaseRuntimeConfig,
71
72    /// HTTP server runtime configuration (optional — all fields have defaults).
73    ///
74    /// CLI flags (`--port`, `--bind`) take precedence over these settings.
75    #[serde(rename = "server")]
76    pub server: ServerRuntimeConfig,
77
78    /// Type definitions
79    #[serde(rename = "types")]
80    pub types: BTreeMap<String, TypeDefinition>,
81
82    /// Query definitions
83    #[serde(rename = "queries")]
84    pub queries: BTreeMap<String, QueryDefinition>,
85
86    /// Mutation definitions
87    #[serde(rename = "mutations")]
88    pub mutations: BTreeMap<String, MutationDefinition>,
89
90    /// Federation configuration
91    #[serde(rename = "federation")]
92    pub federation: FederationConfig,
93
94    /// Security configuration
95    #[serde(rename = "security")]
96    pub security: SecuritySettings,
97
98    /// Observers/event system configuration
99    #[serde(rename = "observers")]
100    pub observers: ObserversConfig,
101
102    /// Result caching configuration
103    #[serde(rename = "caching")]
104    pub caching: CachingConfig,
105
106    /// Analytics configuration
107    #[serde(rename = "analytics")]
108    pub analytics: AnalyticsConfig,
109
110    /// Observability configuration
111    #[serde(rename = "observability")]
112    pub observability: ObservabilityConfig,
113
114    /// Schema includes configuration for multi-file composition
115    #[serde(default)]
116    pub includes: SchemaIncludes,
117
118    /// Domain discovery configuration for domain-based organization
119    #[serde(default)]
120    pub domain_discovery: DomainDiscovery,
121
122    /// Global defaults for list-query auto-params.
123    ///
124    /// Provides project-wide defaults for `where`, `order_by`, `limit`, and `offset`
125    /// parameters on list queries. Per-query `auto_params` overrides are partial —
126    /// only the specified flags override the defaults. Relay queries and single-item
127    /// queries are never affected.
128    #[serde(default)]
129    pub query_defaults: QueryDefaults,
130
131    /// OAuth2 client identity for server-side PKCE flows.
132    ///
133    /// Required when `[security.pkce] enabled = true`.
134    /// Holds the OIDC provider discovery URL, client_id, and a reference to
135    /// the env var containing the client secret. Never stores the secret itself.
136    #[serde(default)]
137    pub auth: Option<OidcClientConfig>,
138
139    /// WebSocket subscription configuration (hooks, limits).
140    #[serde(default)]
141    pub subscriptions: SubscriptionsConfig,
142
143    /// Query validation limits (depth, complexity).
144    #[serde(default)]
145    pub validation: ValidationConfig,
146
147    /// Debug/development settings (database EXPLAIN, SQL exposure).
148    #[serde(default)]
149    pub debug: DebugConfig,
150
151    /// MCP (Model Context Protocol) server configuration.
152    #[serde(default)]
153    pub mcp: McpConfig,
154
155    /// REST transport configuration.
156    #[serde(default)]
157    pub rest: RestTomlConfig,
158
159    /// Naming convention for GraphQL operation names.
160    ///
161    /// `"preserve"` (default) keeps names as authored (snake_case from Python SDKs).
162    /// `"camelCase"` converts operation names to standard GraphQL camelCase.
163    #[serde(default)]
164    pub naming_convention: NamingConvention,
165
166    /// CRUD function naming config for automatic `sql_source` resolution.
167    ///
168    /// When set, mutations that omit `sql_source` have their PostgreSQL function
169    /// name resolved at compile time using the configured template and the entity
170    /// name derived from `return_type`.
171    ///
172    /// Example:
173    /// ```toml
174    /// [crud]
175    /// function_schema = "app"
176    /// function_naming = "trinity"
177    /// ```
178    #[serde(default)]
179    pub crud: Option<CrudNamingConfig>,
180
181    /// Hierarchy definitions for ID-based ltree operators (`descendantOfId`, `ancestorOfId`).
182    ///
183    /// Maps a hierarchy name to its table and ltree path column. Used by the compiler
184    /// to generate subquery-based ltree WHERE clauses that resolve an entity's ltree
185    /// path from its UUID.
186    ///
187    /// Example:
188    /// ```toml
189    /// [hierarchies.category]
190    /// table = "tb_category"
191    /// path_column = "category_path"
192    /// ```
193    #[serde(default)]
194    pub hierarchies: Option<std::collections::HashMap<String, HierarchyConfig>>,
195}
196
197/// Configuration for a single hierarchy used by ID-based ltree operators.
198///
199/// Defines the database table and ltree path column for a named hierarchy.
200/// The `id` column is always `id` (UUID) per the trinity pattern — not configurable.
201#[derive(Debug, Clone, Deserialize, Serialize)]
202pub struct HierarchyConfig {
203    /// Database table containing the ltree column (e.g., `"tb_category"`).
204    pub table: String,
205
206    /// Name of the ltree column in the table (e.g., `"category_path"`).
207    pub path_column: String,
208}
209
210impl HierarchyConfig {
211    /// Validate that required fields are non-empty.
212    ///
213    /// # Errors
214    ///
215    /// Returns an error if `table` or `path_column` is empty.
216    pub fn validate(&self) -> Result<()> {
217        if self.table.is_empty() {
218            anyhow::bail!("hierarchy table must not be empty");
219        }
220        if self.path_column.is_empty() {
221            anyhow::bail!("hierarchy path_column must not be empty");
222        }
223        Ok(())
224    }
225}
226
227impl TomlSchema {
228    /// Load schema from TOML file
229    ///
230    /// # Errors
231    ///
232    /// Returns an error if the file cannot be read or cannot be parsed as a
233    /// valid `TomlSchema`.
234    pub fn from_file(path: &str) -> Result<Self> {
235        let content =
236            std::fs::read_to_string(path).context(format!("Failed to read TOML file: {path}"))?;
237        Self::parse_toml(&content)
238    }
239
240    /// Parse schema from TOML string.
241    ///
242    /// Expands `${VAR}` environment variable placeholders before parsing.
243    ///
244    /// # Errors
245    ///
246    /// Returns an error if the TOML string cannot be deserialized into a
247    /// `TomlSchema`.
248    pub fn parse_toml(content: &str) -> Result<Self> {
249        let expanded = expand_env_vars(content)?;
250        toml::from_str(&expanded).context("Failed to parse TOML schema")
251    }
252
253    /// Validate schema
254    ///
255    /// # Errors
256    ///
257    /// Returns an error if any query or mutation references an undefined type,
258    /// if a field auth rule references an undefined policy, if a federation
259    /// entity references an undefined type, or if server/database/circuit-breaker
260    /// configuration values are invalid.
261    pub fn validate(&self) -> Result<()> {
262        use fraiseql_core::runtime::suggest_similar;
263
264        let type_names: Vec<&str> = self.types.keys().map(String::as_str).collect();
265
266        // Validate that all query return types exist
267        for (query_name, query_def) in &self.queries {
268            if !self.types.contains_key(&query_def.return_type) {
269                let hint = format_suggestions(suggest_similar(&query_def.return_type, &type_names));
270                anyhow::bail!(
271                    "Query '{query_name}' references undefined type '{}'{hint}",
272                    query_def.return_type
273                );
274            }
275        }
276
277        // Validate that all mutation return types exist
278        for (mut_name, mut_def) in &self.mutations {
279            if !self.types.contains_key(&mut_def.return_type) {
280                let hint = format_suggestions(suggest_similar(&mut_def.return_type, &type_names));
281                anyhow::bail!(
282                    "Mutation '{mut_name}' references undefined type '{}'{hint}",
283                    mut_def.return_type
284                );
285            }
286        }
287
288        // Validate field auth rules reference existing policies
289        for field_auth in &self.security.field_auth {
290            let policy_exists = self.security.policies.iter().any(|p| p.name == field_auth.policy);
291            if !policy_exists {
292                let policy_names: Vec<&str> =
293                    self.security.policies.iter().map(|p| p.name.as_str()).collect();
294                let hint = format_suggestions(suggest_similar(&field_auth.policy, &policy_names));
295                anyhow::bail!(
296                    "Field auth references undefined policy '{}'{hint}",
297                    field_auth.policy
298                );
299            }
300        }
301
302        // Validate field hierarchy references exist in hierarchies config
303        let hierarchy_names: std::collections::HashSet<&str> = self
304            .hierarchies
305            .as_ref()
306            .map(|h| h.keys().map(String::as_str).collect())
307            .unwrap_or_default();
308        for (type_name, type_def) in &self.types {
309            for (field_name, field_def) in &type_def.fields {
310                if let Some(ref h_name) = field_def.hierarchy {
311                    if !hierarchy_names.contains(h_name.as_str()) {
312                        let hint = format_suggestions(suggest_similar(
313                            h_name,
314                            &hierarchy_names.iter().copied().collect::<Vec<_>>(),
315                        ));
316                        anyhow::bail!(
317                            "Field '{type_name}.{field_name}' references undefined hierarchy \
318                             '{h_name}'{hint}"
319                        );
320                    }
321                }
322            }
323        }
324
325        // Validate hierarchy configs have non-empty values
326        if let Some(ref hierarchies) = self.hierarchies {
327            for (name, config) in hierarchies {
328                config
329                    .validate()
330                    .map_err(|e| anyhow::anyhow!("Invalid hierarchy config '{name}': {e}"))?;
331            }
332        }
333
334        // Validate federation entities reference existing types
335        for entity in &self.federation.entities {
336            if !self.types.contains_key(&entity.name) {
337                let hint = format_suggestions(suggest_similar(&entity.name, &type_names));
338                anyhow::bail!(
339                    "Federation entity '{}' references undefined type{hint}",
340                    entity.name
341                );
342            }
343        }
344
345        self.server.validate()?;
346        self.database.validate()?;
347
348        // Validate federation circuit breaker configuration
349        if let Some(cb) = &self.federation.circuit_breaker {
350            if cb.failure_threshold == 0 {
351                anyhow::bail!(
352                    "federation.circuit_breaker.failure_threshold must be greater than 0"
353                );
354            }
355            if cb.recovery_timeout_secs == 0 {
356                anyhow::bail!(
357                    "federation.circuit_breaker.recovery_timeout_secs must be greater than 0"
358                );
359            }
360            if cb.success_threshold == 0 {
361                anyhow::bail!(
362                    "federation.circuit_breaker.success_threshold must be greater than 0"
363                );
364            }
365
366            // Validate per-database overrides reference defined entity names
367            let entity_names: std::collections::HashSet<&str> =
368                self.federation.entities.iter().map(|e| e.name.as_str()).collect();
369            for override_cfg in &cb.per_database {
370                if !entity_names.contains(override_cfg.database.as_str()) {
371                    anyhow::bail!(
372                        "federation.circuit_breaker.per_database entry '{}' does not match \
373                         any defined federation entity",
374                        override_cfg.database
375                    );
376                }
377                if override_cfg.failure_threshold == Some(0) {
378                    anyhow::bail!(
379                        "federation.circuit_breaker.per_database['{}'].failure_threshold \
380                         must be greater than 0",
381                        override_cfg.database
382                    );
383                }
384                if override_cfg.recovery_timeout_secs == Some(0) {
385                    anyhow::bail!(
386                        "federation.circuit_breaker.per_database['{}'].recovery_timeout_secs \
387                         must be greater than 0",
388                        override_cfg.database
389                    );
390                }
391                if override_cfg.success_threshold == Some(0) {
392                    anyhow::bail!(
393                        "federation.circuit_breaker.per_database['{}'].success_threshold \
394                         must be greater than 0",
395                        override_cfg.database
396                    );
397                }
398            }
399        }
400
401        Ok(())
402    }
403
404    /// Convert to intermediate schema format (compatible with language-generated types.json)
405    pub fn to_intermediate_schema(&self) -> serde_json::Value {
406        let mut types_json = serde_json::Map::new();
407
408        for (type_name, type_def) in &self.types {
409            let mut fields_json = serde_json::Map::new();
410
411            for (field_name, field_def) in &type_def.fields {
412                fields_json.insert(
413                    field_name.clone(),
414                    serde_json::json!({
415                        "type": field_def.field_type,
416                        "nullable": field_def.nullable,
417                        "description": field_def.description,
418                    }),
419                );
420            }
421
422            types_json.insert(
423                type_name.clone(),
424                serde_json::json!({
425                    "name": type_name,
426                    "sql_source": type_def.sql_source,
427                    "description": type_def.description,
428                    "fields": fields_json,
429                }),
430            );
431        }
432
433        let mut queries_json = serde_json::Map::new();
434
435        for (query_name, query_def) in &self.queries {
436            let args: Vec<serde_json::Value> = query_def
437                .args
438                .iter()
439                .map(|arg| {
440                    serde_json::json!({
441                        "name": arg.name,
442                        "type": arg.arg_type,
443                        "required": arg.required,
444                        "default": arg.default,
445                        "description": arg.description,
446                    })
447                })
448                .collect();
449
450            queries_json.insert(
451                query_name.clone(),
452                serde_json::json!({
453                    "name": query_name,
454                    "return_type": query_def.return_type,
455                    "return_array": query_def.return_array,
456                    "sql_source": query_def.sql_source,
457                    "description": query_def.description,
458                    "args": args,
459                }),
460            );
461        }
462
463        serde_json::json!({
464            "types": types_json,
465            "queries": queries_json,
466        })
467    }
468}
469
470#[cfg(test)]
471mod tests;