use std::fmt::Display;
use rskit_errors::{AppError, AppResult, ErrorCode};
use crate::{FieldError, validate_email, validate_url, validate_uuid};
#[derive(Debug, Default)]
pub struct Validator {
errors: Vec<FieldError>,
}
impl Validator {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn required(mut self, field: &str, value: &str) -> Self {
if value.trim().is_empty() {
self.add(field, "is required");
}
self
}
#[must_use]
pub fn min_length(mut self, field: &str, value: &str, min: usize) -> Self {
if value.chars().count() < min {
self.add(field, format!("must be at least {min} characters"));
}
self
}
#[must_use]
pub fn max_length(mut self, field: &str, value: &str, max: usize) -> Self {
if value.chars().count() > max {
self.add(field, format!("must be at most {max} characters"));
}
self
}
#[must_use]
pub fn email(mut self, field: &str, value: &str) -> Self {
if !validate_email(value) {
self.add(field, "must be a valid email address");
}
self
}
#[must_use]
pub fn url(mut self, field: &str, value: &str) -> Self {
if !validate_url(value) {
self.add(field, "must be a valid URL");
}
self
}
#[must_use]
pub fn pattern(mut self, field: &str, value: &str, re: &str) -> Self {
match regex::Regex::new(re) {
Ok(regex) => {
if !regex.is_match(value) {
self.add(field, format!("must match pattern {re}"));
}
}
Err(err) => self.add(field, format!("invalid pattern: {err}")),
}
self
}
#[must_use]
pub fn required_uuid(mut self, field: &str, value: &str) -> Self {
if !validate_uuid(value) {
self.add(field, "must be a valid UUID");
}
self
}
#[must_use]
pub fn optional_uuid(mut self, field: &str, value: Option<&str>) -> Self {
if let Some(v) = value
&& !validate_uuid(v)
{
self.add(field, "must be a valid UUID");
}
self
}
#[must_use]
pub fn in_range<T: PartialOrd + Display>(
mut self,
field: &str,
value: T,
min: T,
max: T,
) -> Self {
if value < min || value > max {
self.add(field, format!("must be between {min} and {max}"));
}
self
}
#[must_use]
pub fn min_value<T: PartialOrd + Display>(mut self, field: &str, value: T, min: T) -> Self {
if value < min {
self.add(field, format!("must be at least {min}"));
}
self
}
#[must_use]
pub fn max_value<T: PartialOrd + Display>(mut self, field: &str, value: T, max: T) -> Self {
if value > max {
self.add(field, format!("must be {max} or less"));
}
self
}
#[must_use]
pub fn before(mut self, field: &str, value: &str, deadline: &str) -> Self {
match (
chrono::DateTime::parse_from_rfc3339(value),
chrono::DateTime::parse_from_rfc3339(deadline),
) {
(Ok(v), Ok(d)) if v >= d => {
self.add(field, format!("must be before {deadline}"));
}
(Err(_), _) => self.add(field, "must be a valid datetime"),
_ => {}
}
self
}
#[must_use]
pub fn after(mut self, field: &str, value: &str, floor: &str) -> Self {
match (
chrono::DateTime::parse_from_rfc3339(value),
chrono::DateTime::parse_from_rfc3339(floor),
) {
(Ok(v), Ok(f)) if v <= f => {
self.add(field, format!("must be after {floor}"));
}
(Err(_), _) => self.add(field, "must be a valid datetime"),
_ => {}
}
self
}
#[must_use]
pub fn one_of<T: PartialEq + Display>(mut self, field: &str, value: &T, allowed: &[T]) -> Self {
if !allowed.iter().any(|a| a == value) {
let list = allowed
.iter()
.map(|a| a.to_string())
.collect::<Vec<_>>()
.join(", ");
self.add(field, format!("must be one of: {list}"));
}
self
}
#[must_use]
pub fn custom(mut self, condition: bool, field: &str, message: &str) -> Self {
if !condition {
self.add(field, message);
}
self
}
#[must_use]
pub fn has_errors(&self) -> bool {
!self.errors.is_empty()
}
#[must_use]
pub fn errors(&self) -> &[FieldError] {
&self.errors
}
pub fn validate(self) -> AppResult<()> {
if self.errors.is_empty() {
return Ok(());
}
let detail = self
.errors
.iter()
.map(|e| format!("{}: {}", e.field, e.message))
.collect::<Vec<_>>()
.join("; ");
let fields_json =
serde_json::to_value(&self.errors).unwrap_or_else(|_| serde_json::Value::Array(vec![]));
Err(AppError::new(ErrorCode::InvalidInput, detail).with_detail("fields", fields_json))
}
fn add(&mut self, field: &str, message: impl Into<String>) {
self.errors.push(FieldError {
field: field.to_owned(),
message: message.into(),
});
}
}