fraiseql_cli/config/toml_schema/
mod.rs1pub 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
22fn 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#[derive(Debug, Clone, Default, Deserialize, Serialize)]
60#[serde(default, deny_unknown_fields)]
61pub struct TomlSchema {
62 #[serde(rename = "schema")]
64 pub schema: SchemaMetadata,
65
66 #[serde(rename = "database")]
70 pub database: DatabaseRuntimeConfig,
71
72 #[serde(rename = "server")]
76 pub server: ServerRuntimeConfig,
77
78 #[serde(rename = "types")]
80 pub types: BTreeMap<String, TypeDefinition>,
81
82 #[serde(rename = "queries")]
84 pub queries: BTreeMap<String, QueryDefinition>,
85
86 #[serde(rename = "mutations")]
88 pub mutations: BTreeMap<String, MutationDefinition>,
89
90 #[serde(rename = "federation")]
92 pub federation: FederationConfig,
93
94 #[serde(rename = "security")]
96 pub security: SecuritySettings,
97
98 #[serde(rename = "observers")]
100 pub observers: ObserversConfig,
101
102 #[serde(rename = "caching")]
104 pub caching: CachingConfig,
105
106 #[serde(rename = "analytics")]
108 pub analytics: AnalyticsConfig,
109
110 #[serde(rename = "observability")]
112 pub observability: ObservabilityConfig,
113
114 #[serde(default)]
116 pub includes: SchemaIncludes,
117
118 #[serde(default)]
120 pub domain_discovery: DomainDiscovery,
121
122 #[serde(default)]
129 pub query_defaults: QueryDefaults,
130
131 #[serde(default)]
137 pub auth: Option<OidcClientConfig>,
138
139 #[serde(default)]
141 pub subscriptions: SubscriptionsConfig,
142
143 #[serde(default)]
145 pub validation: ValidationConfig,
146
147 #[serde(default)]
149 pub debug: DebugConfig,
150
151 #[serde(default)]
153 pub mcp: McpConfig,
154
155 #[serde(default)]
157 pub rest: RestTomlConfig,
158
159 #[serde(default)]
164 pub naming_convention: NamingConvention,
165
166 #[serde(default)]
179 pub crud: Option<CrudNamingConfig>,
180
181 #[serde(default)]
194 pub hierarchies: Option<std::collections::HashMap<String, HierarchyConfig>>,
195}
196
197#[derive(Debug, Clone, Deserialize, Serialize)]
202pub struct HierarchyConfig {
203 pub table: String,
205
206 pub path_column: String,
208}
209
210impl HierarchyConfig {
211 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 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 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 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 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 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 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 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 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 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 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 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 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;