Error-Proofing Your Web Service: A Rust Tutorial
The Importance of Input Validation
Input validation is vital in preventing security breaches and improving user experience. By validating user input, you can detect errors early on and provide constructive feedback, rather than displaying a generic “400 Bad Request” error.
Setting Up the Project
To follow along, you’ll need a recent Rust installation (1.39+) and a tool to make HTTP requests, such as cURL. Create a new Rust project and add the necessary dependencies to your Cargo.toml
file:
[dependencies]
warp = "0.3"
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_path_to_error = "0.4"
validator = "0.14"
validate_derive = "0.14"
JSON Body Validation
To demonstrate the default behavior in warp, let’s create a small web server with a single route. We’ll define a request object with fields to validate, such as email, address, and a list of pets:
#[derive(Deserialize)]
struct Request {
email: String,
address: String,
pets: Vec<String>,
}
async fn handler(req: Request) {
println!("{:?}", req);
}
Then, we’ll create a minimal handler that takes this JSON body and prints it.
Improving Error Handling
To enhance error handling, we’ll use the serde_path_to_error crate, which converts serde errors to the path in the JSON where they occurred. We’ll create another handler that uses warp::body::aggregate()
instead of warp::body::json()
, allowing us to intervene in the deserialization process:
use serde_path_to_error::ConvertError;
async fn improved_handler() {
let aggregate = warp::body::aggregate().await;
match aggregate {
Ok(req) => {
println!("{:?}", req);
}
Err(err) => {
let json_path_error = err.convert();
// Trigger a custom JSONPathError
}
}
}
Data Validation
Data validation involves ensuring that the provided data adheres to our specification. We’ll use the validator crate to declaratively define validation rules for structs and fields:
use validator::{validate, ValidationRule};
#[derive(Deserialize, Validate)]
struct Request {
#[validate(email)]
email: String,
#[validate(length(min = 1))]
address: String,
#[validate(length(min = 1))]
pets: Vec<String>,
}
This approach is powerful and customizable, allowing us to define rules for email validation, string length, and more.
Handling Validation Errors
When validation fails, we’ll propagate a custom error with the relevant information. We’ll define a FieldError structure to map errors to the fields in which they occurred:
struct FieldError {
field: String,
message: String,
}
impl FieldError {
fn new(field: &str, message: &str) -> Self {
FieldError {
field: field.to_string(),
message: message.to_string(),
}
}
}
For nested structures, we’ll use recursion to parse the validation errors.
Putting it All Together
Let’s see how our improved error handling and data validation work in practice. We’ll send invalid requests and observe the informative error messages:
$ curl -X POST -H "Content-Type: application/json" -d '{"email": "invalid", "address": "", "pets": []}' http://localhost:3030
We’ll also explore how to handle nested errors and create a structured error response.
- Send an invalid request with a missing email field
- Observe the error message indicating the missing email field
- Send an invalid request with an invalid email address
- Observe the error message indicating the invalid email address
By using Rust and its ecosystem, we can create robust and user-friendly error handling mechanisms.