1use globset::Glob;
7use serde::{Deserialize, Serialize};
8use std::net::IpAddr;
9use std::path::PathBuf;
10use zeroize::Zeroizing;
11
12#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "snake_case")]
15pub enum InjectMode {
16 #[default]
18 Header,
19 UrlPath,
21 QueryParam,
23 BasicAuth,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ProxyConfig {
30 #[serde(default = "default_bind_addr")]
32 pub bind_addr: IpAddr,
33
34 #[serde(default)]
36 pub bind_port: u16,
37
38 #[serde(default)]
42 pub allowed_hosts: Vec<String>,
43
44 #[serde(default)]
47 pub strict_filter: bool,
48
49 #[serde(default)]
51 pub routes: Vec<RouteConfig>,
52
53 #[serde(default)]
56 pub external_proxy: Option<ExternalProxyConfig>,
57
58 #[serde(default)]
62 pub direct_connect_ports: Vec<u16>,
63
64 #[serde(default)]
66 pub max_connections: usize,
67
68 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub intercept_ca_dir: Option<PathBuf>,
87
88 #[serde(default, skip)]
96 pub intercept_parent_ca_pems: Option<Vec<u8>>,
97
98 #[serde(default, skip)]
104 pub preloaded_ca: Option<PreloadedCa>,
105
106 #[serde(default, skip)]
110 pub ca_validity: Option<std::time::Duration>,
111}
112
113#[derive(Clone)]
131pub struct PreloadedCa {
132 pub key_der: Zeroizing<Vec<u8>>,
134 pub cert_pem: String,
136}
137
138impl std::fmt::Debug for PreloadedCa {
139 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140 f.debug_struct("PreloadedCa")
141 .field("key_der", &"[REDACTED]")
142 .field("cert_pem_len", &self.cert_pem.len())
143 .finish()
144 }
145}
146
147impl Default for ProxyConfig {
148 fn default() -> Self {
149 Self {
150 bind_addr: default_bind_addr(),
151 bind_port: 0,
152 allowed_hosts: Vec::new(),
153 strict_filter: false,
154 routes: Vec::new(),
155 external_proxy: None,
156 direct_connect_ports: Vec::new(),
157 max_connections: 256,
158 intercept_ca_dir: None,
159 intercept_parent_ca_pems: None,
160 preloaded_ca: None,
161 ca_validity: None,
162 }
163 }
164}
165
166fn default_bind_addr() -> IpAddr {
167 IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
168}
169
170#[derive(Debug, Clone, Default, Serialize, Deserialize)]
172pub struct RouteConfig {
173 pub prefix: String,
176
177 pub upstream: String,
179
180 pub credential_key: Option<String>,
183
184 #[serde(default)]
186 pub inject_mode: InjectMode,
187
188 #[serde(default = "default_inject_header")]
192 pub inject_header: String,
193
194 #[serde(default)]
200 pub credential_format: Option<String>,
201
202 #[serde(default)]
207 pub path_pattern: Option<String>,
208
209 #[serde(default)]
213 pub path_replacement: Option<String>,
214
215 #[serde(default)]
219 pub query_param_name: Option<String>,
220
221 #[serde(default)]
227 pub proxy: Option<ProxyInjectConfig>,
228
229 #[serde(default)]
236 pub env_var: Option<String>,
237
238 #[serde(default)]
244 pub endpoint_rules: Vec<EndpointRule>,
245
246 #[serde(default)]
253 pub tls_ca: Option<String>,
254
255 #[serde(default)]
262 pub tls_client_cert: Option<String>,
263
264 #[serde(default)]
269 pub tls_client_key: Option<String>,
270
271 #[serde(default)]
276 pub oauth2: Option<OAuth2Config>,
277
278 #[serde(default)]
283 pub aws_auth: Option<AwsAuthConfig>,
284}
285
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
292#[serde(deny_unknown_fields)]
293pub struct ProxyInjectConfig {
294 #[serde(default)]
296 pub inject_mode: Option<InjectMode>,
297
298 #[serde(default)]
300 pub inject_header: Option<String>,
301
302 #[serde(default)]
304 pub credential_format: Option<String>,
305
306 #[serde(default)]
308 pub path_pattern: Option<String>,
309
310 #[serde(default)]
312 pub path_replacement: Option<String>,
313
314 #[serde(default)]
316 pub query_param_name: Option<String>,
317}
318
319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
326pub struct EndpointRule {
327 pub method: String,
329 pub path: String,
332}
333
334pub struct CompiledEndpointRules {
340 rules: Vec<CompiledRule>,
341}
342
343struct CompiledRule {
344 method: String,
345 matcher: globset::GlobMatcher,
346}
347
348impl CompiledEndpointRules {
349 pub fn compile(rules: &[EndpointRule]) -> Result<Self, String> {
352 let mut compiled = Vec::with_capacity(rules.len());
353 for rule in rules {
354 let glob = Glob::new(&rule.path)
355 .map_err(|e| format!("invalid endpoint path pattern '{}': {}", rule.path, e))?;
356 compiled.push(CompiledRule {
357 method: rule.method.clone(),
358 matcher: glob.compile_matcher(),
359 });
360 }
361 Ok(Self { rules: compiled })
362 }
363
364 #[must_use]
366 pub fn is_empty(&self) -> bool {
367 self.rules.is_empty()
368 }
369
370 #[must_use]
372 pub fn is_allowed(&self, method: &str, path: &str) -> bool {
373 if self.rules.is_empty() {
374 return true;
375 }
376 let normalized = normalize_path(path);
377 self.rules.iter().any(|r| {
378 (r.method == "*" || r.method.eq_ignore_ascii_case(method))
379 && r.matcher.is_match(&normalized)
380 })
381 }
382}
383
384impl std::fmt::Debug for CompiledEndpointRules {
385 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
386 f.debug_struct("CompiledEndpointRules")
387 .field("count", &self.rules.len())
388 .finish()
389 }
390}
391
392#[cfg(test)]
398fn endpoint_allowed(rules: &[EndpointRule], method: &str, path: &str) -> bool {
399 if rules.is_empty() {
400 return true;
401 }
402 let normalized = normalize_path(path);
403 rules.iter().any(|r| {
404 (r.method == "*" || r.method.eq_ignore_ascii_case(method))
405 && Glob::new(&r.path)
406 .ok()
407 .map(|g| g.compile_matcher())
408 .is_some_and(|m| m.is_match(&normalized))
409 })
410}
411
412fn normalize_path(path: &str) -> String {
418 let path = path.split('?').next().unwrap_or(path);
420
421 let binary = urlencoding::decode_binary(path.as_bytes());
425 let decoded = String::from_utf8_lossy(&binary);
426
427 let segments: Vec<&str> = decoded.split('/').filter(|s| !s.is_empty()).collect();
430 if segments.is_empty() {
431 "/".to_string()
432 } else {
433 format!("/{}", segments.join("/"))
434 }
435}
436
437fn default_inject_header() -> String {
438 "Authorization".to_string()
439}
440
441#[must_use]
445pub fn resolved_credential_format(inject_header: &str, credential_format: Option<&str>) -> String {
446 match credential_format {
447 Some(fmt) => fmt.to_string(),
448 None => {
449 if inject_header.eq_ignore_ascii_case("Authorization") {
450 "Bearer {}".to_string()
451 } else {
452 "{}".to_string()
453 }
454 }
455 }
456}
457
458#[derive(Debug, Clone, Serialize, Deserialize)]
460pub struct ExternalProxyConfig {
461 pub address: String,
463
464 pub auth: Option<ExternalProxyAuth>,
466
467 #[serde(default)]
471 pub bypass_hosts: Vec<String>,
472}
473
474#[derive(Debug, Clone, Serialize, Deserialize)]
476pub struct ExternalProxyAuth {
477 pub keyring_account: String,
479
480 #[serde(default = "default_auth_scheme")]
482 pub scheme: String,
483}
484
485fn default_auth_scheme() -> String {
486 "basic".to_string()
487}
488
489#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
499pub struct OAuth2Config {
500 pub token_url: String,
502 pub client_id: String,
504 pub client_secret: String,
506 #[serde(default)]
508 pub scope: String,
509}
510
511#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
520#[serde(deny_unknown_fields)]
521pub struct AwsAuthConfig {
522 #[serde(default)]
527 pub profile: Option<String>,
528
529 #[serde(default)]
534 pub region: Option<String>,
535
536 #[serde(default)]
541 pub service: Option<String>,
542}
543
544#[cfg(test)]
545#[allow(clippy::unwrap_used)]
546mod tests {
547 use super::*;
548
549 #[test]
550 fn test_default_config() {
551 let config = ProxyConfig::default();
552 assert_eq!(config.bind_addr, IpAddr::V4(std::net::Ipv4Addr::LOCALHOST));
553 assert_eq!(config.bind_port, 0);
554 assert!(config.allowed_hosts.is_empty());
555 assert!(config.routes.is_empty());
556 assert!(config.external_proxy.is_none());
557 }
558
559 #[test]
560 fn test_config_serialization() {
561 let config = ProxyConfig {
562 allowed_hosts: vec!["api.openai.com".to_string()],
563 ..Default::default()
564 };
565 let json = serde_json::to_string(&config).unwrap();
566 let deserialized: ProxyConfig = serde_json::from_str(&json).unwrap();
567 assert_eq!(deserialized.allowed_hosts, vec!["api.openai.com"]);
568 }
569
570 #[test]
571 fn test_external_proxy_config_with_bypass_hosts() {
572 let config = ProxyConfig {
573 external_proxy: Some(ExternalProxyConfig {
574 address: "squid.corp:3128".to_string(),
575 auth: None,
576 bypass_hosts: vec!["internal.corp".to_string(), "*.private.net".to_string()],
577 }),
578 ..Default::default()
579 };
580 let json = serde_json::to_string(&config).unwrap();
581 let deserialized: ProxyConfig = serde_json::from_str(&json).unwrap();
582 let ext = deserialized.external_proxy.unwrap();
583 assert_eq!(ext.address, "squid.corp:3128");
584 assert_eq!(ext.bypass_hosts.len(), 2);
585 assert_eq!(ext.bypass_hosts[0], "internal.corp");
586 assert_eq!(ext.bypass_hosts[1], "*.private.net");
587 }
588
589 #[test]
590 fn test_external_proxy_config_bypass_hosts_default_empty() {
591 let json = r#"{"address": "proxy:3128", "auth": null}"#;
592 let ext: ExternalProxyConfig = serde_json::from_str(json).unwrap();
593 assert!(ext.bypass_hosts.is_empty());
594 }
595
596 #[test]
601 fn test_endpoint_allowed_empty_rules_allows_all() {
602 assert!(endpoint_allowed(&[], "GET", "/anything"));
603 assert!(endpoint_allowed(&[], "DELETE", "/admin/nuke"));
604 }
605
606 fn check(rule: &EndpointRule, method: &str, path: &str) -> bool {
608 endpoint_allowed(std::slice::from_ref(rule), method, path)
609 }
610
611 #[test]
612 fn test_endpoint_rule_exact_path() {
613 let rule = EndpointRule {
614 method: "GET".to_string(),
615 path: "/v1/chat/completions".to_string(),
616 };
617 assert!(check(&rule, "GET", "/v1/chat/completions"));
618 assert!(!check(&rule, "GET", "/v1/chat"));
619 assert!(!check(&rule, "GET", "/v1/chat/completions/extra"));
620 }
621
622 #[test]
623 fn test_endpoint_rule_method_case_insensitive() {
624 let rule = EndpointRule {
625 method: "get".to_string(),
626 path: "/api".to_string(),
627 };
628 assert!(check(&rule, "GET", "/api"));
629 assert!(check(&rule, "Get", "/api"));
630 }
631
632 #[test]
633 fn test_endpoint_rule_method_wildcard() {
634 let rule = EndpointRule {
635 method: "*".to_string(),
636 path: "/api/resource".to_string(),
637 };
638 assert!(check(&rule, "GET", "/api/resource"));
639 assert!(check(&rule, "DELETE", "/api/resource"));
640 assert!(check(&rule, "POST", "/api/resource"));
641 }
642
643 #[test]
644 fn test_endpoint_rule_method_mismatch() {
645 let rule = EndpointRule {
646 method: "GET".to_string(),
647 path: "/api/resource".to_string(),
648 };
649 assert!(!check(&rule, "POST", "/api/resource"));
650 assert!(!check(&rule, "DELETE", "/api/resource"));
651 }
652
653 #[test]
654 fn test_endpoint_rule_single_wildcard() {
655 let rule = EndpointRule {
656 method: "GET".to_string(),
657 path: "/api/v4/projects/*/merge_requests".to_string(),
658 };
659 assert!(check(&rule, "GET", "/api/v4/projects/123/merge_requests"));
660 assert!(check(
661 &rule,
662 "GET",
663 "/api/v4/projects/my-proj/merge_requests"
664 ));
665 assert!(!check(&rule, "GET", "/api/v4/projects/merge_requests"));
666 }
667
668 #[test]
669 fn test_endpoint_rule_double_wildcard() {
670 let rule = EndpointRule {
671 method: "GET".to_string(),
672 path: "/api/v4/projects/**".to_string(),
673 };
674 assert!(check(&rule, "GET", "/api/v4/projects/123"));
675 assert!(check(&rule, "GET", "/api/v4/projects/123/merge_requests"));
676 assert!(check(&rule, "GET", "/api/v4/projects/a/b/c/d"));
677 assert!(!check(&rule, "GET", "/api/v4/other"));
678 }
679
680 #[test]
681 fn test_endpoint_rule_double_wildcard_middle() {
682 let rule = EndpointRule {
683 method: "*".to_string(),
684 path: "/api/**/notes".to_string(),
685 };
686 assert!(check(&rule, "GET", "/api/notes"));
687 assert!(check(&rule, "POST", "/api/projects/123/notes"));
688 assert!(check(&rule, "GET", "/api/a/b/c/notes"));
689 assert!(!check(&rule, "GET", "/api/a/b/c/comments"));
690 }
691
692 #[test]
693 fn test_endpoint_rule_strips_query_string() {
694 let rule = EndpointRule {
695 method: "GET".to_string(),
696 path: "/api/data".to_string(),
697 };
698 assert!(check(&rule, "GET", "/api/data?page=1&limit=10"));
699 }
700
701 #[test]
702 fn test_endpoint_rule_trailing_slash_normalized() {
703 let rule = EndpointRule {
704 method: "GET".to_string(),
705 path: "/api/data".to_string(),
706 };
707 assert!(check(&rule, "GET", "/api/data/"));
708 assert!(check(&rule, "GET", "/api/data"));
709 }
710
711 #[test]
712 fn test_endpoint_rule_double_slash_normalized() {
713 let rule = EndpointRule {
714 method: "GET".to_string(),
715 path: "/api/data".to_string(),
716 };
717 assert!(check(&rule, "GET", "/api//data"));
718 }
719
720 #[test]
721 fn test_endpoint_rule_root_path() {
722 let rule = EndpointRule {
723 method: "GET".to_string(),
724 path: "/".to_string(),
725 };
726 assert!(check(&rule, "GET", "/"));
727 assert!(!check(&rule, "GET", "/anything"));
728 }
729
730 #[test]
731 fn test_compiled_endpoint_rules_hot_path() {
732 let rules = vec![
733 EndpointRule {
734 method: "GET".to_string(),
735 path: "/repos/*/issues".to_string(),
736 },
737 EndpointRule {
738 method: "POST".to_string(),
739 path: "/repos/*/issues/*/comments".to_string(),
740 },
741 ];
742 let compiled = CompiledEndpointRules::compile(&rules).unwrap();
743 assert!(compiled.is_allowed("GET", "/repos/myrepo/issues"));
744 assert!(compiled.is_allowed("POST", "/repos/myrepo/issues/42/comments"));
745 assert!(!compiled.is_allowed("DELETE", "/repos/myrepo"));
746 assert!(!compiled.is_allowed("GET", "/repos/myrepo/pulls"));
747 }
748
749 #[test]
750 fn test_compiled_endpoint_rules_empty_allows_all() {
751 let compiled = CompiledEndpointRules::compile(&[]).unwrap();
752 assert!(compiled.is_allowed("DELETE", "/admin/nuke"));
753 }
754
755 #[test]
756 fn test_compiled_endpoint_rules_invalid_pattern_rejected() {
757 let rules = vec![EndpointRule {
758 method: "GET".to_string(),
759 path: "/api/[invalid".to_string(),
760 }];
761 assert!(CompiledEndpointRules::compile(&rules).is_err());
762 }
763
764 #[test]
765 fn test_endpoint_allowed_multiple_rules() {
766 let rules = vec![
767 EndpointRule {
768 method: "GET".to_string(),
769 path: "/repos/*/issues".to_string(),
770 },
771 EndpointRule {
772 method: "POST".to_string(),
773 path: "/repos/*/issues/*/comments".to_string(),
774 },
775 ];
776 assert!(endpoint_allowed(&rules, "GET", "/repos/myrepo/issues"));
777 assert!(endpoint_allowed(
778 &rules,
779 "POST",
780 "/repos/myrepo/issues/42/comments"
781 ));
782 assert!(!endpoint_allowed(&rules, "DELETE", "/repos/myrepo"));
783 assert!(!endpoint_allowed(&rules, "GET", "/repos/myrepo/pulls"));
784 }
785
786 #[test]
787 fn test_endpoint_rule_serde_default() {
788 let json = r#"{
789 "prefix": "test",
790 "upstream": "https://example.com"
791 }"#;
792 let route: RouteConfig = serde_json::from_str(json).unwrap();
793 assert!(route.endpoint_rules.is_empty());
794 assert!(route.tls_ca.is_none());
795 }
796
797 #[test]
798 fn test_tls_ca_serde_roundtrip() {
799 let json = r#"{
800 "prefix": "k8s",
801 "upstream": "https://kubernetes.local:6443",
802 "tls_ca": "/run/secrets/k8s-ca.crt"
803 }"#;
804 let route: RouteConfig = serde_json::from_str(json).unwrap();
805 assert_eq!(route.tls_ca.as_deref(), Some("/run/secrets/k8s-ca.crt"));
806
807 let serialized = serde_json::to_string(&route).unwrap();
808 let deserialized: RouteConfig = serde_json::from_str(&serialized).unwrap();
809 assert_eq!(
810 deserialized.tls_ca.as_deref(),
811 Some("/run/secrets/k8s-ca.crt")
812 );
813 }
814
815 #[test]
816 fn test_endpoint_rule_percent_encoded_path_decoded() {
817 let rule = EndpointRule {
820 method: "GET".to_string(),
821 path: "/api/v4/projects/*/issues".to_string(),
822 };
823 assert!(check(&rule, "GET", "/api/v4/%70rojects/123/issues"));
824 assert!(check(&rule, "GET", "/api/v4/pro%6Aects/123/issues"));
825 }
826
827 #[test]
828 fn test_endpoint_rule_percent_encoded_full_segment() {
829 let rule = EndpointRule {
830 method: "POST".to_string(),
831 path: "/api/data".to_string(),
832 };
833 assert!(check(&rule, "POST", "/api/%64%61%74%61"));
835 }
836
837 #[test]
838 fn test_compiled_endpoint_rules_percent_encoded() {
839 let rules = vec![EndpointRule {
840 method: "GET".to_string(),
841 path: "/repos/*/issues".to_string(),
842 }];
843 let compiled = CompiledEndpointRules::compile(&rules).unwrap();
844 assert!(compiled.is_allowed("GET", "/repos/myrepo/%69ssues"));
846 assert!(!compiled.is_allowed("GET", "/repos/myrepo/%70ulls"));
847 }
848
849 #[test]
850 fn test_endpoint_rule_percent_encoded_invalid_utf8() {
851 let rule = EndpointRule {
855 method: "GET".to_string(),
856 path: "/api/projects".to_string(),
857 };
858 assert!(!check(&rule, "GET", "/api/%FFprojects"));
860 }
861
862 #[test]
863 fn test_endpoint_rule_serde_roundtrip() {
864 let rule = EndpointRule {
865 method: "GET".to_string(),
866 path: "/api/*/data".to_string(),
867 };
868 let json = serde_json::to_string(&rule).unwrap();
869 let deserialized: EndpointRule = serde_json::from_str(&json).unwrap();
870 assert_eq!(deserialized.method, "GET");
871 assert_eq!(deserialized.path, "/api/*/data");
872 }
873
874 #[test]
879 fn test_oauth2_config_deserialization() {
880 let json = r#"{
881 "token_url": "https://auth.example.com/oauth/token",
882 "client_id": "my-client",
883 "client_secret": "env://CLIENT_SECRET",
884 "scope": "read write"
885 }"#;
886 let config: OAuth2Config = serde_json::from_str(json).unwrap();
887 assert_eq!(config.token_url, "https://auth.example.com/oauth/token");
888 assert_eq!(config.client_id, "my-client");
889 assert_eq!(config.client_secret, "env://CLIENT_SECRET");
890 assert_eq!(config.scope, "read write");
891 }
892
893 #[test]
894 fn test_oauth2_config_default_scope() {
895 let json = r#"{
896 "token_url": "https://auth.example.com/oauth/token",
897 "client_id": "my-client",
898 "client_secret": "env://SECRET"
899 }"#;
900 let config: OAuth2Config = serde_json::from_str(json).unwrap();
901 assert_eq!(config.scope, "");
902 }
903
904 #[test]
905 fn test_route_config_with_oauth2() {
906 let json = r#"{
907 "prefix": "/my-api",
908 "upstream": "https://api.example.com",
909 "oauth2": {
910 "token_url": "https://auth.example.com/oauth/token",
911 "client_id": "agent-1",
912 "client_secret": "env://CLIENT_SECRET",
913 "scope": "api.read"
914 }
915 }"#;
916 let route: RouteConfig = serde_json::from_str(json).unwrap();
917 assert!(route.oauth2.is_some());
918 assert!(route.credential_key.is_none());
919 let oauth2 = route.oauth2.unwrap();
920 assert_eq!(oauth2.token_url, "https://auth.example.com/oauth/token");
921 }
922
923 #[test]
924 fn test_route_config_without_oauth2() {
925 let json = r#"{
926 "prefix": "/openai",
927 "upstream": "https://api.openai.com",
928 "credential_key": "openai"
929 }"#;
930 let route: RouteConfig = serde_json::from_str(json).unwrap();
931 assert!(route.oauth2.is_none());
932 assert!(route.credential_key.is_some());
933 }
934
935 #[test]
936 fn test_route_config_credential_format_omitted_is_none() {
937 let json = r#"{
938 "prefix": "anthropic",
939 "upstream": "https://api.anthropic.com",
940 "credential_key": "env://ANTHROPIC_API_KEY",
941 "inject_header": "x-api-key"
942 }"#;
943 let route: RouteConfig = serde_json::from_str(json).unwrap();
944 assert!(route.credential_format.is_none());
945 assert_eq!(
946 resolved_credential_format(&route.inject_header, route.credential_format.as_deref()),
947 "{}"
948 );
949 }
950
951 #[test]
952 fn test_route_config_explicit_bearer_on_custom_header_preserved() {
953 let json = r#"{
954 "prefix": "litellm",
955 "upstream": "https://litellm",
956 "credential_key": "env://LITELLM_TOKEN",
957 "inject_header": "x-litellm-api-key",
958 "credential_format": "Bearer {}"
959 }"#;
960 let route: RouteConfig = serde_json::from_str(json).unwrap();
961 assert_eq!(route.credential_format.as_deref(), Some("Bearer {}"));
962 assert_eq!(
963 resolved_credential_format(&route.inject_header, route.credential_format.as_deref()),
964 "Bearer {}"
965 );
966 }
967
968 #[test]
969 fn test_resolved_credential_format_authorization_case_insensitive() {
970 for header in ["authorization", "AUTHORIZATION", "Authorization"] {
971 assert_eq!(
972 resolved_credential_format(header, None),
973 "Bearer {}",
974 "omitted format: Authorization header name is matched case-insensitively for Bearer default"
975 );
976 }
977 }
978
979 #[test]
984 fn test_aws_auth_config_minimal_deserializes() {
985 let json = r#"{}"#;
986 let aws: AwsAuthConfig = serde_json::from_str(json).unwrap();
987 assert!(aws.profile.is_none());
988 assert!(aws.region.is_none());
989 assert!(aws.service.is_none());
990 }
991
992 #[test]
993 fn test_aws_auth_config_all_fields_roundtrip() {
994 let original = AwsAuthConfig {
995 profile: Some("my-aws-profile".to_string()),
996 region: Some("us-east-1".to_string()),
997 service: Some("bedrock".to_string()),
998 };
999 let json = serde_json::to_string(&original).unwrap();
1000 let deserialized: AwsAuthConfig = serde_json::from_str(&json).unwrap();
1001 assert_eq!(deserialized.profile.as_deref(), Some("my-aws-profile"));
1002 assert_eq!(deserialized.region.as_deref(), Some("us-east-1"));
1003 assert_eq!(deserialized.service.as_deref(), Some("bedrock"));
1004 }
1005
1006 #[test]
1007 fn test_aws_auth_field_absent_is_none() {
1008 let json = r#"{"prefix": "bedrock", "upstream": "https://bedrock-runtime.us-east-1.amazonaws.com"}"#;
1009 let route: RouteConfig = serde_json::from_str(json).unwrap();
1010 assert!(route.aws_auth.is_none());
1011 }
1012
1013 #[test]
1014 fn test_aws_auth_config_unknown_field_rejected() {
1015 let json = r#"{"profile": "foo", "unknown_field": "bar"}"#;
1016 let result: std::result::Result<AwsAuthConfig, _> = serde_json::from_str(json);
1017 assert!(
1018 result.is_err(),
1019 "unknown fields must be rejected by deny_unknown_fields"
1020 );
1021 }
1022
1023 #[test]
1024 fn test_route_config_with_aws_auth_deserializes() {
1025 let json = r#"{
1026 "prefix": "bedrock",
1027 "upstream": "https://bedrock-runtime.us-east-1.amazonaws.com",
1028 "aws_auth": {
1029 "profile": "my-aws-profile"
1030 }
1031 }"#;
1032 let route: RouteConfig = serde_json::from_str(json).unwrap();
1033 let aws = route.aws_auth.unwrap();
1034 assert_eq!(aws.profile.as_deref(), Some("my-aws-profile"));
1035 assert!(aws.region.is_none());
1036 assert!(aws.service.is_none());
1037 }
1038
1039 #[test]
1040 fn test_route_config_with_full_aws_auth_deserializes() {
1041 let json = r#"{
1042 "prefix": "bedrock",
1043 "upstream": "https://bedrock-runtime.us-east-1.amazonaws.com",
1044 "aws_auth": {
1045 "profile": "my-aws-profile",
1046 "region": "us-west-2",
1047 "service": "bedrock"
1048 }
1049 }"#;
1050 let route: RouteConfig = serde_json::from_str(json).unwrap();
1051 let aws = route.aws_auth.unwrap();
1052 assert_eq!(aws.profile.as_deref(), Some("my-aws-profile"));
1053 assert_eq!(aws.region.as_deref(), Some("us-west-2"));
1054 assert_eq!(aws.service.as_deref(), Some("bedrock"));
1055 }
1056
1057 #[test]
1058 fn test_aws_auth_empty_object_sets_all_none() {
1059 let json = r#"{
1060 "prefix": "bedrock",
1061 "upstream": "https://bedrock-runtime.us-east-1.amazonaws.com",
1062 "aws_auth": {}
1063 }"#;
1064 let route: RouteConfig = serde_json::from_str(json).unwrap();
1065 let aws = route.aws_auth.unwrap();
1066 assert!(aws.profile.is_none());
1067 assert!(aws.region.is_none());
1068 assert!(aws.service.is_none());
1069 }
1070}