1use std::path::Path;
44
45use serde::{Deserialize, Serialize};
46
47use crate::error::{FraiseQLError, Result};
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
55#[serde(default)]
56pub struct ServerConfig {
57 pub host: String,
59
60 pub port: u16,
62
63 pub workers: usize,
65
66 pub max_body_size: usize,
68
69 pub request_logging: bool,
71}
72
73impl Default for ServerConfig {
74 fn default() -> Self {
75 Self {
76 host: "0.0.0.0".to_string(),
77 port: 8000,
78 workers: 0, max_body_size: 1024 * 1024, request_logging: true,
81 }
82 }
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
91#[serde(default)]
92pub struct DatabaseConfig {
93 pub url: String,
95
96 pub max_connections: u32,
98
99 pub min_connections: u32,
101
102 pub connect_timeout_secs: u64,
104
105 pub query_timeout_secs: u64,
107
108 pub idle_timeout_secs: u64,
110
111 pub ssl_mode: SslMode,
113}
114
115impl Default for DatabaseConfig {
116 fn default() -> Self {
117 Self {
118 url: String::new(),
119 max_connections: 10,
120 min_connections: 1,
121 connect_timeout_secs: 10,
122 query_timeout_secs: 30,
123 idle_timeout_secs: 600,
124 ssl_mode: SslMode::Prefer,
125 }
126 }
127}
128
129#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
131#[serde(rename_all = "kebab-case")]
132pub enum SslMode {
133 Disable,
135 #[default]
137 Prefer,
138 Require,
140 VerifyCa,
142 VerifyFull,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
152#[serde(default)]
153pub struct CorsConfig {
154 pub enabled: bool,
156
157 pub allowed_origins: Vec<String>,
159
160 pub allowed_methods: Vec<String>,
162
163 pub allowed_headers: Vec<String>,
165
166 pub expose_headers: Vec<String>,
168
169 pub allow_credentials: bool,
171
172 pub max_age_secs: u64,
174}
175
176impl Default for CorsConfig {
177 fn default() -> Self {
178 Self {
179 enabled: true,
180 allowed_origins: vec![], allowed_methods: vec!["GET".to_string(), "POST".to_string(), "OPTIONS".to_string()],
182 allowed_headers: vec![
183 "Content-Type".to_string(),
184 "Authorization".to_string(),
185 "X-Request-ID".to_string(),
186 ],
187 expose_headers: vec![],
188 allow_credentials: false,
189 max_age_secs: 86400, }
191 }
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
200#[serde(default)]
201pub struct AuthConfig {
202 pub enabled: bool,
204
205 pub provider: AuthProvider,
207
208 pub jwt_secret: Option<String>,
210
211 pub jwt_algorithm: String,
213
214 pub domain: Option<String>,
216
217 pub audience: Option<String>,
219
220 pub client_id: Option<String>,
222
223 pub header_name: String,
225
226 pub token_prefix: String,
228
229 pub exclude_paths: Vec<String>,
231}
232
233impl Default for AuthConfig {
234 fn default() -> Self {
235 Self {
236 enabled: false,
237 provider: AuthProvider::None,
238 jwt_secret: None,
239 jwt_algorithm: "HS256".to_string(),
240 domain: None,
241 audience: None,
242 client_id: None,
243 header_name: "Authorization".to_string(),
244 token_prefix: "Bearer ".to_string(),
245 exclude_paths: vec!["/health".to_string()],
246 }
247 }
248}
249
250#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
252#[serde(rename_all = "lowercase")]
253pub enum AuthProvider {
254 #[default]
256 None,
257 Jwt,
259 Auth0,
261 Clerk,
263 Webhook,
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize)]
273#[serde(default)]
274pub struct RateLimitConfig {
275 pub enabled: bool,
277
278 pub requests_per_window: u32,
280
281 pub window_secs: u64,
283
284 pub key_by: RateLimitKey,
286
287 pub exclude_paths: Vec<String>,
289
290 pub path_limits: Vec<PathRateLimit>,
292}
293
294impl Default for RateLimitConfig {
295 fn default() -> Self {
296 Self {
297 enabled: false,
298 requests_per_window: 100,
299 window_secs: 60,
300 key_by: RateLimitKey::Ip,
301 exclude_paths: vec!["/health".to_string()],
302 path_limits: vec![],
303 }
304 }
305}
306
307#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
309#[serde(rename_all = "snake_case")]
310pub enum RateLimitKey {
311 #[default]
313 Ip,
314 User,
316 ApiKey,
318}
319
320#[derive(Debug, Clone, Serialize, Deserialize)]
322pub struct PathRateLimit {
323 pub path: String,
325 pub requests_per_window: u32,
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize)]
335#[serde(default)]
336pub struct CacheConfig {
337 pub apq_enabled: bool,
339
340 pub apq_ttl_secs: u64,
342
343 pub apq_max_entries: usize,
345
346 pub response_cache_enabled: bool,
348
349 pub response_cache_ttl_secs: u64,
351
352 pub response_cache_max_entries: usize,
354}
355
356impl Default for CacheConfig {
357 fn default() -> Self {
358 Self {
359 apq_enabled: true,
360 apq_ttl_secs: 86400, apq_max_entries: 10_000,
362 response_cache_enabled: false,
363 response_cache_ttl_secs: 60,
364 response_cache_max_entries: 1_000,
365 }
366 }
367}
368
369#[derive(Debug, Clone, Serialize, Deserialize)]
378#[serde(default)]
379pub struct CollationConfig {
380 pub enabled: bool,
382
383 pub fallback_locale: String,
385
386 pub allowed_locales: Vec<String>,
388
389 pub on_invalid_locale: InvalidLocaleStrategy,
391
392 #[serde(skip_serializing_if = "Option::is_none")]
394 pub database_overrides: Option<DatabaseCollationOverrides>,
395}
396
397impl Default for CollationConfig {
398 fn default() -> Self {
399 Self {
400 enabled: true,
401 fallback_locale: "en-US".to_string(),
402 allowed_locales: vec![
403 "en-US".into(),
404 "en-GB".into(),
405 "fr-FR".into(),
406 "de-DE".into(),
407 "es-ES".into(),
408 "ja-JP".into(),
409 "zh-CN".into(),
410 "pt-BR".into(),
411 "it-IT".into(),
412 ],
413 on_invalid_locale: InvalidLocaleStrategy::Fallback,
414 database_overrides: None,
415 }
416 }
417}
418
419#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
421#[serde(rename_all = "snake_case")]
422pub enum InvalidLocaleStrategy {
423 #[default]
425 Fallback,
426 DatabaseDefault,
428 Error,
430}
431
432#[derive(Debug, Clone, Serialize, Deserialize)]
434pub struct DatabaseCollationOverrides {
435 #[serde(skip_serializing_if = "Option::is_none")]
437 pub postgres: Option<PostgresCollationConfig>,
438
439 #[serde(skip_serializing_if = "Option::is_none")]
441 pub mysql: Option<MySqlCollationConfig>,
442
443 #[serde(skip_serializing_if = "Option::is_none")]
445 pub sqlite: Option<SqliteCollationConfig>,
446
447 #[serde(skip_serializing_if = "Option::is_none")]
449 pub sqlserver: Option<SqlServerCollationConfig>,
450}
451
452#[derive(Debug, Clone, Serialize, Deserialize)]
454pub struct PostgresCollationConfig {
455 pub use_icu: bool,
457
458 pub provider: String,
460}
461
462impl Default for PostgresCollationConfig {
463 fn default() -> Self {
464 Self {
465 use_icu: true,
466 provider: "icu".to_string(),
467 }
468 }
469}
470
471#[derive(Debug, Clone, Serialize, Deserialize)]
473pub struct MySqlCollationConfig {
474 pub charset: String,
476
477 pub suffix: String,
479}
480
481impl Default for MySqlCollationConfig {
482 fn default() -> Self {
483 Self {
484 charset: "utf8mb4".to_string(),
485 suffix: "_unicode_ci".to_string(),
486 }
487 }
488}
489
490#[derive(Debug, Clone, Serialize, Deserialize)]
492pub struct SqliteCollationConfig {
493 pub use_nocase: bool,
495}
496
497impl Default for SqliteCollationConfig {
498 fn default() -> Self {
499 Self { use_nocase: true }
500 }
501}
502
503#[derive(Debug, Clone, Serialize, Deserialize)]
505pub struct SqlServerCollationConfig {
506 pub case_insensitive: bool,
508
509 pub accent_insensitive: bool,
511}
512
513impl Default for SqlServerCollationConfig {
514 fn default() -> Self {
515 Self {
516 case_insensitive: true,
517 accent_insensitive: true,
518 }
519 }
520}
521
522#[derive(Debug, Clone, Serialize, Deserialize)]
531#[serde(default)]
532pub struct FraiseQLConfig {
533 pub server: ServerConfig,
535
536 pub database: DatabaseConfig,
538
539 pub cors: CorsConfig,
541
542 pub auth: AuthConfig,
544
545 pub rate_limit: RateLimitConfig,
547
548 pub cache: CacheConfig,
550
551 pub collation: CollationConfig,
553
554 #[serde(skip)]
556 database_url_compat: Option<String>,
557
558 #[serde(skip_serializing, default)]
560 pub database_url: String,
561
562 #[serde(skip_serializing, default)]
564 pub host: String,
565
566 #[serde(skip_serializing, default)]
568 pub port: u16,
569
570 #[serde(skip_serializing, default)]
572 pub max_connections: u32,
573
574 #[serde(skip_serializing, default)]
576 pub query_timeout_secs: u64,
577}
578
579impl Default for FraiseQLConfig {
580 fn default() -> Self {
581 let server = ServerConfig::default();
582 let database = DatabaseConfig::default();
583
584 Self {
585 database_url: String::new(),
587 host: server.host.clone(),
588 port: server.port,
589 max_connections: database.max_connections,
590 query_timeout_secs: database.query_timeout_secs,
591 database_url_compat: None,
592
593 server,
595 database,
596 cors: CorsConfig::default(),
597 auth: AuthConfig::default(),
598 rate_limit: RateLimitConfig::default(),
599 cache: CacheConfig::default(),
600 collation: CollationConfig::default(),
601 }
602 }
603}
604
605impl FraiseQLConfig {
606 #[must_use]
608 pub fn builder() -> ConfigBuilder {
609 ConfigBuilder::default()
610 }
611
612 pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
618 let path = path.as_ref();
619 let content = std::fs::read_to_string(path).map_err(|e| FraiseQLError::Configuration {
620 message: format!("Failed to read config file '{}': {}", path.display(), e),
621 })?;
622
623 Self::from_toml(&content)
624 }
625
626 pub fn from_toml(content: &str) -> Result<Self> {
632 let expanded = expand_env_vars(content);
634
635 let mut config: Self =
636 toml::from_str(&expanded).map_err(|e| FraiseQLError::Configuration {
637 message: format!("Invalid TOML configuration: {e}"),
638 })?;
639
640 config.sync_legacy_fields();
642
643 Ok(config)
644 }
645
646 pub fn from_env() -> Result<Self> {
652 let database_url =
653 std::env::var("DATABASE_URL").map_err(|_| FraiseQLError::Configuration {
654 message: "DATABASE_URL not set".to_string(),
655 })?;
656
657 let host = std::env::var("FRAISEQL_HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
658
659 let port = std::env::var("FRAISEQL_PORT").ok().and_then(|s| s.parse().ok()).unwrap_or(8000);
660
661 let max_connections = std::env::var("FRAISEQL_MAX_CONNECTIONS")
662 .ok()
663 .and_then(|s| s.parse().ok())
664 .unwrap_or(10);
665
666 let query_timeout = std::env::var("FRAISEQL_QUERY_TIMEOUT")
667 .ok()
668 .and_then(|s| s.parse().ok())
669 .unwrap_or(30);
670
671 let mut config = Self {
672 server: ServerConfig {
673 host: host.clone(),
674 port,
675 ..Default::default()
676 },
677 database: DatabaseConfig {
678 url: database_url.clone(),
679 max_connections,
680 query_timeout_secs: query_timeout,
681 ..Default::default()
682 },
683 database_url,
685 host,
686 port,
687 max_connections,
688 query_timeout_secs: query_timeout,
689 ..Default::default()
690 };
691
692 if let Ok(provider) = std::env::var("FRAISEQL_AUTH_PROVIDER") {
694 config.auth.enabled = true;
695 config.auth.provider = match provider.to_lowercase().as_str() {
696 "jwt" => AuthProvider::Jwt,
697 "auth0" => AuthProvider::Auth0,
698 "clerk" => AuthProvider::Clerk,
699 "webhook" => AuthProvider::Webhook,
700 _ => AuthProvider::None,
701 };
702 }
703
704 if let Ok(secret) = std::env::var("JWT_SECRET") {
705 config.auth.jwt_secret = Some(secret);
706 }
707
708 if let Ok(domain) = std::env::var("AUTH0_DOMAIN") {
709 config.auth.domain = Some(domain);
710 }
711
712 if let Ok(audience) = std::env::var("AUTH0_AUDIENCE") {
713 config.auth.audience = Some(audience);
714 }
715
716 Ok(config)
717 }
718
719 fn sync_legacy_fields(&mut self) {
721 if !self.database.url.is_empty() {
723 self.database_url = self.database.url.clone();
724 } else if !self.database_url.is_empty() {
725 self.database.url = self.database_url.clone();
727 }
728
729 self.host = self.server.host.clone();
731 self.port = self.server.port;
732 self.max_connections = self.database.max_connections;
733 self.query_timeout_secs = self.database.query_timeout_secs;
734 }
735
736 #[must_use]
738 pub fn test() -> Self {
739 Self {
740 server: ServerConfig {
741 host: "127.0.0.1".to_string(),
742 port: 0, ..Default::default()
744 },
745 database: DatabaseConfig {
746 url: "postgresql://postgres:postgres@localhost:5432/fraiseql_test".to_string(),
747 max_connections: 2,
748 query_timeout_secs: 5,
749 ..Default::default()
750 },
751 database_url: "postgresql://postgres:postgres@localhost:5432/fraiseql_test".to_string(),
753 host: "127.0.0.1".to_string(),
754 port: 0,
755 max_connections: 2,
756 query_timeout_secs: 5,
757 ..Default::default()
758 }
759 }
760
761 pub fn validate(&self) -> Result<()> {
767 if self.database.url.is_empty() && self.database_url.is_empty() {
769 return Err(FraiseQLError::Configuration {
770 message: "database.url is required".to_string(),
771 });
772 }
773
774 if self.auth.enabled {
776 match self.auth.provider {
777 AuthProvider::Jwt => {
778 if self.auth.jwt_secret.is_none() {
779 return Err(FraiseQLError::Configuration {
780 message: "auth.jwt_secret is required when using JWT provider"
781 .to_string(),
782 });
783 }
784 },
785 AuthProvider::Auth0 | AuthProvider::Clerk => {
786 if self.auth.domain.is_none() {
787 return Err(FraiseQLError::Configuration {
788 message: format!(
789 "auth.domain is required when using {:?} provider",
790 self.auth.provider
791 ),
792 });
793 }
794 },
795 AuthProvider::Webhook | AuthProvider::None => {},
796 }
797 }
798
799 Ok(())
800 }
801
802 #[must_use]
804 pub fn to_toml(&self) -> String {
805 toml::to_string_pretty(self).unwrap_or_default()
806 }
807}
808
809#[allow(clippy::expect_used)] fn expand_env_vars(content: &str) -> String {
814 use std::sync::LazyLock;
815
816 static ENV_VAR_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
818 regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").expect("env var regex is valid")
819 });
820
821 let mut result = content.to_string();
822
823 for cap in ENV_VAR_REGEX.captures_iter(content) {
824 if let Some(full_match) = cap.get(0) {
825 if let Some(var_name_match) = cap.get(1) {
826 let full_match_str = full_match.as_str();
827 let var_name = var_name_match.as_str();
828
829 if let Ok(value) = std::env::var(var_name) {
830 result = result.replace(full_match_str, &value);
831 }
832 }
833 }
834 }
835
836 result
837}
838
839#[derive(Debug, Default)]
841pub struct ConfigBuilder {
842 config: FraiseQLConfig,
843}
844
845impl ConfigBuilder {
846 #[must_use]
848 pub fn database_url(mut self, url: &str) -> Self {
849 self.config.database.url = url.to_string();
850 self.config.database_url = url.to_string();
851 self
852 }
853
854 #[must_use]
856 pub fn host(mut self, host: &str) -> Self {
857 self.config.server.host = host.to_string();
858 self.config.host = host.to_string();
859 self
860 }
861
862 #[must_use]
864 pub fn port(mut self, port: u16) -> Self {
865 self.config.server.port = port;
866 self.config.port = port;
867 self
868 }
869
870 #[must_use]
872 pub fn max_connections(mut self, n: u32) -> Self {
873 self.config.database.max_connections = n;
874 self.config.max_connections = n;
875 self
876 }
877
878 #[must_use]
880 pub fn query_timeout(mut self, secs: u64) -> Self {
881 self.config.database.query_timeout_secs = secs;
882 self.config.query_timeout_secs = secs;
883 self
884 }
885
886 #[must_use]
888 pub fn cors(mut self, cors: CorsConfig) -> Self {
889 self.config.cors = cors;
890 self
891 }
892
893 #[must_use]
895 pub fn auth(mut self, auth: AuthConfig) -> Self {
896 self.config.auth = auth;
897 self
898 }
899
900 #[must_use]
902 pub fn rate_limit(mut self, rate_limit: RateLimitConfig) -> Self {
903 self.config.rate_limit = rate_limit;
904 self
905 }
906
907 #[must_use]
909 pub fn cache(mut self, cache: CacheConfig) -> Self {
910 self.config.cache = cache;
911 self
912 }
913
914 #[must_use]
916 pub fn collation(mut self, collation: CollationConfig) -> Self {
917 self.config.collation = collation;
918 self
919 }
920
921 pub fn build(mut self) -> Result<FraiseQLConfig> {
927 self.config.sync_legacy_fields();
928 self.config.validate()?;
929 Ok(self.config)
930 }
931}
932
933#[cfg(test)]
934mod tests {
935 use super::*;
936
937 #[test]
938 fn test_default_config() {
939 let config = FraiseQLConfig::default();
940 assert_eq!(config.port, 8000);
941 assert_eq!(config.host, "0.0.0.0");
942 assert_eq!(config.server.port, 8000);
943 assert_eq!(config.server.host, "0.0.0.0");
944 }
945
946 #[test]
947 fn test_builder() {
948 let config = FraiseQLConfig::builder()
949 .database_url("postgresql://localhost/test")
950 .port(9000)
951 .build()
952 .unwrap();
953
954 assert_eq!(config.port, 9000);
955 assert_eq!(config.server.port, 9000);
956 assert!(!config.database_url.is_empty());
957 assert!(!config.database.url.is_empty());
958 }
959
960 #[test]
961 fn test_builder_requires_database_url() {
962 let result = FraiseQLConfig::builder().build();
963 assert!(result.is_err());
964 }
965
966 #[test]
967 fn test_from_toml_minimal() {
968 let toml = r#"
969[database]
970url = "postgresql://localhost/test"
971"#;
972 let config = FraiseQLConfig::from_toml(toml).unwrap();
973 assert_eq!(config.database.url, "postgresql://localhost/test");
974 assert_eq!(config.database_url, "postgresql://localhost/test");
975 }
976
977 #[test]
978 fn test_from_toml_full() {
979 let toml = r#"
980[server]
981host = "127.0.0.1"
982port = 9000
983workers = 4
984max_body_size = 2097152
985request_logging = true
986
987[database]
988url = "postgresql://localhost/mydb"
989max_connections = 20
990min_connections = 2
991connect_timeout_secs = 15
992query_timeout_secs = 60
993idle_timeout_secs = 300
994ssl_mode = "require"
995
996[cors]
997enabled = true
998allowed_origins = ["http://localhost:3000", "https://app.example.com"]
999allow_credentials = true
1000
1001[auth]
1002enabled = true
1003provider = "jwt"
1004jwt_secret = "my-secret-key"
1005jwt_algorithm = "HS256"
1006exclude_paths = ["/health", "/metrics"]
1007
1008[rate_limit]
1009enabled = true
1010requests_per_window = 200
1011window_secs = 120
1012key_by = "user"
1013
1014[cache]
1015apq_enabled = true
1016apq_ttl_secs = 3600
1017response_cache_enabled = true
1018"#;
1019 let config = FraiseQLConfig::from_toml(toml).unwrap();
1020
1021 assert_eq!(config.server.host, "127.0.0.1");
1023 assert_eq!(config.server.port, 9000);
1024 assert_eq!(config.server.workers, 4);
1025
1026 assert_eq!(config.database.url, "postgresql://localhost/mydb");
1028 assert_eq!(config.database.max_connections, 20);
1029 assert_eq!(config.database.ssl_mode, SslMode::Require);
1030
1031 assert!(config.cors.enabled);
1033 assert_eq!(config.cors.allowed_origins.len(), 2);
1034 assert!(config.cors.allow_credentials);
1035
1036 assert!(config.auth.enabled);
1038 assert_eq!(config.auth.provider, AuthProvider::Jwt);
1039 assert_eq!(config.auth.jwt_secret, Some("my-secret-key".to_string()));
1040
1041 assert!(config.rate_limit.enabled);
1043 assert_eq!(config.rate_limit.requests_per_window, 200);
1044 assert_eq!(config.rate_limit.key_by, RateLimitKey::User);
1045
1046 assert!(config.cache.apq_enabled);
1048 assert!(config.cache.response_cache_enabled);
1049 }
1050
1051 #[test]
1052 fn test_env_var_expansion() {
1053 temp_env::with_vars(
1054 [
1055 ("TEST_DB_URL", Some("postgresql://user:pass@host/db")),
1056 ("TEST_JWT_SECRET", Some("super-secret")),
1057 ],
1058 || {
1059 let toml = r#"
1060[database]
1061url = "${TEST_DB_URL}"
1062
1063[auth]
1064enabled = true
1065provider = "jwt"
1066jwt_secret = "${TEST_JWT_SECRET}"
1067"#;
1068 let config = FraiseQLConfig::from_toml(toml).unwrap();
1069
1070 assert_eq!(config.database.url, "postgresql://user:pass@host/db");
1071 assert_eq!(config.auth.jwt_secret, Some("super-secret".to_string()));
1072 },
1073 );
1074 }
1075
1076 #[test]
1077 fn test_auth_validation_jwt_requires_secret() {
1078 let toml = r#"
1079[database]
1080url = "postgresql://localhost/test"
1081
1082[auth]
1083enabled = true
1084provider = "jwt"
1085"#;
1086 let result = FraiseQLConfig::from_toml(toml);
1087 let config = result.unwrap();
1089 let validation = config.validate();
1090 assert!(validation.is_err());
1091 assert!(validation.unwrap_err().to_string().contains("jwt_secret is required"));
1092 }
1093
1094 #[test]
1095 fn test_auth_validation_auth0_requires_domain() {
1096 let toml = r#"
1097[database]
1098url = "postgresql://localhost/test"
1099
1100[auth]
1101enabled = true
1102provider = "auth0"
1103"#;
1104 let config = FraiseQLConfig::from_toml(toml).unwrap();
1105 let validation = config.validate();
1106 assert!(validation.is_err());
1107 assert!(validation.unwrap_err().to_string().contains("domain is required"));
1108 }
1109
1110 #[test]
1111 fn test_to_toml() {
1112 let config = FraiseQLConfig::builder()
1113 .database_url("postgresql://localhost/test")
1114 .port(9000)
1115 .build()
1116 .unwrap();
1117
1118 let toml_str = config.to_toml();
1119 assert!(toml_str.contains("[server]"));
1120 assert!(toml_str.contains("[database]"));
1121 assert!(toml_str.contains("port = 9000"));
1122 }
1123
1124 #[test]
1125 fn test_cors_config_defaults() {
1126 let cors = CorsConfig::default();
1127 assert!(cors.enabled);
1128 assert!(cors.allowed_origins.is_empty()); assert!(cors.allowed_methods.contains(&"POST".to_string()));
1130 assert!(cors.allowed_headers.contains(&"Authorization".to_string()));
1131 }
1132
1133 #[test]
1134 fn test_rate_limit_key_variants() {
1135 let toml = r#"
1136[database]
1137url = "postgresql://localhost/test"
1138
1139[rate_limit]
1140key_by = "api_key"
1141"#;
1142 let config = FraiseQLConfig::from_toml(toml).unwrap();
1143 assert_eq!(config.rate_limit.key_by, RateLimitKey::ApiKey);
1144 }
1145
1146 #[test]
1147 fn test_ssl_mode_variants() {
1148 for (ssl_str, expected) in [
1149 ("disable", SslMode::Disable),
1150 ("prefer", SslMode::Prefer),
1151 ("require", SslMode::Require),
1152 ("verify-ca", SslMode::VerifyCa),
1153 ("verify-full", SslMode::VerifyFull),
1154 ] {
1155 let toml = format!(
1156 r#"
1157[database]
1158url = "postgresql://localhost/test"
1159ssl_mode = "{}"
1160"#,
1161 ssl_str
1162 );
1163 let config = FraiseQLConfig::from_toml(&toml).unwrap();
1164 assert_eq!(config.database.ssl_mode, expected);
1165 }
1166 }
1167
1168 #[test]
1169 fn test_legacy_field_sync() {
1170 let config = FraiseQLConfig::builder()
1171 .database_url("postgresql://localhost/test")
1172 .host("192.168.1.1")
1173 .port(4000)
1174 .max_connections(50)
1175 .query_timeout(120)
1176 .build()
1177 .unwrap();
1178
1179 assert_eq!(config.host, "192.168.1.1");
1181 assert_eq!(config.server.host, "192.168.1.1");
1182 assert_eq!(config.port, 4000);
1183 assert_eq!(config.server.port, 4000);
1184 assert_eq!(config.max_connections, 50);
1185 assert_eq!(config.database.max_connections, 50);
1186 assert_eq!(config.query_timeout_secs, 120);
1187 assert_eq!(config.database.query_timeout_secs, 120);
1188 }
1189
1190 #[test]
1191 fn test_auth_providers() {
1192 for (provider_str, expected) in [
1193 ("none", AuthProvider::None),
1194 ("jwt", AuthProvider::Jwt),
1195 ("auth0", AuthProvider::Auth0),
1196 ("clerk", AuthProvider::Clerk),
1197 ("webhook", AuthProvider::Webhook),
1198 ] {
1199 let toml = format!(
1200 r#"
1201[database]
1202url = "postgresql://localhost/test"
1203
1204[auth]
1205provider = "{}"
1206"#,
1207 provider_str
1208 );
1209 let config = FraiseQLConfig::from_toml(&toml).unwrap();
1210 assert_eq!(config.auth.provider, expected);
1211 }
1212 }
1213
1214 #[test]
1215 fn test_collation_config_default() {
1216 let config = CollationConfig::default();
1217 assert!(config.enabled);
1218 assert_eq!(config.fallback_locale, "en-US");
1219 assert!(config.allowed_locales.contains(&"en-US".to_string()));
1220 assert!(config.allowed_locales.contains(&"fr-FR".to_string()));
1221 assert_eq!(config.on_invalid_locale, InvalidLocaleStrategy::Fallback);
1222 assert!(config.database_overrides.is_none());
1223 }
1224
1225 #[test]
1226 fn test_collation_config_from_toml() {
1227 let toml = r#"
1228[database]
1229url = "postgresql://localhost/test"
1230
1231[collation]
1232enabled = true
1233fallback_locale = "en-GB"
1234on_invalid_locale = "error"
1235allowed_locales = ["en-GB", "fr-FR", "de-DE"]
1236"#;
1237 let config = FraiseQLConfig::from_toml(toml).unwrap();
1238
1239 assert!(config.collation.enabled);
1240 assert_eq!(config.collation.fallback_locale, "en-GB");
1241 assert_eq!(config.collation.on_invalid_locale, InvalidLocaleStrategy::Error);
1242 assert_eq!(config.collation.allowed_locales.len(), 3);
1243 assert!(config.collation.allowed_locales.contains(&"de-DE".to_string()));
1244 }
1245
1246 #[test]
1247 fn test_collation_with_postgres_overrides() {
1248 let toml = r#"
1249[database]
1250url = "postgresql://localhost/test"
1251
1252[collation]
1253enabled = true
1254fallback_locale = "en-US"
1255
1256[collation.database_overrides.postgres]
1257use_icu = false
1258provider = "libc"
1259"#;
1260 let config = FraiseQLConfig::from_toml(toml).unwrap();
1261
1262 let overrides = config.collation.database_overrides.as_ref().unwrap();
1263 let pg_config = overrides.postgres.as_ref().unwrap();
1264 assert!(!pg_config.use_icu);
1265 assert_eq!(pg_config.provider, "libc");
1266 }
1267
1268 #[test]
1269 fn test_collation_with_mysql_overrides() {
1270 let toml = r#"
1271[database]
1272url = "postgresql://localhost/test"
1273
1274[collation]
1275enabled = true
1276
1277[collation.database_overrides.mysql]
1278charset = "utf8mb4"
1279suffix = "_0900_ai_ci"
1280"#;
1281 let config = FraiseQLConfig::from_toml(toml).unwrap();
1282
1283 let overrides = config.collation.database_overrides.as_ref().unwrap();
1284 let mysql_config = overrides.mysql.as_ref().unwrap();
1285 assert_eq!(mysql_config.charset, "utf8mb4");
1286 assert_eq!(mysql_config.suffix, "_0900_ai_ci");
1287 }
1288
1289 #[test]
1290 fn test_collation_with_sqlite_overrides() {
1291 let toml = r#"
1292[database]
1293url = "postgresql://localhost/test"
1294
1295[collation]
1296enabled = true
1297
1298[collation.database_overrides.sqlite]
1299use_nocase = false
1300"#;
1301 let config = FraiseQLConfig::from_toml(toml).unwrap();
1302
1303 let overrides = config.collation.database_overrides.as_ref().unwrap();
1304 let sqlite_config = overrides.sqlite.as_ref().unwrap();
1305 assert!(!sqlite_config.use_nocase);
1306 }
1307
1308 #[test]
1309 fn test_invalid_locale_strategy_variants() {
1310 for (strategy_str, expected) in [
1311 ("fallback", InvalidLocaleStrategy::Fallback),
1312 ("database_default", InvalidLocaleStrategy::DatabaseDefault),
1313 ("error", InvalidLocaleStrategy::Error),
1314 ] {
1315 let toml = format!(
1316 r#"
1317[database]
1318url = "postgresql://localhost/test"
1319
1320[collation]
1321on_invalid_locale = "{}"
1322"#,
1323 strategy_str
1324 );
1325 let config = FraiseQLConfig::from_toml(&toml).unwrap();
1326 assert_eq!(config.collation.on_invalid_locale, expected);
1327 }
1328 }
1329
1330 #[test]
1331 fn test_collation_disabled() {
1332 let toml = r#"
1333[database]
1334url = "postgresql://localhost/test"
1335
1336[collation]
1337enabled = false
1338"#;
1339 let config = FraiseQLConfig::from_toml(toml).unwrap();
1340 assert!(!config.collation.enabled);
1341 }
1342
1343 #[test]
1344 fn test_collation_config_builder() {
1345 let collation = CollationConfig {
1346 enabled: false,
1347 fallback_locale: "de-DE".to_string(),
1348 ..Default::default()
1349 };
1350
1351 let config = FraiseQLConfig::builder()
1352 .database_url("postgresql://localhost/test")
1353 .collation(collation)
1354 .build()
1355 .unwrap();
1356
1357 assert!(!config.collation.enabled);
1358 assert_eq!(config.collation.fallback_locale, "de-DE");
1359 }
1360}