Data validation is a cornerstone of robust applications. It ensures that the data your application processes conforms to expected formats and business rules, preventing errors and maintaining data integrity.
If you're familiar with Django REST Framework (DRF) in Python, you'll appreciate its powerful and declarative validator system.
In this article, we'll explore how to build a type-safe and flexible validator system in Rust, taking cues from DRF's elegant design. We'll start with a basic structure and progressively enhance it to create a more practical and feature-rich solution.
Defining the Validators
Struct
Let's begin by opening a base Rust structure for our validator system:
type InputDataType = u32;
struct Validators<T> where T : Fn(InputDataType) -> bool {
validators: Vec<T>,
data: InputDataType
}
impl <T>Validators<T> where T : Fn(InputDataType) -> bool {
fn new(validators: Vec<T>, data: InputDataType) -> Self {
Validators { validators, data }
}
fn validate(&self) -> bool {
self.validators.iter().all(|f| f(self.data))
}
}
Let's break down this code piece by piece:
type InputDataType = u32;
: This line defines a type aliasInputDataType
asu32
. This signifies the type of data we intend to validate. In a real-world scenario, this could be a struct, enum, like a JSON string or even a trait object, or any other complex data type. For simplicity in this example, we're using an unsigned 32-bit integer.struct Validators<T> where T : Fn(u32) -> bool { ... }
: This is the heart of our validator system.struct Validators<T>
: We define a generic struct namedValidators
. The<T>
indicates that Validators is generic over a type parameterT
.where T : Fn(InputDataType) -> bool
: This is a crucial constraint. It dictates that the typeT
must be a function (or a closure) that:Takes a
InputDataType
as input matching our data field.Returns a
bool
value, indicating whether the validation passed (true) or failed (false).
validators: Vec<T>
: This field holds aVec
(vector) of the validator functions of typeT
. This is where we store our collection of validation rules.data: InputDataType
: This field stores the actual data of typeInputDataType
that we want to validate.
impl <T>Validators<T> where T : Fn(InputDataType) -> bool { ... }
: This is the implementation block for our Validators struct. Again, we use generics and the same trait bound as in the struct definition.fn new(validators: Vec<T>, data: InputDataType) -> Self { ... }
: This is a constructor function namednew
.It takes a
Vec<T>
of validator functions and theInputDataType
as arguments.It returns
Self
, which is shorthand forValidators<T>
, creating a new instance of theValidators
struct with the provided values.
fn validate(&self) -> bool { ... }
: This is the core validation logic.&self
: It takes an immutable reference to theValidators
instance, allowing us to access its fields without taking ownership.-> bool
: It returns abool
value indicating the overall validation result.self.validators.iter().all(|f| f(self.data))
: This is the validation logic itself:self.validators.iter()
: We iterate over thevalidators
vector using.iter()
, which provides immutable references to each validator function..all(|f| !f(self.data))
: This is a higher-order function call..all()
is a method on iterators that checks if all element in the iterator satisfies a given predicate (a boolean-returning function).|f| f(self.data)
: This is a closure (an anonymous function). For each validator functionf
in thevalidators
vector, it callsf(self.data)
. This executes the validator function with the input data..all()
will returntrue
if all the validator functions returntrue
. If all validator functions returnfalse
,.all()
will returnfalse.
In essence, the current validate
function checks if all of the provided validators pass for the given data. If all validators are satisfied, the overall validation is considered successful.
Enhancing the Validator System
While the core structure is functional, it has limitations and can be significantly improved to be more practical and aligned with the flexibility of DRF validators. Let's identify areas for enhancement and implement them:
Adding Validators Dynamically:
Currently, validators are provided only at the time of creating a
Validators
instance through thenew
function. It would be more flexible to add validators incrementally after theValidators
object is created.Returning Descriptive Validation Errors:
The current
validate
function simply returns abool
. In real-world applications, it's crucial to know why validation failed. We need to return more informative error messages or structured error data.
Let's implement these enhancements step by step.
Adding Validators Dynamically
We can add a method add_validator to the Validators struct:
impl <T>Validators<T> where T : Fn(u32) -> bool {
// ... (new function and validate from before) ...
fn add_validator(&mut self, validator: T) {
self.validators.push(validator);
}
}
This add_validator
function takes a mutable reference to self (&mut self)
and a new validator function of type T
. It simply pushes the new validator to the validators
vector using .push()
.
Now you can create a Validators
instance and add validators to it later:
let mut validator_system: Validators<Box<dyn Fn(u32) -> bool>> = Validators::new(vec![], 15); // Start with no validators
validator_system.add_validator(Box::new(|data| data > 10));
validator_system.add_validator(Box::new(|data| data % 2 != 0)); // Odd number validator
let is_valid = validator_system.validate();
println!("Is valid (any): {}", is_valid); // Output: Is valid (any): true
[!NOTE] We had to change the type of T
in Validators
to Box<dyn Fn(u32) -> bool>
. This is because trait objects (like Fn(u32) -> bool)
are not sized, and to store them in a Vec
, we need to put them behind a pointer, in this case, a Box. Box<dyn Trait>
is a common way to work with trait objects in Rust.
Returning Descriptive Validation Errors
Returning just a bool
is limiting. Let's upgrade our system to return a Result
that can carry error messages when validation fails.
First, let's define a custom error type (for simplicity, we'll use String
for error messages, but in a real application, you'd likely create a more structured error enum or struct) probably using the thiserror
crate:
type ValidationResult = Result<bool, String>;
Now, let's modify our validate functions to return ValidationResult
:
impl <T>Validators<T> where T : Fn(u32) -> bool {
// ... (new function and add_validator from before) ...
fn validate(&self) -> ValidationResult {
let mut errors = Vec::new();
for validator in &self.validators {
if !validator(self.data) {
// For a real system, you might want to collect error messages
errors.push("Validation failed for a rule".to_string());
}
}
if errors.is_empty() {
Ok(true) // All validators passed
} else {
Err(format!("Validation failed for all rules: {:?}", errors)) // Some validators failed
}
}
}
In validate
:
We iterate through all validators.
If a validator returns
false
, we add a generic error message to anerrors
vector.If
errors
remains empty after checking all validators (meaning all passed), we returnOk(true)
.Otherwise, we return
Err
containing a formatted error message (currently just listing generic messages, but you could enhance this to collect more specific errors).
Now, when you call validate, you get a Result
that you can handle:
let mut validator_system: Validators<Box<dyn Fn(u32) -> bool>> = Validators::new(vec![], 15);
validator_system.add_validator(Box::new(|data| data > 10));
validator_system.add_validator(Box::new(|data| data % 2 != 0));
validator_system.add_validator(Box::new(|data| data < 15)); // This one will fail
let result = validator_system.validate();
match result {
Ok(_) => println!("Validation (any) passed!"),
Err(err) => println!("Validation (any) failed: {}", err),
} // Output: Validation (any) passed!
Complete Code Example
Here's the complete enhanced code incorporating all the improvements:
type InputDataType = u32;
type ValidationResult = Result<bool, String>;
struct Validators<T> where T : Fn(u32) -> bool {
validators: Vec<T>,
data: InputDataType
}
impl <T>Validators<T> where T : Fn(u32) -> bool {
fn new(validators: Vec<T>, data: InputDataType) -> Self {
Validators { validators, data }
}
fn add_validator(&mut self, validator: T) {
self.validators.push(validator);
}
fn valiadte(&self) -> ValidationResult {
let mut errors = Vec::new();
for validator in &self.validators {
if !validator(self.data) {
// For a real system, you might want to collect error messages
errors.push("Validation failed for a rule".to_string());
}
}
if errors.is_empty() {
Ok(true) // All validators passed
} else {
Err(format!("Validation failed for all rules: {:?}", errors)) // Some validators failed
}
}
}
fn main() {
// Example Usage:
// Using Box<dyn Fn(u32) -> bool> to store closures in Vec
let mut validator_system: Validators<Box<dyn Fn(u32) -> bool>> = Validators::new(vec![], 15);
validator_system.add_validator(Box::new(|data| data > 10));
validator_system.add_validator(Box::new(|data| data % 2 != 0));
validator_system.add_validator(Box::new(|data| data < 20));
println!("Validating data: {}", validator_system.data);
let result = validator_system.validate();
match result {
Ok(_) => println!("Validation (all) passed!"),
Err(err) => println!("Validation (all) failed: {}", err),
}
}
Conclusion
We've built a basic yet functional validator system in Rust inspired by the principles of Django REST Framework validators. It allows you to define a set of validation rules (as functions or closures) and apply them to your data. We enhanced the system to:
Dynamically add validators.
Return more informative validation
results
using Result and error messages.
In summary, you have just seen the core basics of how such a validation system might work in Rust. If you have any questions or you wish I write another article explaining how you could implement validators for your serde serialization, feel free to reach out to me on my linkedin.