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] = &[
209 "upstream",
210 "descriptors",
211 "listen",
212 "service",
213 "aliases",
214 "openapi",
215 "auth",
216 "shield",
217 "oidc_discovery",
218 "health",
219 "metrics",
220 "maintenance",
221 "cors",
222 "logging",
223 "metrics_classes",
224 "forwarded_headers",
225 "streaming",
226 "error_details",
227 "response_headers",
228 "runtime",
229];
230
231pub(crate) const KNOWN_STREAMING_KEYS: &[&str] = &["sse_keep_alive_secs", "ndjson_envelope"];
234
235pub(crate) fn unknown_config_keys(yaml: &str) -> Vec<String> {
240 let Ok(serde_yaml::Value::Mapping(map)) = serde_yaml::from_str(yaml) else {
241 return Vec::new();
242 };
243 let mut unknown: Vec<String> = map
244 .keys()
245 .filter_map(|key| key.as_str())
246 .filter(|key| !KNOWN_TOP_LEVEL_KEYS.contains(key))
247 .map(str::to_owned)
248 .collect();
249 if let Some(serde_yaml::Value::Mapping(streaming)) = map.get("streaming") {
252 unknown.extend(
253 streaming
254 .keys()
255 .filter_map(|key| key.as_str())
256 .filter(|key| !KNOWN_STREAMING_KEYS.contains(key))
257 .map(|key| format!("streaming.{key}")),
258 );
259 }
260 unknown
261}
262
263impl TranscodeFileConfig {
264 pub(crate) fn options(&self) -> Result<crate::transcode::TranscodeOptions, String> {
272 use crate::transcode::error::ErrorDetailsPolicy;
273 let mut options = crate::transcode::TranscodeOptions::default()
274 .with_ndjson_envelope(self.streaming.ndjson_envelope);
275 if let Some(cfg) = &self.response_headers {
276 let deny = cfg
277 .deny
278 .iter()
279 .map(|name| {
280 http::HeaderName::from_bytes(name.as_bytes()).map_err(|_| {
281 format!("response_headers.deny entry {name:?} is not a header name")
282 })
283 })
284 .collect::<Result<Vec<_>, _>>()?;
285 options = options.with_denied_response_headers(deny);
286 }
287 if let Some(cfg) = &self.error_details {
288 let base = if cfg.enabled {
289 ErrorDetailsPolicy::default()
290 } else {
291 ErrorDetailsPolicy::disabled()
292 }
293 .with_opaque_details(cfg.opaque);
294 let policy = cfg.routes.iter().try_fold(base, |policy, rule| {
295 if rule.enabled.is_none() && rule.opaque.is_none() {
298 return Err(format!(
299 "error details route {:?} sets neither enabled nor opaque",
300 rule.pattern
301 ));
302 }
303 policy.rule(&rule.pattern, rule.enabled, rule.opaque)
304 })?;
305 options = options.with_error_details(policy);
306 }
307 Ok(options)
308 }
309}
310
311#[derive(Debug, Clone, Deserialize)]
313pub struct UpstreamConfig {
314 pub default: String,
316}
317
318#[derive(Debug, Clone)]
320pub enum DescriptorSource {
321 File { file: PathBuf },
323 Reflection { reflection: String },
325 Embedded { bytes: &'static [u8] },
327}
328
329#[derive(Debug, Clone, Deserialize)]
331#[serde(untagged)]
332enum DescriptorSourceYaml {
333 File { file: PathBuf },
334 Reflection { reflection: String },
335}
336
337impl From<DescriptorSourceYaml> for DescriptorSource {
338 fn from(yaml: DescriptorSourceYaml) -> Self {
339 match yaml {
340 DescriptorSourceYaml::File { file } => DescriptorSource::File { file },
341 DescriptorSourceYaml::Reflection { reflection } => {
342 DescriptorSource::Reflection { reflection }
343 }
344 }
345 }
346}
347
348fn deserialize_descriptor_sources<'de, D>(
349 deserializer: D,
350) -> std::result::Result<Vec<DescriptorSource>, D::Error>
351where
352 D: serde::Deserializer<'de>,
353{
354 let yaml_sources: Vec<DescriptorSourceYaml> = Vec::deserialize(deserializer)?;
355 Ok(yaml_sources.into_iter().map(Into::into).collect())
356}
357
358#[derive(Debug, Clone, Deserialize)]
360pub struct ListenConfig {
361 #[serde(default = "default_http_listen")]
363 pub http: String,
364}
365
366fn default_http_listen() -> String {
367 "0.0.0.0:8080".into()
368}
369
370impl Default for ListenConfig {
371 fn default() -> Self {
372 Self {
373 http: default_http_listen(),
374 }
375 }
376}
377
378#[derive(Debug, Clone, Deserialize)]
380pub struct ServiceConfig {
381 #[serde(default = "default_service_name")]
383 pub name: String,
384}
385
386fn default_service_name() -> String {
387 "structured-proxy".into()
388}
389
390impl Default for ServiceConfig {
391 fn default() -> Self {
392 Self {
393 name: default_service_name(),
394 }
395 }
396}
397
398#[derive(Debug, Clone, Deserialize)]
400#[non_exhaustive]
401pub struct AliasConfig {
402 pub from: String,
403 pub to: String,
404}
405
406#[derive(Debug, Clone, Deserialize)]
408#[non_exhaustive]
409pub struct OpenApiConfig {
410 #[serde(default = "default_true")]
411 pub enabled: bool,
412 #[serde(default = "default_openapi_path")]
414 pub path: String,
415 #[serde(default = "default_docs_path")]
417 pub docs_path: String,
418 #[serde(default)]
419 pub title: Option<String>,
420 #[serde(default)]
421 pub version: Option<String>,
422}
423
424fn default_openapi_path() -> String {
425 "/openapi.json".into()
426}
427
428fn default_docs_path() -> String {
429 "/docs".into()
430}
431
432fn default_true() -> bool {
433 true
434}
435
436#[derive(Debug, Clone, Deserialize)]
438#[non_exhaustive]
439pub struct AuthConfig {
440 #[serde(default = "default_auth_mode")]
442 pub mode: String,
443
444 #[serde(default)]
446 pub jwt: Option<JwtConfig>,
447
448 #[serde(default)]
450 pub forward_auth: Option<ForwardAuthConfig>,
451
452 #[serde(default)]
454 pub authz: Option<AuthzConfig>,
455}
456
457fn default_auth_mode() -> String {
458 "none".into()
459}
460
461#[derive(Debug, Clone, Deserialize)]
463#[non_exhaustive]
464pub struct JwtConfig {
465 #[serde(default)]
467 pub jwks_uri: Option<String>,
468 #[serde(default = "default_jwks_max_age_secs")]
472 pub jwks_max_age_secs: u64,
473 #[serde(default)]
475 pub issuer: Option<String>,
476 #[serde(default)]
478 pub audience: Option<String>,
479 #[serde(default)]
481 pub public_key_pem_file: Option<PathBuf>,
482 #[serde(default)]
484 pub claims_headers: std::collections::HashMap<String, String>,
485 #[serde(default = "default_roles_claim")]
488 pub roles_claim: String,
489 #[serde(default)]
492 pub cache: JwtCacheConfig,
493}
494
495#[derive(Debug, Clone, Deserialize)]
499#[serde(deny_unknown_fields)]
500#[non_exhaustive]
501pub struct JwtCacheConfig {
502 #[serde(default = "default_true")]
504 pub enabled: bool,
505 #[serde(default = "default_jwt_cache_max_entries")]
508 pub max_entries: usize,
509 #[serde(default = "default_jwt_cache_max_ttl_secs")]
513 pub max_ttl_secs: u64,
514 #[serde(default = "default_jwt_cache_max_token_bytes")]
518 pub max_token_bytes: usize,
519}
520
521pub(crate) fn default_jwks_max_age_secs() -> u64 {
523 300
524}
525
526fn default_jwt_cache_max_entries() -> usize {
527 10_000
528}
529
530fn default_jwt_cache_max_ttl_secs() -> u64 {
531 60
532}
533
534fn default_jwt_cache_max_token_bytes() -> usize {
535 4096
536}
537
538impl Default for JwtCacheConfig {
539 fn default() -> Self {
540 Self {
541 enabled: true,
542 max_entries: default_jwt_cache_max_entries(),
543 max_ttl_secs: default_jwt_cache_max_ttl_secs(),
544 max_token_bytes: default_jwt_cache_max_token_bytes(),
545 }
546 }
547}
548
549pub(crate) fn default_roles_claim() -> String {
552 "roles".into()
553}
554
555#[derive(Debug, Clone, Deserialize)]
557#[non_exhaustive]
558pub struct ForwardAuthConfig {
559 #[serde(default)]
560 pub enabled: bool,
561 #[serde(default = "default_forward_auth_path")]
562 pub path: String,
563 #[serde(default)]
565 pub policies: Vec<RoutePolicyConfig>,
566 #[serde(default)]
568 pub login_url: Option<String>,
569 #[serde(default)]
571 pub applications_path: Option<PathBuf>,
572}
573
574fn default_forward_auth_path() -> String {
575 "/auth/verify".into()
576}
577
578#[derive(Debug, Clone, Deserialize)]
580#[non_exhaustive]
581pub struct RoutePolicyConfig {
582 pub path: String,
583 #[serde(default = "default_methods_all")]
584 pub methods: Vec<String>,
585 #[serde(default)]
586 pub require_auth: bool,
587 #[serde(default)]
588 pub required_roles: Vec<String>,
589}
590
591fn default_methods_all() -> Vec<String> {
592 vec!["*".into()]
593}
594
595#[derive(Debug, Clone, Deserialize)]
599#[non_exhaustive]
600pub struct AuthzConfig {
601 #[serde(default)]
603 pub enabled: bool,
604 #[serde(default)]
607 pub endpoint: String,
608 #[serde(default = "default_authz_timeout_ms")]
610 pub timeout_ms: u64,
611 #[serde(default)]
614 pub failure_mode_allow: bool,
615}
616
617fn default_authz_timeout_ms() -> u64 {
618 200
619}
620
621#[derive(Debug, Clone, Deserialize)]
629#[serde(deny_unknown_fields)]
630#[non_exhaustive]
631pub struct ShieldConfig {
632 #[serde(default)]
633 pub enabled: bool,
634 #[serde(default)]
637 pub profiles: std::collections::HashMap<String, LimitProfileConfig>,
638 #[serde(default)]
641 pub rules: Vec<RateRuleConfig>,
642 #[serde(default)]
646 pub default_profile: Option<String>,
647 #[serde(default)]
650 pub jwt_limits: Option<JwtLimitConfig>,
651 #[serde(default)]
654 pub limit_service: Option<LimitServiceConfig>,
655 #[serde(default)]
658 pub sync: Option<SyncConfig>,
659 #[serde(default)]
665 pub trusted_proxies: Vec<String>,
666}
667
668#[derive(Debug, Clone, Deserialize)]
670#[serde(deny_unknown_fields)]
671#[non_exhaustive]
672pub struct LimitProfileConfig {
673 pub rate: String,
676 #[serde(default)]
679 pub burst: Option<u64>,
680}
681
682#[derive(Debug, Clone, Deserialize)]
689#[serde(deny_unknown_fields)]
690#[non_exhaustive]
691pub struct RateRuleConfig {
692 pub pattern: String,
694 #[serde(default)]
696 pub key: KeySourceConfig,
697 #[serde(default)]
700 pub profile: Option<String>,
701}
702
703#[derive(Debug, Clone, Default, PartialEq, Eq)]
708#[non_exhaustive]
709pub enum KeySourceConfig {
710 #[default]
712 Ip,
713 Header {
716 name: String,
718 },
719 JwtClaim {
722 claim: String,
724 },
725}
726
727#[derive(Deserialize)]
734#[serde(deny_unknown_fields, rename_all = "snake_case")]
735struct KeySourceRaw {
736 #[serde(rename = "type")]
737 kind: String,
738 #[serde(default)]
739 name: Option<String>,
740 #[serde(default)]
741 claim: Option<String>,
742}
743
744impl<'de> Deserialize<'de> for KeySourceConfig {
745 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
746 where
747 D: serde::Deserializer<'de>,
748 {
749 use serde::de::Error;
750 let raw = KeySourceRaw::deserialize(deserializer)?;
751 match raw.kind.as_str() {
752 "ip" => {
753 if raw.name.is_some() || raw.claim.is_some() {
754 return Err(D::Error::custom("key type 'ip' takes no other fields"));
755 }
756 Ok(Self::Ip)
757 }
758 "header" => {
759 if raw.claim.is_some() {
760 return Err(D::Error::custom(
761 "key type 'header' takes 'name', not 'claim'",
762 ));
763 }
764 let name = raw.name.ok_or_else(|| D::Error::missing_field("name"))?;
765 Ok(Self::Header { name })
766 }
767 "jwt_claim" => {
768 if raw.name.is_some() {
769 return Err(D::Error::custom(
770 "key type 'jwt_claim' takes 'claim', not 'name'",
771 ));
772 }
773 let claim = raw.claim.ok_or_else(|| D::Error::missing_field("claim"))?;
774 Ok(Self::JwtClaim { claim })
775 }
776 other => Err(D::Error::unknown_variant(
777 other,
778 &["ip", "header", "jwt_claim"],
779 )),
780 }
781 }
782}
783
784#[derive(Debug, Clone, Deserialize)]
788#[serde(deny_unknown_fields)]
789#[non_exhaustive]
790pub struct JwtLimitConfig {
791 #[serde(default = "default_tier_claim")]
793 pub tier_claim: String,
794 #[serde(default = "default_rpm_claim")]
797 pub rpm_claim: String,
798 #[serde(default = "default_burst_claim")]
800 pub burst_claim: String,
801}
802
803fn default_tier_claim() -> String {
804 "ratelimit_tier".to_string()
805}
806fn default_rpm_claim() -> String {
807 "ratelimit_rpm".to_string()
808}
809fn default_burst_claim() -> String {
810 "ratelimit_burst".to_string()
811}
812
813#[derive(Debug, Clone, Deserialize)]
817#[serde(deny_unknown_fields)]
818#[non_exhaustive]
819pub struct LimitServiceConfig {
820 pub endpoint: String,
823 #[serde(default = "default_limit_ttl_secs")]
826 pub ttl_secs: u64,
827 #[serde(default = "default_limit_timeout_ms")]
829 pub timeout_ms: u64,
830}
831
832fn default_limit_ttl_secs() -> u64 {
833 300
834}
835fn default_limit_timeout_ms() -> u64 {
836 500
837}
838
839#[derive(Debug, Clone, Deserialize)]
841#[serde(deny_unknown_fields)]
842#[non_exhaustive]
843pub struct SyncConfig {
844 pub redis_url: String,
847 #[serde(default = "default_sync_interval_ms")]
850 pub interval_ms: u64,
851}
852
853fn default_sync_interval_ms() -> u64 {
854 500
855}
856
857#[derive(Debug, Clone, Deserialize)]
859#[non_exhaustive]
860pub struct OidcDiscoveryConfig {
861 #[serde(default)]
862 pub enabled: bool,
863 pub issuer: String,
864 #[serde(default)]
865 pub authorization_endpoint: Option<String>,
866 #[serde(default)]
867 pub token_endpoint: Option<String>,
868 #[serde(default)]
869 pub userinfo_endpoint: Option<String>,
870 #[serde(default)]
871 pub jwks_uri: Option<String>,
872 #[serde(default)]
873 pub signing_key: Option<SigningKeyConfig>,
874}
875
876#[derive(Debug, Clone, Deserialize)]
878#[non_exhaustive]
879pub struct SigningKeyConfig {
880 #[serde(default = "default_algorithm")]
881 pub algorithm: String,
882 pub public_key_pem_file: PathBuf,
883}
884
885fn default_algorithm() -> String {
886 "EdDSA".into()
887}
888
889#[derive(Debug, Clone, Deserialize)]
895#[non_exhaustive]
896pub struct HealthConfig {
897 #[serde(default = "default_true")]
899 pub enabled: bool,
900 #[serde(default = "default_health_path")]
902 pub path: String,
903 #[serde(default = "default_health_live_path")]
905 pub live_path: String,
906 #[serde(default = "default_health_ready_path")]
908 pub ready_path: String,
909 #[serde(default = "default_health_startup_path")]
911 pub startup_path: String,
912}
913
914fn default_health_path() -> String {
915 "/health".into()
916}
917fn default_health_live_path() -> String {
918 "/health/live".into()
919}
920fn default_health_ready_path() -> String {
921 "/health/ready".into()
922}
923fn default_health_startup_path() -> String {
924 "/health/startup".into()
925}
926
927impl Default for HealthConfig {
928 fn default() -> Self {
929 Self {
930 enabled: true,
931 path: default_health_path(),
932 live_path: default_health_live_path(),
933 ready_path: default_health_ready_path(),
934 startup_path: default_health_startup_path(),
935 }
936 }
937}
938
939#[derive(Debug, Clone, Deserialize)]
941#[non_exhaustive]
942pub struct MetricsConfig {
943 #[serde(default = "default_true")]
945 pub enabled: bool,
946 #[serde(default = "default_metrics_path")]
948 pub path: String,
949}
950
951fn default_metrics_path() -> String {
952 "/metrics".into()
953}
954
955impl Default for MetricsConfig {
956 fn default() -> Self {
957 Self {
958 enabled: true,
959 path: default_metrics_path(),
960 }
961 }
962}
963
964#[derive(Debug, Clone, Deserialize)]
966#[non_exhaustive]
967pub struct MaintenanceConfig {
968 #[serde(default)]
969 pub enabled: bool,
970 #[serde(default = "default_exempt_paths")]
972 pub exempt_paths: Vec<String>,
973 #[serde(default = "default_maintenance_message")]
974 pub message: String,
975}
976
977fn default_exempt_paths() -> Vec<String> {
978 vec![
979 "/health/**".into(),
980 "/.well-known/**".into(),
981 "/metrics".into(),
982 "/auth/verify".into(),
983 ]
984}
985
986fn default_maintenance_message() -> String {
987 "Service is under maintenance. Please try again later.".into()
988}
989
990impl Default for MaintenanceConfig {
991 fn default() -> Self {
992 Self {
993 enabled: false,
994 exempt_paths: default_exempt_paths(),
995 message: default_maintenance_message(),
996 }
997 }
998}
999
1000#[derive(Debug, Clone, Default, Deserialize)]
1002#[non_exhaustive]
1003pub struct CorsConfig {
1004 #[serde(default)]
1006 pub origins: Vec<String>,
1007}
1008
1009#[derive(Debug, Clone, Deserialize)]
1011#[non_exhaustive]
1012pub struct LoggingConfig {
1013 #[serde(default = "default_log_level")]
1014 pub level: String,
1015 #[serde(default = "default_log_format")]
1016 pub format: String,
1017}
1018
1019fn default_log_level() -> String {
1020 "info".into()
1021}
1022fn default_log_format() -> String {
1023 "json".into()
1024}
1025
1026impl Default for LoggingConfig {
1027 fn default() -> Self {
1028 Self {
1029 level: default_log_level(),
1030 format: default_log_format(),
1031 }
1032 }
1033}
1034
1035#[derive(Debug, Clone, Deserialize)]
1037#[non_exhaustive]
1038pub struct MetricsClassConfig {
1039 pub pattern: String,
1041 pub class: String,
1043}
1044
1045impl ProxyConfig {
1046 pub fn from_file(path: &std::path::Path) -> anyhow::Result<Self> {
1048 Self::from_yaml_str(&std::fs::read_to_string(path)?)
1049 }
1050
1051 pub fn from_yaml_str(yaml: &str) -> anyhow::Result<Self> {
1056 let config: Self = serde_yaml::from_str(yaml)?;
1057 config.validate()?;
1058 Ok(config)
1059 }
1060
1061 pub fn validate(&self) -> anyhow::Result<()> {
1067 if self.streaming.sse_keep_alive_secs == 0 {
1068 anyhow::bail!("streaming.sse_keep_alive_secs must be greater than 0");
1069 }
1070 self.validate_edge_paths()?;
1071 Ok(())
1072 }
1073
1074 fn validate_edge_paths(&self) -> anyhow::Result<()> {
1079 let mut seen = std::collections::HashSet::new();
1080 let mut check = |label: &str, path: &str| -> anyhow::Result<()> {
1081 if !path.starts_with('/') {
1082 anyhow::bail!("endpoint path {path:?} ({label}) must start with '/'");
1083 }
1084 if !seen.insert(path.to_string()) {
1085 anyhow::bail!("duplicate endpoint path {path:?} ({label}); each built-in endpoint must have a distinct path");
1086 }
1087 Ok(())
1088 };
1089 if self.health.enabled {
1090 check("health.path", &self.health.path)?;
1091 check("health.live_path", &self.health.live_path)?;
1092 check("health.ready_path", &self.health.ready_path)?;
1093 check("health.startup_path", &self.health.startup_path)?;
1094 }
1095 if self.metrics.enabled {
1096 check("metrics.path", &self.metrics.path)?;
1097 }
1098 if let Some(openapi) = self.openapi.as_ref().filter(|o| o.enabled) {
1099 check("openapi.path", &openapi.path)?;
1100 check("openapi.docs_path", &openapi.docs_path)?;
1101 }
1102 Ok(())
1103 }
1104
1105 pub fn parse_rate(rate: &str) -> Option<u32> {
1107 let parts: Vec<&str> = rate.split('/').collect();
1108 if parts.len() != 2 {
1109 return None;
1110 }
1111 parts[0].trim().parse().ok()
1112 }
1113}
1114
1115#[cfg(test)]
1116mod tests;