feat: round-3 abstractions — ConfigValidator, AsyncLifecycleManager, MetricsBridge, rate limiter pre-acquire
This commit is contained in:
1 parent
d27e00060c
commit
1ea3b35581
9 files changed
+605
-3
No files matched your search
@@ -0,0 +1,215 @@
|
||||
//! Config validation traits and built-in validators (feature `validation`).
|
||||
//!
|
||||
//! [`ConfigValidator`] lets application config types sanity-check themselves
|
||||
//! after deserialization — e.g. ensuring a database URL parses, a port is in
|
||||
//! range, or a required field is non-empty — and collect all failures into a
|
||||
//! single report rather than failing one field at a time.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::ConfigError;
|
||||
|
||||
/// A single validation failure with a human-readable path and message.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ValidationFailure {
|
||||
/// Dotted path to the offending field, e.g. `"database.url"`.
|
||||
pub path: String,
|
||||
/// What was wrong.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl fmt::Display for ValidationFailure {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}: {}", self.path, self.message)
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors produced by [`ConfigValidator::validate`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ValidationError {
|
||||
/// All failures found in a single validation pass.
|
||||
pub failures: Vec<ValidationFailure>,
|
||||
}
|
||||
|
||||
impl fmt::Display for ValidationError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "config validation failed ({} issue(s)):", self.failures.len())?;
|
||||
for failure in &self.failures {
|
||||
write!(f, "\n - {failure}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ValidationError {}
|
||||
|
||||
impl From<ValidationError> for ConfigError {
|
||||
fn from(err: ValidationError) -> Self {
|
||||
ConfigError::Validation(err.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait for types that can validate themselves after configuration loading.
|
||||
///
|
||||
/// Implementors collect field-level failures rather than returning on the
|
||||
/// first error, so operators see the full problem set in one pass.
|
||||
pub trait ConfigValidator {
|
||||
fn validate(&self) -> Result<(), ValidationError>;
|
||||
}
|
||||
|
||||
/// Convenience blanket for any serializable config type that implements
|
||||
/// [`ConfigValidator`]. Callers typically invoke this on the output of
|
||||
/// [`ConfigLoader::build`](crate::loader::ConfigLoader::build).
|
||||
///
|
||||
/// ```no_run
|
||||
/// # use mytheclipse_config::{ConfigLoader, ConfigValidator, ConfigValidatorExt};
|
||||
/// # use serde::Deserialize;
|
||||
/// # #[derive(Debug, Deserialize)]
|
||||
/// # struct Cfg { port: u16 }
|
||||
/// # impl ConfigValidator for Cfg {
|
||||
/// # fn validate(&self) -> Result<(), mytheclipse_config::ValidationError> { Ok(()) }
|
||||
/// # }
|
||||
/// let cfg: Cfg = ConfigLoader::new().build().unwrap();
|
||||
/// cfg.validate_config().unwrap();
|
||||
/// ```
|
||||
pub trait ConfigValidatorExt: ConfigValidator {
|
||||
/// Validates `self`, returning `Ok(())` on success.
|
||||
fn validate_config(&self) -> Result<(), ConfigError> {
|
||||
self.validate().map_err(ConfigError::from)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ConfigValidator> ConfigValidatorExt for T {}
|
||||
|
||||
/// Validates that a string is a well-formed URL (http/https).
|
||||
pub fn validate_url(path: &str, value: &str) -> Option<ValidationFailure> {
|
||||
if value.is_empty() {
|
||||
return Some(ValidationFailure {
|
||||
path: path.to_string(),
|
||||
message: "url must not be empty".into(),
|
||||
});
|
||||
}
|
||||
// Minimal heuristic: scheme + host. We avoid pulling in a full URL crate
|
||||
// to keep the dependency surface small.
|
||||
let scheme_len = if value.starts_with("http://") { 7 } else if value.starts_with("https://") { 8 } else {
|
||||
return Some(ValidationFailure {
|
||||
path: path.to_string(),
|
||||
message: format!("url must start with http:// or https:// (got {value:?})"),
|
||||
});
|
||||
};
|
||||
let host = &value[scheme_len..];
|
||||
if host.is_empty() {
|
||||
return Some(ValidationFailure {
|
||||
path: path.to_string(),
|
||||
message: format!("url has no host portion (got {value:?})"),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Validates that a port number is in the valid range (1–65535).
|
||||
pub fn validate_port(path: &str, port: u16) -> Option<ValidationFailure> {
|
||||
// u16 already ranges 0–65535; exclude 0 (reserved/unspecified).
|
||||
if port == 0 {
|
||||
Some(ValidationFailure {
|
||||
path: path.to_string(),
|
||||
message: "port must be > 0".into(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates that a string is non-empty.
|
||||
pub fn validate_non_empty(path: &str, value: &str) -> Option<ValidationFailure> {
|
||||
if value.trim().is_empty() {
|
||||
Some(ValidationFailure {
|
||||
path: path.to_string(),
|
||||
message: "value must not be empty".into(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates that a numeric value falls within `[lo, hi]`.
|
||||
pub fn validate_range<T>(path: &str, value: T, lo: T, hi: T) -> Option<ValidationFailure>
|
||||
where
|
||||
T: PartialOrd + fmt::Display + Copy,
|
||||
{
|
||||
if value < lo || value > hi {
|
||||
Some(ValidationFailure {
|
||||
path: path.to_string(),
|
||||
message: format!("value {value} is out of range [{lo}, {hi}]"),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects all failures from an iterator of `Option<ValidationFailure>`.
|
||||
pub fn collect_failures(opts: impl IntoIterator<Item = Option<ValidationFailure>>) -> Result<(), ValidationError> {
|
||||
let failures: Vec<_> = opts.into_iter().flatten().collect();
|
||||
if failures.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ValidationError { failures })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn url_validator_pass_and_fail() {
|
||||
assert!(validate_url("db.url", "https://example.com").is_none());
|
||||
assert!(validate_url("db.url", "").is_some());
|
||||
assert!(validate_url("db.url", "ftp://bad").is_some());
|
||||
assert!(validate_url("db.url", "https://").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_validator_rejects_zero() {
|
||||
assert!(validate_port("port", 0).is_some());
|
||||
assert!(validate_port("port", 1).is_none());
|
||||
assert!(validate_port("port", 65535).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_validator_bounds() {
|
||||
assert!(validate_range("x", 5, 1, 10).is_none());
|
||||
assert!(validate_range("x", 10, 1, 10).is_none());
|
||||
assert!(validate_range("x", 0, 1, 10).is_some());
|
||||
assert!(validate_range("x", 11, 1, 10).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_failures_aggregates_all() {
|
||||
let opts = [validate_non_empty("a", ""), validate_non_empty("b", "ok"), validate_url("c.d", "bad://x")];
|
||||
let err = collect_failures(opts).unwrap_err();
|
||||
assert_eq!(err.failures.len(), 2);
|
||||
assert_eq!(err.failures[0].path, "a");
|
||||
assert_eq!(err.failures[1].path, "c.d");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_failures_ok_when_all_pass() {
|
||||
let opts = [validate_url("a", "https://ok.com"), validate_port("b", 8080)];
|
||||
assert!(collect_failures(opts).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blanket_ext_wrappers_validator() {
|
||||
struct Cfg;
|
||||
impl ConfigValidator for Cfg {
|
||||
fn validate(&self) -> Result<(), ValidationError> {
|
||||
Err(ValidationError {
|
||||
failures: vec![ValidationFailure { path: "x".into(), message: "bad".into() }],
|
||||
})
|
||||
}
|
||||
}
|
||||
let c = Cfg;
|
||||
assert!(c.validate_config().is_err());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user