use std::collections::HashMap;
use garde::rules::{length, pattern, range};
use regex::Regex;
use serde::{Deserialize, Serialize};
use crate::blueprint::Blueprint;
use crate::raw::{Row, Value};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PurifyError {
pub field: String,
pub rule: String,
pub message: String,
}
pub trait SyncRule: Send + Sync {
fn validate(&self, field_name: &str, value: &Value) -> Result<(), String>;
}
pub struct Purifier {
custom_rules: HashMap<String, Box<dyn SyncRule>>,
}
impl Default for Purifier {
fn default() -> Self {
Self::new()
}
}
impl Purifier {
pub fn new() -> Self {
Self {
custom_rules: HashMap::new(),
}
}
pub fn register_rule(&mut self, name: impl Into<String>, rule: Box<dyn SyncRule>) {
self.custom_rules.insert(name.into(), rule);
}
pub fn purify_row(
&self,
blueprint: &Blueprint,
model_name: &str,
row: &Row,
) -> Result<(), Vec<PurifyError>> {
let model = match blueprint.models.get(model_name) {
Some(m) => m,
None => {
return Err(vec![PurifyError {
field: "model".to_owned(),
rule: "unknown_model".to_owned(),
message: format!("Unknown model: {}", model_name),
}]);
}
};
let mut errors = Vec::new();
for (field_name, field) in &model.fields {
let value = row.get(field_name);
let metadata = &field.options.metadata;
let is_required = metadata
.get("required")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if is_required && value.map(Value::is_null).unwrap_or(true) {
errors.push(PurifyError {
field: field_name.clone(),
rule: "required".to_owned(),
message: format!("Field '{}' is required", field_name),
});
continue;
}
let Some(value) = value else { continue };
if value.is_null() {
continue;
}
if let Some(val) = value.as_f64() {
let min = metadata.get("min").and_then(|v| v.as_f64());
let max = metadata.get("max").and_then(|v| v.as_f64());
if min.is_some() || max.is_some() {
if let Err(e) = range::apply(&val, (min, max)) {
let rule = if val < min.unwrap_or(f64::NEG_INFINITY) { "min" } else { "max" };
errors.push(PurifyError {
field: field_name.clone(),
rule: rule.to_owned(),
message: e.to_string(),
});
}
}
}
if let Some(s) = value.as_str() {
let min_len = metadata.get("minLength").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let max_len = metadata
.get("maxLength")
.and_then(|v| v.as_u64())
.unwrap_or(usize::MAX as u64) as usize;
if metadata.contains_key("minLength") || metadata.contains_key("maxLength") {
if let Err(e) = length::simple::apply(&s, (min_len, max_len)) {
let rule = if s.len() < min_len { "minLength" } else { "maxLength" };
errors.push(PurifyError {
field: field_name.clone(),
rule: rule.to_owned(),
message: e.to_string(),
});
}
}
if let Some(pat) = metadata.get("pattern").and_then(|v| v.as_str()) {
match Regex::new(pat) {
Ok(re) => {
if let Err(e) = pattern::apply(&s, (&re,)) {
errors.push(PurifyError {
field: field_name.clone(),
rule: "pattern".to_owned(),
message: e.to_string(),
});
}
}
Err(e) => {
errors.push(PurifyError {
field: field_name.clone(),
rule: "pattern".to_owned(),
message: format!("Invalid pattern '{}': {}", pat, e),
});
}
}
}
}
if let Some(rule_name) = metadata.get("purifyAs").and_then(|v| v.as_str()) {
if let Some(rule) = self.custom_rules.get(rule_name) {
if let Err(msg) = rule.validate(field_name, value) {
errors.push(PurifyError {
field: field_name.clone(),
rule: rule_name.to_owned(),
message: msg,
});
}
}
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
pub fn purify_row_sync(
blueprint: &Blueprint,
model_name: &str,
row: &Row,
) -> Result<(), Vec<PurifyError>> {
Purifier::default().purify_row(blueprint, model_name, row)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Blueprint, Field, Model};
use indexmap::IndexMap;
use serde_json::json;
#[test]
fn test_required() {
let blueprint = Blueprint::new().model(
"user",
Model::new().field(
"name",
Field::string("Name").with_metadata("required", json!(true)),
),
);
let row: Row = IndexMap::new();
let result = purify_row_sync(&blueprint, "user", &row);
assert!(result.is_err());
assert_eq!(result.unwrap_err()[0].rule, "required");
}
#[test]
fn test_min_length() {
let blueprint = Blueprint::new().model(
"user",
Model::new().field(
"name",
Field::string("Name")
.with_metadata("required", json!(true))
.with_metadata("minLength", json!(2)),
),
);
let row = IndexMap::from([("name".to_owned(), json!("A"))]);
let result = purify_row_sync(&blueprint, "user", &row);
assert!(result.is_err());
assert_eq!(result.unwrap_err()[0].rule, "minLength");
}
#[test]
fn test_valid_row() {
let blueprint = Blueprint::new().model(
"user",
Model::new()
.field(
"name",
Field::string("Name")
.with_metadata("required", json!(true))
.with_metadata("minLength", json!(2)),
)
.field(
"age",
Field::number("Age").with_metadata("min", json!(18)),
),
);
let row = IndexMap::from([
("name".to_owned(), json!("Alice")),
("age".to_owned(), json!(25)),
]);
assert!(purify_row_sync(&blueprint, "user", &row).is_ok());
}
#[test]
fn test_pattern() {
let blueprint = Blueprint::new().model(
"user",
Model::new().field(
"code",
Field::string("Code").with_metadata("pattern", json!(r"^\d{4}$")),
),
);
let row = IndexMap::from([("code".to_owned(), json!("1234"))]);
assert!(purify_row_sync(&blueprint, "user", &row).is_ok());
let row = IndexMap::from([("code".to_owned(), json!("abcd"))]);
let result = purify_row_sync(&blueprint, "user", &row);
assert!(result.is_err());
assert_eq!(result.unwrap_err()[0].rule, "pattern");
}
#[test]
fn test_custom_sync_rule() {
struct AllowListRule(Vec<String>);
impl SyncRule for AllowListRule {
fn validate(&self, _field_name: &str, value: &Value) -> Result<(), String> {
let s = value.as_str().unwrap_or("");
if self.0.iter().any(|v| v == s) {
Ok(())
} else {
Err(format!("'{}' is not an allowed value", s))
}
}
}
let blueprint = Blueprint::new().model(
"item",
Model::new().field(
"status",
Field::string("Status").with_metadata("purifyAs", json!("allowlist")),
),
);
let mut purifier = Purifier::new();
purifier.register_rule(
"allowlist",
Box::new(AllowListRule(vec!["active".into(), "inactive".into()])),
);
let row = IndexMap::from([("status".to_owned(), json!("active"))]);
assert!(purifier.purify_row(&blueprint, "item", &row).is_ok());
let row = IndexMap::from([("status".to_owned(), json!("unknown"))]);
let result = purifier.purify_row(&blueprint, "item", &row);
assert!(result.is_err());
assert_eq!(result.unwrap_err()[0].rule, "allowlist");
}
}