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)]
813fn expand_env_vars(content: &str) -> String {
814 use once_cell::sync::Lazy;
815
816 static ENV_VAR_REGEX: Lazy<regex::Regex> =
818 Lazy::new(|| regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").expect("Invalid regex"));
819
820 let mut result = content.to_string();
821
822 for cap in ENV_VAR_REGEX.captures_iter(content) {
823 if let Some(full_match) = cap.get(0) {
824 if let Some(var_name_match) = cap.get(1) {
825 let full_match_str = full_match.as_str();
826 let var_name = var_name_match.as_str();
827
828 if let Ok(value) = std::env::var(var_name) {
829 result = result.replace(full_match_str, &value);
830 }
831 }
832 }
833 }
834
835 result
836}
837
838#[derive(Debug, Default)]
840pub struct ConfigBuilder {
841 config: FraiseQLConfig,
842}
843
844impl ConfigBuilder {
845 #[must_use]
847 pub fn database_url(mut self, url: &str) -> Self {
848 self.config.database.url = url.to_string();
849 self.config.database_url = url.to_string();
850 self
851 }
852
853 #[must_use]
855 pub fn host(mut self, host: &str) -> Self {
856 self.config.server.host = host.to_string();
857 self.config.host = host.to_string();
858 self
859 }
860
861 #[must_use]
863 pub fn port(mut self, port: u16) -> Self {
864 self.config.server.port = port;
865 self.config.port = port;
866 self
867 }
868
869 #[must_use]
871 pub fn max_connections(mut self, n: u32) -> Self {
872 self.config.database.max_connections = n;
873 self.config.max_connections = n;
874 self
875 }
876
877 #[must_use]
879 pub fn query_timeout(mut self, secs: u64) -> Self {
880 self.config.database.query_timeout_secs = secs;
881 self.config.query_timeout_secs = secs;
882 self
883 }
884
885 #[must_use]
887 pub fn cors(mut self, cors: CorsConfig) -> Self {
888 self.config.cors = cors;
889 self
890 }
891
892 #[must_use]
894 pub fn auth(mut self, auth: AuthConfig) -> Self {
895 self.config.auth = auth;
896 self
897 }
898
899 #[must_use]
901 pub fn rate_limit(mut self, rate_limit: RateLimitConfig) -> Self {
902 self.config.rate_limit = rate_limit;
903 self
904 }
905
906 #[must_use]
908 pub fn cache(mut self, cache: CacheConfig) -> Self {
909 self.config.cache = cache;
910 self
911 }
912
913 #[must_use]
915 pub fn collation(mut self, collation: CollationConfig) -> Self {
916 self.config.collation = collation;
917 self
918 }
919
920 pub fn build(mut self) -> Result<FraiseQLConfig> {
926 self.config.sync_legacy_fields();
927 self.config.validate()?;
928 Ok(self.config)
929 }
930}
931
932#[cfg(test)]
933mod tests {
934 use super::*;
935
936 #[test]
937 fn test_default_config() {
938 let config = FraiseQLConfig::default();
939 assert_eq!(config.port, 8000);
940 assert_eq!(config.host, "0.0.0.0");
941 assert_eq!(config.server.port, 8000);
942 assert_eq!(config.server.host, "0.0.0.0");
943 }
944
945 #[test]
946 fn test_builder() {
947 let config = FraiseQLConfig::builder()
948 .database_url("postgresql://localhost/test")
949 .port(9000)
950 .build()
951 .unwrap();
952
953 assert_eq!(config.port, 9000);
954 assert_eq!(config.server.port, 9000);
955 assert!(!config.database_url.is_empty());
956 assert!(!config.database.url.is_empty());
957 }
958
959 #[test]
960 fn test_builder_requires_database_url() {
961 let result = FraiseQLConfig::builder().build();
962 assert!(result.is_err());
963 }
964
965 #[test]
966 fn test_from_toml_minimal() {
967 let toml = r#"
968[database]
969url = "postgresql://localhost/test"
970"#;
971 let config = FraiseQLConfig::from_toml(toml).unwrap();
972 assert_eq!(config.database.url, "postgresql://localhost/test");
973 assert_eq!(config.database_url, "postgresql://localhost/test");
974 }
975
976 #[test]
977 fn test_from_toml_full() {
978 let toml = r#"
979[server]
980host = "127.0.0.1"
981port = 9000
982workers = 4
983max_body_size = 2097152
984request_logging = true
985
986[database]
987url = "postgresql://localhost/mydb"
988max_connections = 20
989min_connections = 2
990connect_timeout_secs = 15
991query_timeout_secs = 60
992idle_timeout_secs = 300
993ssl_mode = "require"
994
995[cors]
996enabled = true
997allowed_origins = ["http://localhost:3000", "https://app.example.com"]
998allow_credentials = true
999
1000[auth]
1001enabled = true
1002provider = "jwt"
1003jwt_secret = "my-secret-key"
1004jwt_algorithm = "HS256"
1005exclude_paths = ["/health", "/metrics"]
1006
1007[rate_limit]
1008enabled = true
1009requests_per_window = 200
1010window_secs = 120
1011key_by = "user"
1012
1013[cache]
1014apq_enabled = true
1015apq_ttl_secs = 3600
1016response_cache_enabled = true
1017"#;
1018 let config = FraiseQLConfig::from_toml(toml).unwrap();
1019
1020 assert_eq!(config.server.host, "127.0.0.1");
1022 assert_eq!(config.server.port, 9000);
1023 assert_eq!(config.server.workers, 4);
1024
1025 assert_eq!(config.database.url, "postgresql://localhost/mydb");
1027 assert_eq!(config.database.max_connections, 20);
1028 assert_eq!(config.database.ssl_mode, SslMode::Require);
1029
1030 assert!(config.cors.enabled);
1032 assert_eq!(config.cors.allowed_origins.len(), 2);
1033 assert!(config.cors.allow_credentials);
1034
1035 assert!(config.auth.enabled);
1037 assert_eq!(config.auth.provider, AuthProvider::Jwt);
1038 assert_eq!(config.auth.jwt_secret, Some("my-secret-key".to_string()));
1039
1040 assert!(config.rate_limit.enabled);
1042 assert_eq!(config.rate_limit.requests_per_window, 200);
1043 assert_eq!(config.rate_limit.key_by, RateLimitKey::User);
1044
1045 assert!(config.cache.apq_enabled);
1047 assert!(config.cache.response_cache_enabled);
1048 }
1049
1050 #[test]
1051 fn test_env_var_expansion() {
1052 std::env::set_var("TEST_DB_URL", "postgresql://user:pass@host/db");
1053 std::env::set_var("TEST_JWT_SECRET", "super-secret");
1054
1055 let toml = r#"
1056[database]
1057url = "${TEST_DB_URL}"
1058
1059[auth]
1060enabled = true
1061provider = "jwt"
1062jwt_secret = "${TEST_JWT_SECRET}"
1063"#;
1064 let config = FraiseQLConfig::from_toml(toml).unwrap();
1065
1066 assert_eq!(config.database.url, "postgresql://user:pass@host/db");
1067 assert_eq!(config.auth.jwt_secret, Some("super-secret".to_string()));
1068
1069 std::env::remove_var("TEST_DB_URL");
1070 std::env::remove_var("TEST_JWT_SECRET");
1071 }
1072
1073 #[test]
1074 fn test_auth_validation_jwt_requires_secret() {
1075 let toml = r#"
1076[database]
1077url = "postgresql://localhost/test"
1078
1079[auth]
1080enabled = true
1081provider = "jwt"
1082"#;
1083 let result = FraiseQLConfig::from_toml(toml);
1084 let config = result.unwrap();
1086 let validation = config.validate();
1087 assert!(validation.is_err());
1088 assert!(validation.unwrap_err().to_string().contains("jwt_secret is required"));
1089 }
1090
1091 #[test]
1092 fn test_auth_validation_auth0_requires_domain() {
1093 let toml = r#"
1094[database]
1095url = "postgresql://localhost/test"
1096
1097[auth]
1098enabled = true
1099provider = "auth0"
1100"#;
1101 let config = FraiseQLConfig::from_toml(toml).unwrap();
1102 let validation = config.validate();
1103 assert!(validation.is_err());
1104 assert!(validation.unwrap_err().to_string().contains("domain is required"));
1105 }
1106
1107 #[test]
1108 fn test_to_toml() {
1109 let config = FraiseQLConfig::builder()
1110 .database_url("postgresql://localhost/test")
1111 .port(9000)
1112 .build()
1113 .unwrap();
1114
1115 let toml_str = config.to_toml();
1116 assert!(toml_str.contains("[server]"));
1117 assert!(toml_str.contains("[database]"));
1118 assert!(toml_str.contains("port = 9000"));
1119 }
1120
1121 #[test]
1122 fn test_cors_config_defaults() {
1123 let cors = CorsConfig::default();
1124 assert!(cors.enabled);
1125 assert!(cors.allowed_origins.is_empty()); assert!(cors.allowed_methods.contains(&"POST".to_string()));
1127 assert!(cors.allowed_headers.contains(&"Authorization".to_string()));
1128 }
1129
1130 #[test]
1131 fn test_rate_limit_key_variants() {
1132 let toml = r#"
1133[database]
1134url = "postgresql://localhost/test"
1135
1136[rate_limit]
1137key_by = "api_key"
1138"#;
1139 let config = FraiseQLConfig::from_toml(toml).unwrap();
1140 assert_eq!(config.rate_limit.key_by, RateLimitKey::ApiKey);
1141 }
1142
1143 #[test]
1144 fn test_ssl_mode_variants() {
1145 for (ssl_str, expected) in [
1146 ("disable", SslMode::Disable),
1147 ("prefer", SslMode::Prefer),
1148 ("require", SslMode::Require),
1149 ("verify-ca", SslMode::VerifyCa),
1150 ("verify-full", SslMode::VerifyFull),
1151 ] {
1152 let toml = format!(
1153 r#"
1154[database]
1155url = "postgresql://localhost/test"
1156ssl_mode = "{}"
1157"#,
1158 ssl_str
1159 );
1160 let config = FraiseQLConfig::from_toml(&toml).unwrap();
1161 assert_eq!(config.database.ssl_mode, expected);
1162 }
1163 }
1164
1165 #[test]
1166 fn test_legacy_field_sync() {
1167 let config = FraiseQLConfig::builder()
1168 .database_url("postgresql://localhost/test")
1169 .host("192.168.1.1")
1170 .port(4000)
1171 .max_connections(50)
1172 .query_timeout(120)
1173 .build()
1174 .unwrap();
1175
1176 assert_eq!(config.host, "192.168.1.1");
1178 assert_eq!(config.server.host, "192.168.1.1");
1179 assert_eq!(config.port, 4000);
1180 assert_eq!(config.server.port, 4000);
1181 assert_eq!(config.max_connections, 50);
1182 assert_eq!(config.database.max_connections, 50);
1183 assert_eq!(config.query_timeout_secs, 120);
1184 assert_eq!(config.database.query_timeout_secs, 120);
1185 }
1186
1187 #[test]
1188 fn test_auth_providers() {
1189 for (provider_str, expected) in [
1190 ("none", AuthProvider::None),
1191 ("jwt", AuthProvider::Jwt),
1192 ("auth0", AuthProvider::Auth0),
1193 ("clerk", AuthProvider::Clerk),
1194 ("webhook", AuthProvider::Webhook),
1195 ] {
1196 let toml = format!(
1197 r#"
1198[database]
1199url = "postgresql://localhost/test"
1200
1201[auth]
1202provider = "{}"
1203"#,
1204 provider_str
1205 );
1206 let config = FraiseQLConfig::from_toml(&toml).unwrap();
1207 assert_eq!(config.auth.provider, expected);
1208 }
1209 }
1210
1211 #[test]
1212 fn test_collation_config_default() {
1213 let config = CollationConfig::default();
1214 assert!(config.enabled);
1215 assert_eq!(config.fallback_locale, "en-US");
1216 assert!(config.allowed_locales.contains(&"en-US".to_string()));
1217 assert!(config.allowed_locales.contains(&"fr-FR".to_string()));
1218 assert_eq!(config.on_invalid_locale, InvalidLocaleStrategy::Fallback);
1219 assert!(config.database_overrides.is_none());
1220 }
1221
1222 #[test]
1223 fn test_collation_config_from_toml() {
1224 let toml = r#"
1225[database]
1226url = "postgresql://localhost/test"
1227
1228[collation]
1229enabled = true
1230fallback_locale = "en-GB"
1231on_invalid_locale = "error"
1232allowed_locales = ["en-GB", "fr-FR", "de-DE"]
1233"#;
1234 let config = FraiseQLConfig::from_toml(toml).unwrap();
1235
1236 assert!(config.collation.enabled);
1237 assert_eq!(config.collation.fallback_locale, "en-GB");
1238 assert_eq!(config.collation.on_invalid_locale, InvalidLocaleStrategy::Error);
1239 assert_eq!(config.collation.allowed_locales.len(), 3);
1240 assert!(config.collation.allowed_locales.contains(&"de-DE".to_string()));
1241 }
1242
1243 #[test]
1244 fn test_collation_with_postgres_overrides() {
1245 let toml = r#"
1246[database]
1247url = "postgresql://localhost/test"
1248
1249[collation]
1250enabled = true
1251fallback_locale = "en-US"
1252
1253[collation.database_overrides.postgres]
1254use_icu = false
1255provider = "libc"
1256"#;
1257 let config = FraiseQLConfig::from_toml(toml).unwrap();
1258
1259 let overrides = config.collation.database_overrides.as_ref().unwrap();
1260 let pg_config = overrides.postgres.as_ref().unwrap();
1261 assert!(!pg_config.use_icu);
1262 assert_eq!(pg_config.provider, "libc");
1263 }
1264
1265 #[test]
1266 fn test_collation_with_mysql_overrides() {
1267 let toml = r#"
1268[database]
1269url = "postgresql://localhost/test"
1270
1271[collation]
1272enabled = true
1273
1274[collation.database_overrides.mysql]
1275charset = "utf8mb4"
1276suffix = "_0900_ai_ci"
1277"#;
1278 let config = FraiseQLConfig::from_toml(toml).unwrap();
1279
1280 let overrides = config.collation.database_overrides.as_ref().unwrap();
1281 let mysql_config = overrides.mysql.as_ref().unwrap();
1282 assert_eq!(mysql_config.charset, "utf8mb4");
1283 assert_eq!(mysql_config.suffix, "_0900_ai_ci");
1284 }
1285
1286 #[test]
1287 fn test_collation_with_sqlite_overrides() {
1288 let toml = r#"
1289[database]
1290url = "postgresql://localhost/test"
1291
1292[collation]
1293enabled = true
1294
1295[collation.database_overrides.sqlite]
1296use_nocase = false
1297"#;
1298 let config = FraiseQLConfig::from_toml(toml).unwrap();
1299
1300 let overrides = config.collation.database_overrides.as_ref().unwrap();
1301 let sqlite_config = overrides.sqlite.as_ref().unwrap();
1302 assert!(!sqlite_config.use_nocase);
1303 }
1304
1305 #[test]
1306 fn test_invalid_locale_strategy_variants() {
1307 for (strategy_str, expected) in [
1308 ("fallback", InvalidLocaleStrategy::Fallback),
1309 ("database_default", InvalidLocaleStrategy::DatabaseDefault),
1310 ("error", InvalidLocaleStrategy::Error),
1311 ] {
1312 let toml = format!(
1313 r#"
1314[database]
1315url = "postgresql://localhost/test"
1316
1317[collation]
1318on_invalid_locale = "{}"
1319"#,
1320 strategy_str
1321 );
1322 let config = FraiseQLConfig::from_toml(&toml).unwrap();
1323 assert_eq!(config.collation.on_invalid_locale, expected);
1324 }
1325 }
1326
1327 #[test]
1328 fn test_collation_disabled() {
1329 let toml = r#"
1330[database]
1331url = "postgresql://localhost/test"
1332
1333[collation]
1334enabled = false
1335"#;
1336 let config = FraiseQLConfig::from_toml(toml).unwrap();
1337 assert!(!config.collation.enabled);
1338 }
1339
1340 #[test]
1341 fn test_collation_config_builder() {
1342 let collation = CollationConfig {
1343 enabled: false,
1344 fallback_locale: "de-DE".to_string(),
1345 ..Default::default()
1346 };
1347
1348 let config = FraiseQLConfig::builder()
1349 .database_url("postgresql://localhost/test")
1350 .collation(collation)
1351 .build()
1352 .unwrap();
1353
1354 assert!(!config.collation.enabled);
1355 assert_eq!(config.collation.fallback_locale, "de-DE");
1356 }
1357}