1#![deny(missing_docs)]
47
48use std::{
49 collections::BTreeSet,
50 fmt,
51 net::{IpAddr, Ipv4Addr},
52 path::PathBuf,
53 time::Duration,
54};
55
56pub use baukit_core::{
57 DeploymentEnvironment, DeploymentEnvironment as Environment, LogFormat, ParseEnvironmentError,
58};
59use config::{Config, File, Map as ConfigMap, Source, Value, ValueKind};
60use serde::{Deserialize, Deserializer};
61use thiserror::Error;
62use zeroize::Zeroize;
63
64const DEFAULT_LOCAL_FILE: &str = "config/local.toml";
65const DEFAULT_DOTENV_FILE: &str = ".env";
66const REDACTED: &str = "[redacted]";
67
68pub struct Secret<T: Zeroize>(T);
73
74impl<T: Zeroize> Secret<T> {
75 pub fn new(value: T) -> Self {
77 Self(value)
78 }
79
80 pub fn expose(&self) -> &T {
82 &self.0
83 }
84}
85
86impl<T> Clone for Secret<T>
87where
88 T: Clone + Zeroize,
89{
90 fn clone(&self) -> Self {
91 Self(self.0.clone())
92 }
93}
94
95impl<T: Zeroize> fmt::Debug for Secret<T> {
96 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
97 formatter.write_str(REDACTED)
98 }
99}
100
101impl<T: Zeroize> fmt::Display for Secret<T> {
102 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
103 formatter.write_str(REDACTED)
104 }
105}
106
107impl<T: Zeroize> Drop for Secret<T> {
108 fn drop(&mut self) {
109 self.0.zeroize();
110 }
111}
112
113impl<'de, T> Deserialize<'de> for Secret<T>
114where
115 T: Deserialize<'de> + Zeroize,
116{
117 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
118 where
119 D: Deserializer<'de>,
120 {
121 T::deserialize(deserializer).map(Self)
122 }
123}
124
125#[derive(Clone, Debug, Deserialize)]
127#[serde(default)]
128pub struct HttpConfig {
129 pub bind_address: IpAddr,
131 pub port: u16,
133 #[serde(with = "duration_seconds")]
135 pub request_timeout: Duration,
136 pub body_size_limit: usize,
138 pub concurrency_limit: usize,
140 pub cors_allowed_origins: Vec<String>,
142}
143
144impl Default for HttpConfig {
145 fn default() -> Self {
146 Self {
147 bind_address: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
148 port: 8080,
149 request_timeout: Duration::from_secs(30),
150 body_size_limit: 2 * 1024 * 1024,
151 concurrency_limit: 1_024,
152 cors_allowed_origins: Vec::new(),
153 }
154 }
155}
156
157impl Validate for HttpConfig {
158 fn validate(&self) -> Result<(), ValidationErrors> {
159 let mut errors = Vec::new();
160 require_non_zero(self.port, "port", &mut errors);
161 require_non_zero(
162 self.request_timeout.as_secs(),
163 "request_timeout",
164 &mut errors,
165 );
166 require_non_zero(self.body_size_limit, "body_size_limit", &mut errors);
167 require_non_zero(self.concurrency_limit, "concurrency_limit", &mut errors);
168 for (index, origin) in self.cors_allowed_origins.iter().enumerate() {
169 if origin.trim().is_empty() {
170 errors.push(ValidationError::new(
171 format!("cors_allowed_origins[{index}]"),
172 "must not be empty",
173 ));
174 }
175 }
176 validation_result(errors)
177 }
178}
179
180#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)]
182#[serde(rename_all = "snake_case")]
183pub enum RateLimitFailMode {
184 #[default]
186 Open,
187 Closed,
189}
190
191#[derive(Clone, Debug, Deserialize)]
193#[serde(default)]
194pub struct RateLimitScopeConfig {
195 pub enabled: bool,
197 pub requests_per_period: u64,
199 #[serde(with = "duration_seconds")]
201 pub period: Duration,
202 pub burst: u64,
204}
205
206impl RateLimitScopeConfig {
207 fn identity_default() -> Self {
208 Self {
209 enabled: true,
210 requests_per_period: 60,
211 period: Duration::from_secs(60),
212 burst: 10,
213 }
214 }
215
216 fn ip_default() -> Self {
217 Self {
218 enabled: true,
219 requests_per_period: 6_000,
220 period: Duration::from_secs(60),
221 burst: 500,
222 }
223 }
224
225 fn validate_into(&self, path: &str, errors: &mut Vec<ValidationError>) {
226 if !self.enabled {
227 return;
228 }
229 require_non_zero(
230 self.requests_per_period,
231 &format!("{path}.requests_per_period"),
232 errors,
233 );
234 require_non_zero(self.period.as_nanos(), &format!("{path}.period"), errors);
235 if self.requests_per_period.checked_add(self.burst).is_none() {
236 errors.push(ValidationError::new(
237 format!("{path}.burst"),
238 "must not overflow the bucket capacity",
239 ));
240 }
241 }
242}
243
244impl Default for RateLimitScopeConfig {
245 fn default() -> Self {
246 Self::identity_default()
247 }
248}
249
250#[derive(Clone, Debug, Deserialize)]
256#[serde(default)]
257pub struct RateLimitConfig {
258 pub redis_url: Secret<String>,
260 pub identity: RateLimitScopeConfig,
262 #[serde(
264 default = "RateLimitScopeConfig::ip_default",
265 deserialize_with = "deserialize_ip_rate_limit_scope"
266 )]
267 pub ip: RateLimitScopeConfig,
268 pub fail_mode: RateLimitFailMode,
270 pub trusted_proxy_hops: usize,
272 pub key_prefix: String,
274}
275
276fn deserialize_ip_rate_limit_scope<'de, D>(
277 deserializer: D,
278) -> Result<RateLimitScopeConfig, D::Error>
279where
280 D: Deserializer<'de>,
281{
282 #[derive(Deserialize)]
283 struct PartialScope {
284 enabled: Option<bool>,
285 requests_per_period: Option<u64>,
286 period: Option<u64>,
287 burst: Option<u64>,
288 }
289
290 let partial = PartialScope::deserialize(deserializer)?;
291 let defaults = RateLimitScopeConfig::ip_default();
292 Ok(RateLimitScopeConfig {
293 enabled: partial.enabled.unwrap_or(defaults.enabled),
294 requests_per_period: partial
295 .requests_per_period
296 .unwrap_or(defaults.requests_per_period),
297 period: partial.period.map_or(defaults.period, Duration::from_secs),
298 burst: partial.burst.unwrap_or(defaults.burst),
299 })
300}
301
302impl Default for RateLimitConfig {
303 fn default() -> Self {
304 Self {
305 redis_url: Secret::new("redis://127.0.0.1/".to_owned()),
306 identity: RateLimitScopeConfig::identity_default(),
307 ip: RateLimitScopeConfig::ip_default(),
308 fail_mode: RateLimitFailMode::Open,
309 trusted_proxy_hops: 1,
310 key_prefix: "rl:".to_owned(),
311 }
312 }
313}
314
315impl Validate for RateLimitConfig {
316 fn validate(&self) -> Result<(), ValidationErrors> {
317 let mut errors = Vec::new();
318 self.identity.validate_into("identity", &mut errors);
319 self.ip.validate_into("ip", &mut errors);
320 if self.redis_url.expose().trim().is_empty() {
321 errors.push(ValidationError::new("redis_url", "must not be empty"));
322 }
323 if self.key_prefix.trim().is_empty() {
324 errors.push(ValidationError::new("key_prefix", "must not be empty"));
325 }
326 if self.trusted_proxy_hops > 32 {
327 errors.push(ValidationError::new(
328 "trusted_proxy_hops",
329 "must be at most 32",
330 ));
331 }
332 validation_result(errors)
333 }
334}
335
336#[derive(Clone, Debug, Deserialize)]
338#[serde(default)]
339pub struct OpsConfig {
340 pub bind_address: IpAddr,
342 pub port: u16,
344}
345
346impl Default for OpsConfig {
347 fn default() -> Self {
348 Self {
349 bind_address: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
350 port: 9090,
351 }
352 }
353}
354
355impl Validate for OpsConfig {
356 fn validate(&self) -> Result<(), ValidationErrors> {
357 let mut errors = Vec::new();
358 require_non_zero(self.port, "port", &mut errors);
359 validation_result(errors)
360 }
361}
362
363#[derive(Clone, Debug, Deserialize)]
365#[serde(default)]
366pub struct DatabaseConfig {
367 pub url: Secret<String>,
369 pub max_connections: u32,
371 pub min_connections: u32,
373 #[serde(with = "duration_seconds")]
375 pub acquire_timeout: Duration,
376}
377
378impl Default for DatabaseConfig {
379 fn default() -> Self {
380 Self {
381 url: Secret::new("postgres://localhost/app".to_owned()),
382 max_connections: 10,
383 min_connections: 1,
384 acquire_timeout: Duration::from_secs(5),
385 }
386 }
387}
388
389impl Validate for DatabaseConfig {
390 fn validate(&self) -> Result<(), ValidationErrors> {
391 let mut errors = Vec::new();
392 if self.url.expose().trim().is_empty() {
393 errors.push(ValidationError::new("url", "must not be empty"));
394 }
395 require_non_zero(self.max_connections, "max_connections", &mut errors);
396 if self.min_connections > self.max_connections {
397 errors.push(ValidationError::new(
398 "min_connections",
399 "must not exceed max_connections",
400 ));
401 }
402 require_non_zero(
403 self.acquire_timeout.as_secs(),
404 "acquire_timeout",
405 &mut errors,
406 );
407 validation_result(errors)
408 }
409}
410
411#[derive(Clone, Debug, Default, Deserialize)]
417#[serde(default)]
418pub struct TelemetryResourceConfig {
419 pub service_name: Option<String>,
421 pub service_version: Option<String>,
423 pub service_commit: Option<String>,
425 pub product: Option<String>,
427}
428
429impl Validate for TelemetryResourceConfig {
430 fn validate(&self) -> Result<(), ValidationErrors> {
431 let mut errors = Vec::new();
432 validate_optional_text(&self.service_name, "service_name", &mut errors);
433 validate_optional_text(&self.service_version, "service_version", &mut errors);
434 validate_optional_text(&self.service_commit, "service_commit", &mut errors);
435 validate_optional_text(&self.product, "product", &mut errors);
436 validation_result(errors)
437 }
438}
439
440#[derive(Clone, Debug, Deserialize)]
442#[serde(default)]
443pub struct TelemetryConfig {
444 pub otlp_endpoint: Option<String>,
446 pub trace_sampling_ratio: f64,
448 pub log_format: LogFormat,
450 pub resource: TelemetryResourceConfig,
452}
453
454impl Default for TelemetryConfig {
455 fn default() -> Self {
456 Self {
457 otlp_endpoint: std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok(),
458 trace_sampling_ratio: 1.0,
459 log_format: LogFormat::Auto,
460 resource: TelemetryResourceConfig::default(),
461 }
462 }
463}
464
465impl Validate for TelemetryConfig {
466 fn validate(&self) -> Result<(), ValidationErrors> {
467 let mut errors = Vec::new();
468 validate_optional_text(&self.otlp_endpoint, "otlp_endpoint", &mut errors);
469 if !self.trace_sampling_ratio.is_finite()
470 || !(0.0..=1.0).contains(&self.trace_sampling_ratio)
471 {
472 errors.push(ValidationError::new(
473 "trace_sampling_ratio",
474 "must be a finite value between 0.0 and 1.0",
475 ));
476 }
477 extend_validation(&mut errors, self.resource.validate(), Some("resource"));
478 validation_result(errors)
479 }
480}
481
482#[derive(Clone, Debug, Deserialize)]
484#[serde(default)]
485pub struct ShutdownConfig {
486 #[serde(with = "duration_seconds")]
488 pub drain_timeout: Duration,
489}
490
491impl Default for ShutdownConfig {
492 fn default() -> Self {
493 Self {
494 drain_timeout: Duration::from_secs(30),
495 }
496 }
497}
498
499impl Validate for ShutdownConfig {
500 fn validate(&self) -> Result<(), ValidationErrors> {
501 let mut errors = Vec::new();
502 require_non_zero(self.drain_timeout.as_secs(), "drain_timeout", &mut errors);
503 validation_result(errors)
504 }
505}
506
507#[derive(Clone, Debug, Deserialize)]
515#[serde(default)]
516pub struct BaukitConfig<T: Default> {
517 pub environment: Environment,
519 pub http: HttpConfig,
521 pub ops: OpsConfig,
523 pub database: Option<DatabaseConfig>,
528 pub rate_limit: RateLimitConfig,
530 pub telemetry: TelemetryConfig,
532 pub shutdown: ShutdownConfig,
534 #[serde(flatten)]
536 pub product: T,
537}
538
539impl<T: Default> Default for BaukitConfig<T> {
540 fn default() -> Self {
541 Self {
542 environment: Environment::default(),
543 http: HttpConfig::default(),
544 ops: OpsConfig::default(),
545 database: None,
546 rate_limit: RateLimitConfig::default(),
547 telemetry: TelemetryConfig::default(),
548 shutdown: ShutdownConfig::default(),
549 product: T::default(),
550 }
551 }
552}
553
554#[derive(Default, Deserialize)]
555#[serde(default)]
556struct StandardConfig {
557 environment: Environment,
558 http: HttpConfig,
559 ops: OpsConfig,
560 database: Option<DatabaseConfig>,
561 rate_limit: RateLimitConfig,
562 telemetry: TelemetryConfig,
563 shutdown: ShutdownConfig,
564}
565
566impl<T> Validate for BaukitConfig<T>
567where
568 T: Default + Validate,
569{
570 fn validate(&self) -> Result<(), ValidationErrors> {
571 let mut errors = Vec::new();
572 extend_validation(&mut errors, self.http.validate(), Some("http"));
573 extend_validation(&mut errors, self.ops.validate(), Some("ops"));
574 if let Some(database) = &self.database {
575 extend_validation(&mut errors, database.validate(), Some("database"));
576 }
577 extend_validation(&mut errors, self.rate_limit.validate(), Some("rate_limit"));
578 extend_validation(&mut errors, self.telemetry.validate(), Some("telemetry"));
579 extend_validation(&mut errors, self.shutdown.validate(), Some("shutdown"));
580 extend_validation(&mut errors, self.product.validate(), Some("product"));
581 validation_result(errors)
582 }
583}
584
585pub trait Validate {
587 fn validate(&self) -> Result<(), ValidationErrors>;
589}
590
591impl Validate for () {
592 fn validate(&self) -> Result<(), ValidationErrors> {
593 Ok(())
594 }
595}
596
597#[derive(Clone, Debug, Error, Eq, PartialEq)]
599#[error("{path}: {message}")]
600pub struct ValidationError {
601 path: String,
602 message: String,
603}
604
605impl ValidationError {
606 pub fn new(path: impl Into<String>, message: impl Into<String>) -> Self {
608 Self {
609 path: path.into(),
610 message: message.into(),
611 }
612 }
613
614 pub fn path(&self) -> &str {
616 &self.path
617 }
618
619 pub fn message(&self) -> &str {
621 &self.message
622 }
623
624 fn with_prefix(mut self, prefix: &str) -> Self {
625 self.path = if self.path.is_empty() {
626 prefix.to_owned()
627 } else {
628 format!("{prefix}.{}", self.path)
629 };
630 self
631 }
632}
633
634#[derive(Clone, Debug, Error, Eq, PartialEq)]
636#[error("{rendered}")]
637pub struct ValidationErrors {
638 errors: Vec<ValidationError>,
639 rendered: String,
640}
641
642impl ValidationErrors {
643 pub fn new(errors: Vec<ValidationError>) -> Self {
647 let mut rendered = format!(
648 "configuration validation failed ({} error(s))",
649 errors.len()
650 );
651 for error in &errors {
652 rendered.push_str("\n- ");
653 rendered.push_str(&error.to_string());
654 }
655 Self { errors, rendered }
656 }
657
658 pub fn errors(&self) -> &[ValidationError] {
660 &self.errors
661 }
662
663 pub fn into_errors(self) -> Vec<ValidationError> {
665 self.errors
666 }
667}
668
669#[derive(Clone, Debug)]
671pub struct ConfigLoader {
672 prefix: String,
673 environment: Environment,
674 local_file: Option<PathBuf>,
675 dotenv_file: Option<PathBuf>,
676 environment_collections: BTreeSet<String>,
677}
678
679impl ConfigLoader {
680 pub fn new(app_name: &str, environment: Environment) -> Result<Self, LoadError> {
685 let prefix = normalize_prefix(app_name)?;
686 Ok(Self {
687 prefix,
688 environment,
689 local_file: Some(PathBuf::from(DEFAULT_LOCAL_FILE)),
690 dotenv_file: Some(PathBuf::from(DEFAULT_DOTENV_FILE)),
691 environment_collections: BTreeSet::from(["http.cors_allowed_origins".to_owned()]),
692 })
693 }
694
695 #[must_use]
697 pub fn local_file(mut self, path: impl Into<PathBuf>) -> Self {
698 self.local_file = Some(path.into());
699 self
700 }
701
702 #[must_use]
704 pub fn without_local_file(mut self) -> Self {
705 self.local_file = None;
706 self
707 }
708
709 #[must_use]
713 pub fn dotenv_file(mut self, path: impl Into<PathBuf>) -> Self {
714 self.dotenv_file = Some(path.into());
715 self
716 }
717
718 #[must_use]
720 pub fn without_dotenv(mut self) -> Self {
721 self.dotenv_file = None;
722 self
723 }
724
725 pub fn environment_prefix(&self) -> &str {
727 &self.prefix
728 }
729
730 #[must_use]
738 pub fn environment_collection(mut self, path: impl Into<String>) -> Self {
739 self.environment_collections
740 .insert(path.into().to_ascii_lowercase());
741 self
742 }
743
744 pub fn load<T>(&self) -> Result<BaukitConfig<T>, LoadError>
746 where
747 T: Default + Validate + serde::de::DeserializeOwned,
748 {
749 self.load_dotenv()?;
750
751 let mut builder = Config::builder();
752 if let Some(path) = &self.local_file {
753 builder = builder.add_source(File::from(path.clone()).required(false));
754 }
755 builder = builder.add_source(CollectionEnvironment::new(
756 config::Environment::with_prefix(&self.prefix)
757 .prefix_separator("__")
758 .separator("__")
759 .try_parsing(false),
763 self.environment_collections.clone(),
764 ));
765 builder = builder.set_override("environment", self.environment.to_string())?;
768
769 let merged = builder.build()?;
770 let standard = merged.clone().try_deserialize::<StandardConfig>()?;
771 let product = deserialize_product_config::<T>(merged.cache)?;
772 let loaded = BaukitConfig {
773 environment: standard.environment,
774 http: standard.http,
775 ops: standard.ops,
776 database: standard.database,
777 rate_limit: standard.rate_limit,
778 telemetry: standard.telemetry,
779 shutdown: standard.shutdown,
780 product,
781 };
782 loaded.validate()?;
783 Ok(loaded)
784 }
785
786 fn load_dotenv(&self) -> Result<(), LoadError> {
787 if self.environment != Environment::Local {
788 return Ok(());
789 }
790 let Some(path) = &self.dotenv_file else {
791 return Ok(());
792 };
793 match dotenvy::from_path(path) {
794 Ok(_) => Ok(()),
795 Err(dotenvy::Error::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => {
796 Ok(())
797 }
798 Err(source) => Err(LoadError::Dotenv {
799 path: path.clone(),
800 source,
801 }),
802 }
803 }
804}
805
806#[derive(Clone, Debug)]
807struct CollectionEnvironment {
808 inner: config::Environment,
809 keys: BTreeSet<String>,
810}
811
812impl CollectionEnvironment {
813 fn new(inner: config::Environment, keys: BTreeSet<String>) -> Self {
814 Self { inner, keys }
815 }
816}
817
818impl Source for CollectionEnvironment {
819 fn clone_into_box(&self) -> Box<dyn Source + Send + Sync> {
820 Box::new(self.clone())
821 }
822
823 fn collect(&self) -> Result<ConfigMap<String, Value>, config::ConfigError> {
824 let mut values = self.inner.collect()?;
825 for (key, value) in &mut values {
826 if self.keys.contains(key) {
827 parse_json_array(value);
828 }
829 }
830 Ok(values)
831 }
832}
833
834fn parse_json_array(value: &mut Value) {
835 let ValueKind::String(raw) = &value.kind else {
836 return;
837 };
838 let Ok(serde_json::Value::Array(items)) = serde_json::from_str(raw) else {
839 return;
840 };
841 let origin = value.origin().map(str::to_owned);
842 value.kind = ValueKind::Array(
843 items
844 .into_iter()
845 .map(|item| json_to_config_value(item, origin.as_ref()))
846 .collect(),
847 );
848}
849
850fn json_to_config_value(value: serde_json::Value, origin: Option<&String>) -> Value {
851 let kind = match value {
852 serde_json::Value::Null => ValueKind::Nil,
853 serde_json::Value::Bool(value) => ValueKind::Boolean(value),
854 serde_json::Value::Number(value) => value.as_i64().map_or_else(
855 || {
856 value.as_u64().map_or_else(
857 || ValueKind::Float(value.as_f64().unwrap_or_default()),
858 ValueKind::U64,
859 )
860 },
861 ValueKind::I64,
862 ),
863 serde_json::Value::String(value) => ValueKind::String(value),
864 serde_json::Value::Array(values) => ValueKind::Array(
865 values
866 .into_iter()
867 .map(|value| json_to_config_value(value, origin))
868 .collect(),
869 ),
870 serde_json::Value::Object(values) => ValueKind::Table(
871 values
872 .into_iter()
873 .map(|(key, value)| (key, json_to_config_value(value, origin)))
874 .collect(),
875 ),
876 };
877 Value::new(origin, kind)
878}
879
880fn deserialize_product_config<T>(mut merged: Value) -> Result<T, config::ConfigError>
881where
882 T: Default + serde::de::DeserializeOwned,
883{
884 if let ValueKind::Table(table) = &mut merged.kind {
885 for standard_key in [
886 "environment",
887 "http",
888 "ops",
889 "database",
890 "rate_limit",
891 "telemetry",
892 "shutdown",
893 ] {
894 table.remove(standard_key);
895 }
896 if table.is_empty() {
897 return Ok(T::default());
898 }
899 }
900 merged.try_deserialize()
901}
902
903#[derive(Debug, Error)]
905pub enum LoadError {
906 #[error("invalid application name `{app_name}` for an environment prefix: {reason}")]
908 InvalidPrefix {
909 app_name: String,
911 reason: &'static str,
913 },
914 #[error("failed to load dotenv file `{path}`: {source}")]
916 Dotenv {
917 path: PathBuf,
919 #[source]
921 source: dotenvy::Error,
922 },
923 #[error("failed to load configuration: {0}")]
925 Configuration(#[from] config::ConfigError),
926 #[error(transparent)]
928 Validation(#[from] ValidationErrors),
929}
930
931fn normalize_prefix(app_name: &str) -> Result<String, LoadError> {
932 if app_name.is_empty() {
933 return Err(invalid_prefix(app_name));
934 }
935 let mut prefix = String::with_capacity(app_name.len());
936 for character in app_name.chars() {
937 match character {
938 'a'..='z' => prefix.push(character.to_ascii_uppercase()),
939 'A'..='Z' | '0'..='9' | '_' => prefix.push(character),
940 '-' => prefix.push('_'),
941 _ => return Err(invalid_prefix(app_name)),
942 }
943 }
944 Ok(prefix)
945}
946
947fn invalid_prefix(app_name: &str) -> LoadError {
948 LoadError::InvalidPrefix {
949 app_name: app_name.to_owned(),
950 reason: "use only ASCII letters, digits, `_`, or `-`",
951 }
952}
953
954fn require_non_zero<T>(value: T, path: &str, errors: &mut Vec<ValidationError>)
955where
956 T: Default + PartialEq,
957{
958 if value == T::default() {
959 errors.push(ValidationError::new(path, "must be non-zero"));
960 }
961}
962
963fn validate_optional_text(value: &Option<String>, path: &str, errors: &mut Vec<ValidationError>) {
964 if value.as_ref().is_some_and(|value| value.trim().is_empty()) {
965 errors.push(ValidationError::new(path, "must not be empty when set"));
966 }
967}
968
969fn validation_result(errors: Vec<ValidationError>) -> Result<(), ValidationErrors> {
970 if errors.is_empty() {
971 Ok(())
972 } else {
973 Err(ValidationErrors::new(errors))
974 }
975}
976
977fn extend_validation(
978 target: &mut Vec<ValidationError>,
979 result: Result<(), ValidationErrors>,
980 prefix: Option<&str>,
981) {
982 if let Err(errors) = result {
983 target.extend(errors.into_errors().into_iter().map(|error| match prefix {
984 Some(prefix) => error.with_prefix(prefix),
985 None => error,
986 }));
987 }
988}
989
990mod duration_seconds {
991 use std::time::Duration;
992
993 use serde::{Deserialize, Deserializer};
994
995 pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
996 where
997 D: Deserializer<'de>,
998 {
999 u64::deserialize(deserializer).map(Duration::from_secs)
1000 }
1001}
1002
1003#[cfg(test)]
1004mod tests;
1005
1006#[doc = include_str!("../README.md")]
1008#[cfg(doctest)]
1009struct ReadmeDoctests;