1use serde::Deserialize;
7use std::path::PathBuf;
8
9#[derive(Debug, Clone, Deserialize)]
17pub struct ProxyConfig {
18 pub upstream: UpstreamConfig,
20
21 #[serde(default, deserialize_with = "deserialize_descriptor_sources")]
23 pub descriptors: Vec<DescriptorSource>,
24
25 #[serde(default)]
27 pub listen: ListenConfig,
28
29 #[serde(default)]
31 pub service: ServiceConfig,
32
33 #[serde(default)]
35 pub aliases: Vec<AliasConfig>,
36
37 #[serde(default)]
39 pub openapi: Option<OpenApiConfig>,
40
41 #[serde(default)]
43 pub auth: Option<AuthConfig>,
44
45 #[serde(default)]
47 pub shield: Option<ShieldConfig>,
48
49 #[serde(default)]
51 pub oidc_discovery: Option<OidcDiscoveryConfig>,
52
53 #[serde(default)]
55 pub health: HealthConfig,
56
57 #[serde(default)]
59 pub metrics: MetricsConfig,
60
61 #[serde(default)]
63 pub maintenance: MaintenanceConfig,
64
65 #[serde(default)]
67 pub cors: CorsConfig,
68
69 #[serde(default)]
71 pub logging: LoggingConfig,
72
73 #[serde(default)]
75 pub metrics_classes: Vec<MetricsClassConfig>,
76
77 #[serde(default = "default_forwarded_headers")]
79 pub forwarded_headers: Vec<String>,
80
81 #[serde(default)]
83 pub streaming: StreamingConfig,
84}
85
86fn default_forwarded_headers() -> Vec<String> {
87 vec![
88 "authorization".into(),
89 "dpop".into(),
90 "x-request-id".into(),
91 "x-forwarded-for".into(),
92 "x-forwarded-proto".into(),
93 "x-real-ip".into(),
94 "accept-language".into(),
95 "user-agent".into(),
96 "idempotency-key".into(),
97 ]
98}
99
100#[derive(Debug, Clone, Deserialize)]
106pub struct StreamingConfig {
107 #[serde(default = "default_sse_keep_alive_secs")]
111 pub sse_keep_alive_secs: u64,
112}
113
114fn default_sse_keep_alive_secs() -> u64 {
115 15
116}
117
118impl Default for StreamingConfig {
119 fn default() -> Self {
120 Self {
121 sse_keep_alive_secs: default_sse_keep_alive_secs(),
122 }
123 }
124}
125
126#[derive(Debug, Default, Deserialize)]
148pub(crate) struct TranscodeFileConfig {
149 #[serde(default)]
150 error_details: Option<ErrorDetailsFileConfig>,
151 #[serde(default)]
152 streaming: StreamingFileConfig,
153 #[serde(default)]
154 response_headers: Option<ResponseHeadersFileConfig>,
155}
156
157#[derive(Debug, Deserialize)]
160#[serde(deny_unknown_fields)]
161struct ResponseHeadersFileConfig {
162 #[serde(default)]
164 deny: Vec<String>,
165}
166
167#[derive(Debug, Deserialize)]
170#[serde(deny_unknown_fields)]
171struct ErrorDetailsFileConfig {
172 #[serde(default = "default_true")]
174 enabled: bool,
175 #[serde(default)]
178 opaque: bool,
179 #[serde(default)]
182 routes: Vec<ErrorDetailsRouteFileConfig>,
183}
184
185#[derive(Debug, Deserialize)]
187#[serde(deny_unknown_fields)]
188struct ErrorDetailsRouteFileConfig {
189 pattern: String,
190 #[serde(default)]
191 enabled: Option<bool>,
192 #[serde(default)]
193 opaque: Option<bool>,
194}
195
196#[derive(Debug, Default, Deserialize)]
200struct StreamingFileConfig {
201 #[serde(default)]
202 ndjson_envelope: bool,
203}
204
205pub(crate) const KNOWN_TOP_LEVEL_KEYS: &[&str] = &[
208 "upstream",
209 "descriptors",
210 "listen",
211 "service",
212 "aliases",
213 "openapi",
214 "auth",
215 "shield",
216 "oidc_discovery",
217 "health",
218 "metrics",
219 "maintenance",
220 "cors",
221 "logging",
222 "metrics_classes",
223 "forwarded_headers",
224 "streaming",
225 "error_details",
226 "response_headers",
227];
228
229pub(crate) const KNOWN_STREAMING_KEYS: &[&str] = &["sse_keep_alive_secs", "ndjson_envelope"];
232
233pub(crate) fn unknown_config_keys(yaml: &str) -> Vec<String> {
238 let Ok(serde_yaml::Value::Mapping(map)) = serde_yaml::from_str(yaml) else {
239 return Vec::new();
240 };
241 let mut unknown: Vec<String> = map
242 .keys()
243 .filter_map(|key| key.as_str())
244 .filter(|key| !KNOWN_TOP_LEVEL_KEYS.contains(key))
245 .map(str::to_owned)
246 .collect();
247 if let Some(serde_yaml::Value::Mapping(streaming)) = map.get("streaming") {
250 unknown.extend(
251 streaming
252 .keys()
253 .filter_map(|key| key.as_str())
254 .filter(|key| !KNOWN_STREAMING_KEYS.contains(key))
255 .map(|key| format!("streaming.{key}")),
256 );
257 }
258 unknown
259}
260
261impl TranscodeFileConfig {
262 pub(crate) fn options(&self) -> Result<crate::transcode::TranscodeOptions, String> {
270 use crate::transcode::error::ErrorDetailsPolicy;
271 let mut options = crate::transcode::TranscodeOptions::default()
272 .with_ndjson_envelope(self.streaming.ndjson_envelope);
273 if let Some(cfg) = &self.response_headers {
274 let deny = cfg
275 .deny
276 .iter()
277 .map(|name| {
278 http::HeaderName::from_bytes(name.as_bytes()).map_err(|_| {
279 format!("response_headers.deny entry {name:?} is not a header name")
280 })
281 })
282 .collect::<Result<Vec<_>, _>>()?;
283 options = options.with_denied_response_headers(deny);
284 }
285 if let Some(cfg) = &self.error_details {
286 let base = if cfg.enabled {
287 ErrorDetailsPolicy::default()
288 } else {
289 ErrorDetailsPolicy::disabled()
290 }
291 .with_opaque_details(cfg.opaque);
292 let policy = cfg.routes.iter().try_fold(base, |policy, rule| {
293 if rule.enabled.is_none() && rule.opaque.is_none() {
296 return Err(format!(
297 "error details route {:?} sets neither enabled nor opaque",
298 rule.pattern
299 ));
300 }
301 policy.rule(&rule.pattern, rule.enabled, rule.opaque)
302 })?;
303 options = options.with_error_details(policy);
304 }
305 Ok(options)
306 }
307}
308
309#[derive(Debug, Clone, Deserialize)]
311pub struct UpstreamConfig {
312 pub default: String,
314}
315
316#[derive(Debug, Clone)]
318pub enum DescriptorSource {
319 File { file: PathBuf },
321 Reflection { reflection: String },
323 Embedded { bytes: &'static [u8] },
325}
326
327#[derive(Debug, Clone, Deserialize)]
329#[serde(untagged)]
330enum DescriptorSourceYaml {
331 File { file: PathBuf },
332 Reflection { reflection: String },
333}
334
335impl From<DescriptorSourceYaml> for DescriptorSource {
336 fn from(yaml: DescriptorSourceYaml) -> Self {
337 match yaml {
338 DescriptorSourceYaml::File { file } => DescriptorSource::File { file },
339 DescriptorSourceYaml::Reflection { reflection } => {
340 DescriptorSource::Reflection { reflection }
341 }
342 }
343 }
344}
345
346fn deserialize_descriptor_sources<'de, D>(
347 deserializer: D,
348) -> std::result::Result<Vec<DescriptorSource>, D::Error>
349where
350 D: serde::Deserializer<'de>,
351{
352 let yaml_sources: Vec<DescriptorSourceYaml> = Vec::deserialize(deserializer)?;
353 Ok(yaml_sources.into_iter().map(Into::into).collect())
354}
355
356#[derive(Debug, Clone, Deserialize)]
358pub struct ListenConfig {
359 #[serde(default = "default_http_listen")]
361 pub http: String,
362}
363
364fn default_http_listen() -> String {
365 "0.0.0.0:8080".into()
366}
367
368impl Default for ListenConfig {
369 fn default() -> Self {
370 Self {
371 http: default_http_listen(),
372 }
373 }
374}
375
376#[derive(Debug, Clone, Deserialize)]
378pub struct ServiceConfig {
379 #[serde(default = "default_service_name")]
381 pub name: String,
382}
383
384fn default_service_name() -> String {
385 "structured-proxy".into()
386}
387
388impl Default for ServiceConfig {
389 fn default() -> Self {
390 Self {
391 name: default_service_name(),
392 }
393 }
394}
395
396#[derive(Debug, Clone, Deserialize)]
398#[non_exhaustive]
399pub struct AliasConfig {
400 pub from: String,
401 pub to: String,
402}
403
404#[derive(Debug, Clone, Deserialize)]
406#[non_exhaustive]
407pub struct OpenApiConfig {
408 #[serde(default = "default_true")]
409 pub enabled: bool,
410 #[serde(default = "default_openapi_path")]
412 pub path: String,
413 #[serde(default = "default_docs_path")]
415 pub docs_path: String,
416 #[serde(default)]
417 pub title: Option<String>,
418 #[serde(default)]
419 pub version: Option<String>,
420}
421
422fn default_openapi_path() -> String {
423 "/openapi.json".into()
424}
425
426fn default_docs_path() -> String {
427 "/docs".into()
428}
429
430fn default_true() -> bool {
431 true
432}
433
434#[derive(Debug, Clone, Deserialize)]
436#[non_exhaustive]
437pub struct AuthConfig {
438 #[serde(default = "default_auth_mode")]
440 pub mode: String,
441
442 #[serde(default)]
444 pub jwt: Option<JwtConfig>,
445
446 #[serde(default)]
448 pub forward_auth: Option<ForwardAuthConfig>,
449
450 #[serde(default)]
452 pub authz: Option<AuthzConfig>,
453}
454
455fn default_auth_mode() -> String {
456 "none".into()
457}
458
459#[derive(Debug, Clone, Deserialize)]
461#[non_exhaustive]
462pub struct JwtConfig {
463 #[serde(default)]
465 pub jwks_uri: Option<String>,
466 #[serde(default = "default_jwks_max_age_secs")]
470 pub jwks_max_age_secs: u64,
471 #[serde(default)]
473 pub issuer: Option<String>,
474 #[serde(default)]
476 pub audience: Option<String>,
477 #[serde(default)]
479 pub public_key_pem_file: Option<PathBuf>,
480 #[serde(default)]
482 pub claims_headers: std::collections::HashMap<String, String>,
483 #[serde(default = "default_roles_claim")]
486 pub roles_claim: String,
487 #[serde(default)]
490 pub cache: JwtCacheConfig,
491}
492
493#[derive(Debug, Clone, Deserialize)]
497#[serde(deny_unknown_fields)]
498#[non_exhaustive]
499pub struct JwtCacheConfig {
500 #[serde(default = "default_true")]
502 pub enabled: bool,
503 #[serde(default = "default_jwt_cache_max_entries")]
506 pub max_entries: usize,
507 #[serde(default = "default_jwt_cache_max_ttl_secs")]
511 pub max_ttl_secs: u64,
512 #[serde(default = "default_jwt_cache_max_token_bytes")]
516 pub max_token_bytes: usize,
517}
518
519pub(crate) fn default_jwks_max_age_secs() -> u64 {
521 300
522}
523
524fn default_jwt_cache_max_entries() -> usize {
525 10_000
526}
527
528fn default_jwt_cache_max_ttl_secs() -> u64 {
529 60
530}
531
532fn default_jwt_cache_max_token_bytes() -> usize {
533 4096
534}
535
536impl Default for JwtCacheConfig {
537 fn default() -> Self {
538 Self {
539 enabled: true,
540 max_entries: default_jwt_cache_max_entries(),
541 max_ttl_secs: default_jwt_cache_max_ttl_secs(),
542 max_token_bytes: default_jwt_cache_max_token_bytes(),
543 }
544 }
545}
546
547pub(crate) fn default_roles_claim() -> String {
550 "roles".into()
551}
552
553#[derive(Debug, Clone, Deserialize)]
555#[non_exhaustive]
556pub struct ForwardAuthConfig {
557 #[serde(default)]
558 pub enabled: bool,
559 #[serde(default = "default_forward_auth_path")]
560 pub path: String,
561 #[serde(default)]
563 pub policies: Vec<RoutePolicyConfig>,
564 #[serde(default)]
566 pub login_url: Option<String>,
567 #[serde(default)]
569 pub applications_path: Option<PathBuf>,
570}
571
572fn default_forward_auth_path() -> String {
573 "/auth/verify".into()
574}
575
576#[derive(Debug, Clone, Deserialize)]
578#[non_exhaustive]
579pub struct RoutePolicyConfig {
580 pub path: String,
581 #[serde(default = "default_methods_all")]
582 pub methods: Vec<String>,
583 #[serde(default)]
584 pub require_auth: bool,
585 #[serde(default)]
586 pub required_roles: Vec<String>,
587}
588
589fn default_methods_all() -> Vec<String> {
590 vec!["*".into()]
591}
592
593#[derive(Debug, Clone, Deserialize)]
597#[non_exhaustive]
598pub struct AuthzConfig {
599 #[serde(default)]
601 pub enabled: bool,
602 #[serde(default)]
605 pub endpoint: String,
606 #[serde(default = "default_authz_timeout_ms")]
608 pub timeout_ms: u64,
609 #[serde(default)]
612 pub failure_mode_allow: bool,
613}
614
615fn default_authz_timeout_ms() -> u64 {
616 200
617}
618
619#[derive(Debug, Clone, Deserialize)]
627#[serde(deny_unknown_fields)]
628#[non_exhaustive]
629pub struct ShieldConfig {
630 #[serde(default)]
631 pub enabled: bool,
632 #[serde(default)]
635 pub profiles: std::collections::HashMap<String, LimitProfileConfig>,
636 #[serde(default)]
639 pub rules: Vec<RateRuleConfig>,
640 #[serde(default)]
644 pub default_profile: Option<String>,
645 #[serde(default)]
648 pub jwt_limits: Option<JwtLimitConfig>,
649 #[serde(default)]
652 pub limit_service: Option<LimitServiceConfig>,
653 #[serde(default)]
656 pub sync: Option<SyncConfig>,
657 #[serde(default)]
663 pub trusted_proxies: Vec<String>,
664}
665
666#[derive(Debug, Clone, Deserialize)]
668#[serde(deny_unknown_fields)]
669#[non_exhaustive]
670pub struct LimitProfileConfig {
671 pub rate: String,
674 #[serde(default)]
677 pub burst: Option<u64>,
678}
679
680#[derive(Debug, Clone, Deserialize)]
687#[serde(deny_unknown_fields)]
688#[non_exhaustive]
689pub struct RateRuleConfig {
690 pub pattern: String,
692 #[serde(default)]
694 pub key: KeySourceConfig,
695 #[serde(default)]
698 pub profile: Option<String>,
699}
700
701#[derive(Debug, Clone, Default, PartialEq, Eq)]
706#[non_exhaustive]
707pub enum KeySourceConfig {
708 #[default]
710 Ip,
711 Header {
714 name: String,
716 },
717 JwtClaim {
720 claim: String,
722 },
723}
724
725#[derive(Deserialize)]
732#[serde(deny_unknown_fields, rename_all = "snake_case")]
733struct KeySourceRaw {
734 #[serde(rename = "type")]
735 kind: String,
736 #[serde(default)]
737 name: Option<String>,
738 #[serde(default)]
739 claim: Option<String>,
740}
741
742impl<'de> Deserialize<'de> for KeySourceConfig {
743 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
744 where
745 D: serde::Deserializer<'de>,
746 {
747 use serde::de::Error;
748 let raw = KeySourceRaw::deserialize(deserializer)?;
749 match raw.kind.as_str() {
750 "ip" => {
751 if raw.name.is_some() || raw.claim.is_some() {
752 return Err(D::Error::custom("key type 'ip' takes no other fields"));
753 }
754 Ok(Self::Ip)
755 }
756 "header" => {
757 if raw.claim.is_some() {
758 return Err(D::Error::custom(
759 "key type 'header' takes 'name', not 'claim'",
760 ));
761 }
762 let name = raw.name.ok_or_else(|| D::Error::missing_field("name"))?;
763 Ok(Self::Header { name })
764 }
765 "jwt_claim" => {
766 if raw.name.is_some() {
767 return Err(D::Error::custom(
768 "key type 'jwt_claim' takes 'claim', not 'name'",
769 ));
770 }
771 let claim = raw.claim.ok_or_else(|| D::Error::missing_field("claim"))?;
772 Ok(Self::JwtClaim { claim })
773 }
774 other => Err(D::Error::unknown_variant(
775 other,
776 &["ip", "header", "jwt_claim"],
777 )),
778 }
779 }
780}
781
782#[derive(Debug, Clone, Deserialize)]
786#[serde(deny_unknown_fields)]
787#[non_exhaustive]
788pub struct JwtLimitConfig {
789 #[serde(default = "default_tier_claim")]
791 pub tier_claim: String,
792 #[serde(default = "default_rpm_claim")]
795 pub rpm_claim: String,
796 #[serde(default = "default_burst_claim")]
798 pub burst_claim: String,
799}
800
801fn default_tier_claim() -> String {
802 "ratelimit_tier".to_string()
803}
804fn default_rpm_claim() -> String {
805 "ratelimit_rpm".to_string()
806}
807fn default_burst_claim() -> String {
808 "ratelimit_burst".to_string()
809}
810
811#[derive(Debug, Clone, Deserialize)]
815#[serde(deny_unknown_fields)]
816#[non_exhaustive]
817pub struct LimitServiceConfig {
818 pub endpoint: String,
821 #[serde(default = "default_limit_ttl_secs")]
824 pub ttl_secs: u64,
825 #[serde(default = "default_limit_timeout_ms")]
827 pub timeout_ms: u64,
828}
829
830fn default_limit_ttl_secs() -> u64 {
831 300
832}
833fn default_limit_timeout_ms() -> u64 {
834 500
835}
836
837#[derive(Debug, Clone, Deserialize)]
839#[serde(deny_unknown_fields)]
840#[non_exhaustive]
841pub struct SyncConfig {
842 pub redis_url: String,
845 #[serde(default = "default_sync_interval_ms")]
848 pub interval_ms: u64,
849}
850
851fn default_sync_interval_ms() -> u64 {
852 500
853}
854
855#[derive(Debug, Clone, Deserialize)]
857#[non_exhaustive]
858pub struct OidcDiscoveryConfig {
859 #[serde(default)]
860 pub enabled: bool,
861 pub issuer: String,
862 #[serde(default)]
863 pub authorization_endpoint: Option<String>,
864 #[serde(default)]
865 pub token_endpoint: Option<String>,
866 #[serde(default)]
867 pub userinfo_endpoint: Option<String>,
868 #[serde(default)]
869 pub jwks_uri: Option<String>,
870 #[serde(default)]
871 pub signing_key: Option<SigningKeyConfig>,
872}
873
874#[derive(Debug, Clone, Deserialize)]
876#[non_exhaustive]
877pub struct SigningKeyConfig {
878 #[serde(default = "default_algorithm")]
879 pub algorithm: String,
880 pub public_key_pem_file: PathBuf,
881}
882
883fn default_algorithm() -> String {
884 "EdDSA".into()
885}
886
887#[derive(Debug, Clone, Deserialize)]
893#[non_exhaustive]
894pub struct HealthConfig {
895 #[serde(default = "default_true")]
897 pub enabled: bool,
898 #[serde(default = "default_health_path")]
900 pub path: String,
901 #[serde(default = "default_health_live_path")]
903 pub live_path: String,
904 #[serde(default = "default_health_ready_path")]
906 pub ready_path: String,
907 #[serde(default = "default_health_startup_path")]
909 pub startup_path: String,
910}
911
912fn default_health_path() -> String {
913 "/health".into()
914}
915fn default_health_live_path() -> String {
916 "/health/live".into()
917}
918fn default_health_ready_path() -> String {
919 "/health/ready".into()
920}
921fn default_health_startup_path() -> String {
922 "/health/startup".into()
923}
924
925impl Default for HealthConfig {
926 fn default() -> Self {
927 Self {
928 enabled: true,
929 path: default_health_path(),
930 live_path: default_health_live_path(),
931 ready_path: default_health_ready_path(),
932 startup_path: default_health_startup_path(),
933 }
934 }
935}
936
937#[derive(Debug, Clone, Deserialize)]
939#[non_exhaustive]
940pub struct MetricsConfig {
941 #[serde(default = "default_true")]
943 pub enabled: bool,
944 #[serde(default = "default_metrics_path")]
946 pub path: String,
947}
948
949fn default_metrics_path() -> String {
950 "/metrics".into()
951}
952
953impl Default for MetricsConfig {
954 fn default() -> Self {
955 Self {
956 enabled: true,
957 path: default_metrics_path(),
958 }
959 }
960}
961
962#[derive(Debug, Clone, Deserialize)]
964#[non_exhaustive]
965pub struct MaintenanceConfig {
966 #[serde(default)]
967 pub enabled: bool,
968 #[serde(default = "default_exempt_paths")]
970 pub exempt_paths: Vec<String>,
971 #[serde(default = "default_maintenance_message")]
972 pub message: String,
973}
974
975fn default_exempt_paths() -> Vec<String> {
976 vec![
977 "/health/**".into(),
978 "/.well-known/**".into(),
979 "/metrics".into(),
980 "/auth/verify".into(),
981 ]
982}
983
984fn default_maintenance_message() -> String {
985 "Service is under maintenance. Please try again later.".into()
986}
987
988impl Default for MaintenanceConfig {
989 fn default() -> Self {
990 Self {
991 enabled: false,
992 exempt_paths: default_exempt_paths(),
993 message: default_maintenance_message(),
994 }
995 }
996}
997
998#[derive(Debug, Clone, Default, Deserialize)]
1000#[non_exhaustive]
1001pub struct CorsConfig {
1002 #[serde(default)]
1004 pub origins: Vec<String>,
1005}
1006
1007#[derive(Debug, Clone, Deserialize)]
1009#[non_exhaustive]
1010pub struct LoggingConfig {
1011 #[serde(default = "default_log_level")]
1012 pub level: String,
1013 #[serde(default = "default_log_format")]
1014 pub format: String,
1015}
1016
1017fn default_log_level() -> String {
1018 "info".into()
1019}
1020fn default_log_format() -> String {
1021 "json".into()
1022}
1023
1024impl Default for LoggingConfig {
1025 fn default() -> Self {
1026 Self {
1027 level: default_log_level(),
1028 format: default_log_format(),
1029 }
1030 }
1031}
1032
1033#[derive(Debug, Clone, Deserialize)]
1035#[non_exhaustive]
1036pub struct MetricsClassConfig {
1037 pub pattern: String,
1039 pub class: String,
1041}
1042
1043impl ProxyConfig {
1044 pub fn from_file(path: &std::path::Path) -> anyhow::Result<Self> {
1046 Self::from_yaml_str(&std::fs::read_to_string(path)?)
1047 }
1048
1049 pub fn from_yaml_str(yaml: &str) -> anyhow::Result<Self> {
1054 let config: Self = serde_yaml::from_str(yaml)?;
1055 config.validate()?;
1056 Ok(config)
1057 }
1058
1059 pub fn validate(&self) -> anyhow::Result<()> {
1065 if self.streaming.sse_keep_alive_secs == 0 {
1066 anyhow::bail!("streaming.sse_keep_alive_secs must be greater than 0");
1067 }
1068 self.validate_edge_paths()?;
1069 Ok(())
1070 }
1071
1072 fn validate_edge_paths(&self) -> anyhow::Result<()> {
1077 let mut seen = std::collections::HashSet::new();
1078 let mut check = |label: &str, path: &str| -> anyhow::Result<()> {
1079 if !path.starts_with('/') {
1080 anyhow::bail!("endpoint path {path:?} ({label}) must start with '/'");
1081 }
1082 if !seen.insert(path.to_string()) {
1083 anyhow::bail!("duplicate endpoint path {path:?} ({label}); each built-in endpoint must have a distinct path");
1084 }
1085 Ok(())
1086 };
1087 if self.health.enabled {
1088 check("health.path", &self.health.path)?;
1089 check("health.live_path", &self.health.live_path)?;
1090 check("health.ready_path", &self.health.ready_path)?;
1091 check("health.startup_path", &self.health.startup_path)?;
1092 }
1093 if self.metrics.enabled {
1094 check("metrics.path", &self.metrics.path)?;
1095 }
1096 if let Some(openapi) = self.openapi.as_ref().filter(|o| o.enabled) {
1097 check("openapi.path", &openapi.path)?;
1098 check("openapi.docs_path", &openapi.docs_path)?;
1099 }
1100 Ok(())
1101 }
1102
1103 pub fn parse_rate(rate: &str) -> Option<u32> {
1105 let parts: Vec<&str> = rate.split('/').collect();
1106 if parts.len() != 2 {
1107 return None;
1108 }
1109 parts[0].trim().parse().ok()
1110 }
1111}
1112
1113#[cfg(test)]
1114mod tests;