use std::{collections::BTreeMap, path::PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use super::runtime::{DatabaseRuntimeConfig, ServerRuntimeConfig};
use super::expand_env_vars;
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct DomainDiscovery {
pub enabled: bool,
pub root_dir: String,
}
#[derive(Debug, Clone)]
pub struct Domain {
pub name: String,
pub path: PathBuf,
}
impl DomainDiscovery {
pub fn resolve_domains(&self) -> Result<Vec<Domain>> {
if !self.enabled {
return Ok(Vec::new());
}
let root = PathBuf::from(&self.root_dir);
if !root.is_dir() {
anyhow::bail!("Domain discovery root not found: {}", self.root_dir);
}
let mut domains = Vec::new();
for entry in std::fs::read_dir(&root)
.context(format!("Failed to read domain root: {}", self.root_dir))?
{
let entry = entry.context("Failed to read directory entry")?;
let path = entry.path();
if path.is_dir() {
let name = path
.file_name()
.and_then(|n| n.to_str())
.map(std::string::ToString::to_string)
.ok_or_else(|| anyhow::anyhow!("Invalid domain name: {}", path.display()))?;
domains.push(Domain { name, path });
}
}
domains.sort_by(|a, b| a.name.cmp(&b.name));
Ok(domains)
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct SchemaIncludes {
pub types: Vec<String>,
pub queries: Vec<String>,
pub mutations: Vec<String>,
}
impl SchemaIncludes {
pub fn is_empty(&self) -> bool {
self.types.is_empty() && self.queries.is_empty() && self.mutations.is_empty()
}
pub fn resolve_globs(&self) -> Result<ResolvedIncludes> {
use glob::glob as glob_pattern;
let mut type_paths = Vec::new();
let mut query_paths = Vec::new();
let mut mutation_paths = Vec::new();
for pattern in &self.types {
for entry in glob_pattern(pattern)
.context(format!("Invalid glob pattern for types: {pattern}"))?
{
match entry {
Ok(path) => type_paths.push(path),
Err(e) => {
anyhow::bail!("Error resolving type glob pattern '{pattern}': {e}");
},
}
}
}
for pattern in &self.queries {
for entry in glob_pattern(pattern)
.context(format!("Invalid glob pattern for queries: {pattern}"))?
{
match entry {
Ok(path) => query_paths.push(path),
Err(e) => {
anyhow::bail!("Error resolving query glob pattern '{pattern}': {e}");
},
}
}
}
for pattern in &self.mutations {
for entry in glob_pattern(pattern)
.context(format!("Invalid glob pattern for mutations: {pattern}"))?
{
match entry {
Ok(path) => mutation_paths.push(path),
Err(e) => {
anyhow::bail!("Error resolving mutation glob pattern '{pattern}': {e}");
},
}
}
}
type_paths.sort();
query_paths.sort();
mutation_paths.sort();
type_paths.dedup();
query_paths.dedup();
mutation_paths.dedup();
Ok(ResolvedIncludes {
types: type_paths,
queries: query_paths,
mutations: mutation_paths,
})
}
}
#[derive(Debug, Clone)]
pub struct ResolvedIncludes {
pub types: Vec<PathBuf>,
pub queries: Vec<PathBuf>,
pub mutations: Vec<PathBuf>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct TomlSchema {
#[serde(rename = "schema")]
pub schema: SchemaMetadata,
#[serde(rename = "database")]
pub database: DatabaseRuntimeConfig,
#[serde(rename = "server")]
pub server: ServerRuntimeConfig,
#[serde(rename = "types")]
pub types: BTreeMap<String, TypeDefinition>,
#[serde(rename = "queries")]
pub queries: BTreeMap<String, QueryDefinition>,
#[serde(rename = "mutations")]
pub mutations: BTreeMap<String, MutationDefinition>,
#[serde(rename = "federation")]
pub federation: FederationConfig,
#[serde(rename = "security")]
pub security: SecuritySettings,
#[serde(rename = "observers")]
pub observers: ObserversConfig,
#[serde(rename = "caching")]
pub caching: CachingConfig,
#[serde(rename = "analytics")]
pub analytics: AnalyticsConfig,
#[serde(rename = "observability")]
pub observability: ObservabilityConfig,
#[serde(default)]
pub includes: SchemaIncludes,
#[serde(default)]
pub domain_discovery: DomainDiscovery,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct SchemaMetadata {
pub name: String,
pub version: String,
pub description: Option<String>,
pub database_target: String,
}
impl Default for SchemaMetadata {
fn default() -> Self {
Self {
name: "myapp".to_string(),
version: "1.0.0".to_string(),
description: None,
database_target: "postgresql".to_string(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct TypeDefinition {
pub sql_source: String,
pub description: Option<String>,
pub fields: BTreeMap<String, FieldDefinition>,
}
impl Default for TypeDefinition {
fn default() -> Self {
Self {
sql_source: "v_entity".to_string(),
description: None,
fields: BTreeMap::new(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct FieldDefinition {
#[serde(rename = "type")]
pub field_type: String,
#[serde(default)]
pub nullable: bool,
pub description: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct QueryDefinition {
pub return_type: String,
#[serde(default)]
pub return_array: bool,
pub sql_source: String,
pub description: Option<String>,
pub args: Vec<ArgumentDefinition>,
}
impl Default for QueryDefinition {
fn default() -> Self {
Self {
return_type: "String".to_string(),
return_array: false,
sql_source: "v_entity".to_string(),
description: None,
args: vec![],
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct MutationDefinition {
pub return_type: String,
pub sql_source: String,
pub operation: String,
pub description: Option<String>,
pub args: Vec<ArgumentDefinition>,
}
impl Default for MutationDefinition {
fn default() -> Self {
Self {
return_type: "String".to_string(),
sql_source: "fn_operation".to_string(),
operation: "CREATE".to_string(),
description: None,
args: vec![],
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ArgumentDefinition {
pub name: String,
#[serde(rename = "type")]
pub arg_type: String,
#[serde(default)]
pub required: bool,
pub default: Option<serde_json::Value>,
pub description: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct PerDatabaseCircuitBreakerOverride {
pub database: String,
pub failure_threshold: Option<u32>,
pub recovery_timeout_secs: Option<u64>,
pub success_threshold: Option<u32>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct FederationCircuitBreakerConfig {
pub enabled: bool,
pub failure_threshold: u32,
pub recovery_timeout_secs: u64,
pub success_threshold: u32,
pub per_database: Vec<PerDatabaseCircuitBreakerOverride>,
}
impl Default for FederationCircuitBreakerConfig {
fn default() -> Self {
Self {
enabled: true,
failure_threshold: 5,
recovery_timeout_secs: 30,
success_threshold: 2,
per_database: vec![],
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct FederationConfig {
#[serde(default)]
pub enabled: bool,
pub apollo_version: Option<u32>,
pub entities: Vec<FederationEntity>,
pub circuit_breaker: Option<FederationCircuitBreakerConfig>,
}
impl Default for FederationConfig {
fn default() -> Self {
Self {
enabled: false,
apollo_version: Some(2),
entities: vec![],
circuit_breaker: None,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct FederationEntity {
pub name: String,
pub key_fields: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct SecuritySettings {
pub default_policy: Option<String>,
pub rules: Vec<AuthorizationRule>,
pub policies: Vec<AuthorizationPolicy>,
pub field_auth: Vec<FieldAuthRule>,
pub enterprise: EnterpriseSecurityConfig,
}
impl Default for SecuritySettings {
fn default() -> Self {
Self {
default_policy: Some("authenticated".to_string()),
rules: vec![],
policies: vec![],
field_auth: vec![],
enterprise: EnterpriseSecurityConfig::default(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AuthorizationRule {
pub name: String,
pub rule: String,
pub description: Option<String>,
#[serde(default)]
pub cacheable: bool,
pub cache_ttl_seconds: Option<u32>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AuthorizationPolicy {
pub name: String,
#[serde(rename = "type")]
pub policy_type: String,
pub rule: Option<String>,
pub roles: Vec<String>,
pub strategy: Option<String>,
#[serde(default)]
pub attributes: Vec<String>,
pub description: Option<String>,
pub cache_ttl_seconds: Option<u32>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct FieldAuthRule {
pub type_name: String,
pub field_name: String,
pub policy: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct EnterpriseSecurityConfig {
pub rate_limiting_enabled: bool,
pub auth_endpoint_max_requests: u32,
pub auth_endpoint_window_seconds: u64,
pub audit_logging_enabled: bool,
pub audit_log_backend: String,
pub audit_retention_days: u32,
pub error_sanitization: bool,
pub hide_implementation_details: bool,
pub constant_time_comparison: bool,
pub pkce_enabled: bool,
}
impl Default for EnterpriseSecurityConfig {
fn default() -> Self {
Self {
rate_limiting_enabled: true,
auth_endpoint_max_requests: 100,
auth_endpoint_window_seconds: 60,
audit_logging_enabled: true,
audit_log_backend: "postgresql".to_string(),
audit_retention_days: 365,
error_sanitization: true,
hide_implementation_details: true,
constant_time_comparison: true,
pkce_enabled: true,
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct ObserversConfig {
#[serde(default)]
pub enabled: bool,
pub backend: String,
pub redis_url: Option<String>,
pub nats_url: Option<String>,
pub handlers: Vec<EventHandler>,
}
impl Default for ObserversConfig {
fn default() -> Self {
Self {
enabled: false,
backend: "redis".to_string(),
redis_url: None,
nats_url: None,
handlers: vec![],
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct EventHandler {
pub name: String,
pub event: String,
pub action: String,
pub webhook_url: Option<String>,
pub retry_strategy: Option<String>,
pub max_retries: Option<u32>,
pub description: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct CachingConfig {
#[serde(default)]
pub enabled: bool,
pub backend: String,
pub redis_url: Option<String>,
pub rules: Vec<CacheRule>,
}
impl Default for CachingConfig {
fn default() -> Self {
Self {
enabled: false,
backend: "redis".to_string(),
redis_url: None,
rules: vec![],
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct CacheRule {
pub query: String,
pub ttl_seconds: u32,
pub invalidation_triggers: Vec<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct AnalyticsConfig {
#[serde(default)]
pub enabled: bool,
pub queries: Vec<AnalyticsQuery>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct AnalyticsQuery {
pub name: String,
pub sql_source: String,
pub description: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub struct ObservabilityConfig {
pub prometheus_enabled: bool,
pub prometheus_port: u16,
pub otel_enabled: bool,
pub otel_exporter: String,
pub otel_jaeger_endpoint: Option<String>,
pub health_check_enabled: bool,
pub health_check_interval_seconds: u32,
pub log_level: String,
pub log_format: String,
}
impl Default for ObservabilityConfig {
fn default() -> Self {
Self {
prometheus_enabled: false,
prometheus_port: 9090,
otel_enabled: false,
otel_exporter: "jaeger".to_string(),
otel_jaeger_endpoint: None,
health_check_enabled: true,
health_check_interval_seconds: 30,
log_level: "info".to_string(),
log_format: "json".to_string(),
}
}
}
impl TomlSchema {
pub fn from_file(path: &str) -> Result<Self> {
let content =
std::fs::read_to_string(path).context(format!("Failed to read TOML file: {path}"))?;
Self::parse_toml(&content)
}
pub fn parse_toml(content: &str) -> Result<Self> {
let expanded = expand_env_vars(content);
toml::from_str(&expanded).context("Failed to parse TOML schema")
}
pub fn validate(&self) -> Result<()> {
for (query_name, query_def) in &self.queries {
if !self.types.contains_key(&query_def.return_type) {
anyhow::bail!(
"Query '{query_name}' references undefined type '{}'",
query_def.return_type
);
}
}
for (mut_name, mut_def) in &self.mutations {
if !self.types.contains_key(&mut_def.return_type) {
anyhow::bail!(
"Mutation '{mut_name}' references undefined type '{}'",
mut_def.return_type
);
}
}
for field_auth in &self.security.field_auth {
let policy_exists = self.security.policies.iter().any(|p| p.name == field_auth.policy);
if !policy_exists {
anyhow::bail!("Field auth references undefined policy '{}'", field_auth.policy);
}
}
for entity in &self.federation.entities {
if !self.types.contains_key(&entity.name) {
anyhow::bail!("Federation entity '{}' references undefined type", entity.name);
}
}
self.server.validate()?;
self.database.validate()?;
if let Some(cb) = &self.federation.circuit_breaker {
if cb.failure_threshold == 0 {
anyhow::bail!(
"federation.circuit_breaker.failure_threshold must be greater than 0"
);
}
if cb.recovery_timeout_secs == 0 {
anyhow::bail!(
"federation.circuit_breaker.recovery_timeout_secs must be greater than 0"
);
}
if cb.success_threshold == 0 {
anyhow::bail!(
"federation.circuit_breaker.success_threshold must be greater than 0"
);
}
let entity_names: std::collections::HashSet<&str> =
self.federation.entities.iter().map(|e| e.name.as_str()).collect();
for override_cfg in &cb.per_database {
if !entity_names.contains(override_cfg.database.as_str()) {
anyhow::bail!(
"federation.circuit_breaker.per_database entry '{}' does not match \
any defined federation entity",
override_cfg.database
);
}
if override_cfg.failure_threshold == Some(0) {
anyhow::bail!(
"federation.circuit_breaker.per_database['{}'].failure_threshold \
must be greater than 0",
override_cfg.database
);
}
if override_cfg.recovery_timeout_secs == Some(0) {
anyhow::bail!(
"federation.circuit_breaker.per_database['{}'].recovery_timeout_secs \
must be greater than 0",
override_cfg.database
);
}
if override_cfg.success_threshold == Some(0) {
anyhow::bail!(
"federation.circuit_breaker.per_database['{}'].success_threshold \
must be greater than 0",
override_cfg.database
);
}
}
}
Ok(())
}
pub fn to_intermediate_schema(&self) -> serde_json::Value {
let mut types_json = serde_json::Map::new();
for (type_name, type_def) in &self.types {
let mut fields_json = serde_json::Map::new();
for (field_name, field_def) in &type_def.fields {
fields_json.insert(
field_name.clone(),
serde_json::json!({
"type": field_def.field_type,
"nullable": field_def.nullable,
"description": field_def.description,
}),
);
}
types_json.insert(
type_name.clone(),
serde_json::json!({
"name": type_name,
"sql_source": type_def.sql_source,
"description": type_def.description,
"fields": fields_json,
}),
);
}
let mut queries_json = serde_json::Map::new();
for (query_name, query_def) in &self.queries {
let args: Vec<serde_json::Value> = query_def
.args
.iter()
.map(|arg| {
serde_json::json!({
"name": arg.name,
"type": arg.arg_type,
"required": arg.required,
"default": arg.default,
"description": arg.description,
})
})
.collect();
queries_json.insert(
query_name.clone(),
serde_json::json!({
"name": query_name,
"return_type": query_def.return_type,
"return_array": query_def.return_array,
"sql_source": query_def.sql_source,
"description": query_def.description,
"args": args,
}),
);
}
serde_json::json!({
"types": types_json,
"queries": queries_json,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_toml_schema() {
let toml = r#"
[schema]
name = "myapp"
version = "1.0.0"
database_target = "postgresql"
[types.User]
sql_source = "v_user"
[types.User.fields.id]
type = "ID"
nullable = false
[types.User.fields.name]
type = "String"
nullable = false
[queries.users]
return_type = "User"
return_array = true
sql_source = "v_user"
"#;
let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
assert_eq!(schema.schema.name, "myapp");
assert!(schema.types.contains_key("User"));
}
#[test]
fn test_validate_schema() {
let schema = TomlSchema::default();
assert!(schema.validate().is_ok());
}
#[test]
fn test_observers_config_nats_url_round_trip() {
let toml = r#"
[schema]
name = "myapp"
version = "1.0.0"
database_target = "postgresql"
[observers]
enabled = true
backend = "nats"
nats_url = "nats://localhost:4222"
"#;
let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
assert_eq!(schema.observers.backend, "nats");
assert_eq!(
schema.observers.nats_url.as_deref(),
Some("nats://localhost:4222")
);
assert!(schema.observers.redis_url.is_none());
}
#[test]
fn test_observers_config_redis_url_unchanged() {
let toml = r#"
[schema]
name = "myapp"
version = "1.0.0"
database_target = "postgresql"
[observers]
enabled = true
backend = "redis"
redis_url = "redis://localhost:6379"
"#;
let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
assert_eq!(schema.observers.backend, "redis");
assert_eq!(
schema.observers.redis_url.as_deref(),
Some("redis://localhost:6379")
);
assert!(schema.observers.nats_url.is_none());
}
#[test]
fn test_observers_config_nats_url_default_is_none() {
let config = ObserversConfig::default();
assert!(config.nats_url.is_none());
}
#[test]
fn test_federation_circuit_breaker_round_trip() {
let toml = r#"
[schema]
name = "myapp"
version = "1.0.0"
database_target = "postgresql"
[types.Product]
sql_source = "v_product"
[federation]
enabled = true
apollo_version = 2
[[federation.entities]]
name = "Product"
key_fields = ["id"]
[federation.circuit_breaker]
enabled = true
failure_threshold = 3
recovery_timeout_secs = 60
success_threshold = 1
"#;
let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
let cb = schema.federation.circuit_breaker.as_ref().expect("Expected circuit_breaker");
assert!(cb.enabled);
assert_eq!(cb.failure_threshold, 3);
assert_eq!(cb.recovery_timeout_secs, 60);
assert_eq!(cb.success_threshold, 1);
assert!(cb.per_database.is_empty());
}
#[test]
fn test_federation_circuit_breaker_zero_failure_threshold_rejected() {
let toml = r#"
[schema]
name = "myapp"
version = "1.0.0"
database_target = "postgresql"
[federation]
enabled = true
[federation.circuit_breaker]
enabled = true
failure_threshold = 0
recovery_timeout_secs = 30
success_threshold = 2
"#;
let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
let err = schema.validate().unwrap_err();
assert!(err.to_string().contains("failure_threshold"), "{err}");
}
#[test]
fn test_federation_circuit_breaker_zero_recovery_timeout_rejected() {
let toml = r#"
[schema]
name = "myapp"
version = "1.0.0"
database_target = "postgresql"
[federation]
enabled = true
[federation.circuit_breaker]
enabled = true
failure_threshold = 5
recovery_timeout_secs = 0
success_threshold = 2
"#;
let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
let err = schema.validate().unwrap_err();
assert!(err.to_string().contains("recovery_timeout_secs"), "{err}");
}
#[test]
fn test_federation_circuit_breaker_per_database_unknown_entity_rejected() {
let toml = r#"
[schema]
name = "myapp"
version = "1.0.0"
database_target = "postgresql"
[types.Product]
sql_source = "v_product"
[federation]
enabled = true
[[federation.entities]]
name = "Product"
key_fields = ["id"]
[federation.circuit_breaker]
enabled = true
failure_threshold = 5
recovery_timeout_secs = 30
success_threshold = 2
[[federation.circuit_breaker.per_database]]
database = "NonExistentEntity"
failure_threshold = 3
"#;
let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
let err = schema.validate().unwrap_err();
assert!(err.to_string().contains("NonExistentEntity"), "{err}");
}
#[test]
fn test_federation_circuit_breaker_per_database_valid() {
let toml = r#"
[schema]
name = "myapp"
version = "1.0.0"
database_target = "postgresql"
[types.Product]
sql_source = "v_product"
[federation]
enabled = true
[[federation.entities]]
name = "Product"
key_fields = ["id"]
[federation.circuit_breaker]
enabled = true
failure_threshold = 5
recovery_timeout_secs = 30
success_threshold = 2
[[federation.circuit_breaker.per_database]]
database = "Product"
failure_threshold = 3
recovery_timeout_secs = 15
"#;
let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
assert!(schema.validate().is_ok());
let cb = schema.federation.circuit_breaker.as_ref().unwrap();
assert_eq!(cb.per_database.len(), 1);
assert_eq!(cb.per_database[0].database, "Product");
assert_eq!(cb.per_database[0].failure_threshold, Some(3));
assert_eq!(cb.per_database[0].recovery_timeout_secs, Some(15));
}
#[test]
fn test_toml_schema_parses_server_section() {
let toml = r#"
[schema]
name = "myapp"
version = "1.0.0"
database_target = "postgresql"
[server]
host = "127.0.0.1"
port = 9999
[server.cors]
origins = ["https://example.com"]
"#;
let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
assert_eq!(schema.server.host, "127.0.0.1");
assert_eq!(schema.server.port, 9999);
assert_eq!(schema.server.cors.origins, ["https://example.com"]);
}
#[test]
fn test_toml_schema_database_uses_runtime_config() {
let toml = r#"
[schema]
name = "myapp"
version = "1.0.0"
database_target = "postgresql"
[database]
url = "postgresql://localhost/mydb"
pool_min = 5
pool_max = 30
ssl_mode = "require"
"#;
let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
assert_eq!(schema.database.url, Some("postgresql://localhost/mydb".to_string()));
assert_eq!(schema.database.pool_min, 5);
assert_eq!(schema.database.pool_max, 30);
assert_eq!(schema.database.ssl_mode, "require");
}
#[test]
fn test_env_var_expansion_in_toml_schema() {
temp_env::with_var("SCHEMA_TEST_DB_URL", Some("postgres://test/fraiseql"), || {
let toml = r#"
[schema]
name = "myapp"
version = "1.0.0"
database_target = "postgresql"
[database]
url = "${SCHEMA_TEST_DB_URL}"
"#;
let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
assert_eq!(
schema.database.url,
Some("postgres://test/fraiseql".to_string())
);
});
}
#[test]
fn test_toml_schema_defaults_without_server_section() {
let toml = r#"
[schema]
name = "myapp"
version = "1.0.0"
database_target = "postgresql"
"#;
let schema = TomlSchema::parse_toml(toml).expect("Failed to parse");
assert_eq!(schema.server.host, "0.0.0.0");
assert_eq!(schema.server.port, 8080);
assert_eq!(schema.database.pool_min, 2);
assert_eq!(schema.database.pool_max, 20);
assert!(schema.database.url.is_none());
}
}