use crate::Error;
use regex::Regex;
use std::collections::HashMap;
use std::sync::{Arc, LazyLock};
static UUID_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
.expect("UUID regex is valid")
});
static EMAIL_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").expect("email regex is valid")
});
pub trait RouteConstraint: Send + Sync {
fn validate(&self, value: &str) -> Result<(), String>;
fn description(&self) -> &str;
}
#[derive(Debug, Clone)]
pub struct IntConstraint;
impl RouteConstraint for IntConstraint {
fn validate(&self, value: &str) -> Result<(), String> {
value
.parse::<i64>()
.map(|_| ())
.map_err(|_| format!("'{}' is not a valid integer", value))
}
fn description(&self) -> &str {
"integer"
}
}
#[derive(Debug, Clone)]
pub struct UIntConstraint;
impl RouteConstraint for UIntConstraint {
fn validate(&self, value: &str) -> Result<(), String> {
value
.parse::<u64>()
.map(|_| ())
.map_err(|_| format!("'{}' is not a valid unsigned integer", value))
}
fn description(&self) -> &str {
"unsigned integer"
}
}
#[derive(Debug, Clone)]
pub struct FloatConstraint;
impl RouteConstraint for FloatConstraint {
fn validate(&self, value: &str) -> Result<(), String> {
value
.parse::<f64>()
.map(|_| ())
.map_err(|_| format!("'{}' is not a valid float", value))
}
fn description(&self) -> &str {
"float"
}
}
#[derive(Debug, Clone)]
pub struct AlphaConstraint;
impl RouteConstraint for AlphaConstraint {
fn validate(&self, value: &str) -> Result<(), String> {
if value.chars().all(|c| c.is_alphabetic()) {
Ok(())
} else {
Err(format!("'{}' must contain only letters", value))
}
}
fn description(&self) -> &str {
"alphabetic"
}
}
#[derive(Debug, Clone)]
pub struct AlphaNumConstraint;
impl RouteConstraint for AlphaNumConstraint {
fn validate(&self, value: &str) -> Result<(), String> {
if value.chars().all(|c| c.is_alphanumeric()) {
Ok(())
} else {
Err(format!("'{}' must contain only letters and numbers", value))
}
}
fn description(&self) -> &str {
"alphanumeric"
}
}
#[derive(Debug, Clone)]
pub struct UuidConstraint;
impl RouteConstraint for UuidConstraint {
fn validate(&self, value: &str) -> Result<(), String> {
if UUID_REGEX.is_match(value) {
Ok(())
} else {
Err(format!("'{}' is not a valid UUID", value))
}
}
fn description(&self) -> &str {
"UUID"
}
}
#[derive(Debug, Clone)]
pub struct EmailConstraint;
impl RouteConstraint for EmailConstraint {
fn validate(&self, value: &str) -> Result<(), String> {
if EMAIL_REGEX.is_match(value) {
Ok(())
} else {
Err(format!("'{}' is not a valid email address", value))
}
}
fn description(&self) -> &str {
"email address"
}
}
pub struct RegexConstraint {
regex: Regex,
description: String,
}
impl RegexConstraint {
pub fn new(pattern: &str, description: &str) -> Result<Self, regex::Error> {
Ok(Self {
regex: Regex::new(pattern)?,
description: description.to_string(),
})
}
}
impl RouteConstraint for RegexConstraint {
fn validate(&self, value: &str) -> Result<(), String> {
if self.regex.is_match(value) {
Ok(())
} else {
Err(format!(
"'{}' must match pattern: {}",
value, self.description
))
}
}
fn description(&self) -> &str {
&self.description
}
}
#[derive(Debug, Clone)]
pub struct LengthConstraint {
min: Option<usize>,
max: Option<usize>,
}
impl LengthConstraint {
pub fn new(min: Option<usize>, max: Option<usize>) -> Self {
Self { min, max }
}
pub fn min(min: usize) -> Self {
Self {
min: Some(min),
max: None,
}
}
pub fn max(max: usize) -> Self {
Self {
min: None,
max: Some(max),
}
}
pub fn exact(length: usize) -> Self {
Self {
min: Some(length),
max: Some(length),
}
}
}
impl RouteConstraint for LengthConstraint {
fn validate(&self, value: &str) -> Result<(), String> {
let len = value.len();
if let Some(min) = self.min
&& len < min
{
return Err(format!("'{}' must be at least {} characters", value, min));
}
if let Some(max) = self.max
&& len > max
{
return Err(format!("'{}' must be at most {} characters", value, max));
}
Ok(())
}
fn description(&self) -> &str {
match (self.min, self.max) {
(Some(min), Some(max)) if min == max => "exact length",
(Some(_), Some(_)) => "length range",
(Some(_), None) => "minimum length",
(None, Some(_)) => "maximum length",
(None, None) => "any length",
}
}
}
#[derive(Debug, Clone)]
pub struct RangeConstraint {
min: Option<i64>,
max: Option<i64>,
}
impl RangeConstraint {
pub fn new(min: Option<i64>, max: Option<i64>) -> Self {
Self { min, max }
}
pub fn min(min: i64) -> Self {
Self {
min: Some(min),
max: None,
}
}
pub fn max(max: i64) -> Self {
Self {
min: None,
max: Some(max),
}
}
}
impl RouteConstraint for RangeConstraint {
fn validate(&self, value: &str) -> Result<(), String> {
let num = value
.parse::<i64>()
.map_err(|_| format!("'{}' is not a valid number", value))?;
if let Some(min) = self.min
&& num < min
{
return Err(format!("'{}' must be at least {}", value, min));
}
if let Some(max) = self.max
&& num > max
{
return Err(format!("'{}' must be at most {}", value, max));
}
Ok(())
}
fn description(&self) -> &str {
match (self.min, self.max) {
(Some(_), Some(_)) => "number in range",
(Some(_), None) => "minimum value",
(None, Some(_)) => "maximum value",
(None, None) => "any number",
}
}
}
#[derive(Debug, Clone)]
pub struct EnumConstraint {
values: Vec<String>,
}
impl EnumConstraint {
pub fn new(values: Vec<String>) -> Self {
Self { values }
}
}
impl RouteConstraint for EnumConstraint {
fn validate(&self, value: &str) -> Result<(), String> {
if self.values.contains(&value.to_string()) {
Ok(())
} else {
Err(format!(
"'{}' must be one of: {}",
value,
self.values.join(", ")
))
}
}
fn description(&self) -> &str {
"enum value"
}
}
#[derive(Default)]
pub struct RouteConstraints {
constraints: HashMap<String, Arc<dyn RouteConstraint>>,
}
impl RouteConstraints {
pub fn new() -> Self {
Self::default()
}
pub fn add(mut self, param: impl Into<String>, constraint: Box<dyn RouteConstraint>) -> Self {
self.constraints.insert(param.into(), Arc::from(constraint));
self
}
pub fn add_mut(&mut self, param: impl Into<String>, constraint: Box<dyn RouteConstraint>) {
self.constraints.insert(param.into(), Arc::from(constraint));
}
pub fn validate(&self, params: &crate::RouteParams) -> Result<(), Error> {
for (param_name, constraint) in &self.constraints {
let Some((_, raw)) = params.iter().find(|(k, _)| *k == param_name) else {
continue;
};
let value = std::str::from_utf8(raw).map_err(|_| {
Error::BadRequest(format!(
"Invalid route parameter '{}': not valid UTF-8",
param_name
))
})?;
constraint.validate(value).map_err(|msg| {
Error::BadRequest(format!("Invalid route parameter '{}': {}", param_name, msg))
})?;
}
Ok(())
}
pub fn is_empty(&self) -> bool {
self.constraints.is_empty()
}
pub fn len(&self) -> usize {
self.constraints.len()
}
}
impl Clone for RouteConstraints {
fn clone(&self) -> Self {
Self {
constraints: self.constraints.clone(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use bytes::Bytes;
#[test]
fn test_int_constraint() {
let constraint = IntConstraint;
assert!(constraint.validate("123").is_ok());
assert!(constraint.validate("-456").is_ok());
assert!(constraint.validate("abc").is_err());
assert!(constraint.validate("12.5").is_err());
}
#[test]
fn test_uint_constraint() {
let constraint = UIntConstraint;
assert!(constraint.validate("123").is_ok());
assert!(constraint.validate("0").is_ok());
assert!(constraint.validate("-456").is_err());
assert!(constraint.validate("abc").is_err());
}
#[test]
fn test_alpha_constraint() {
let constraint = AlphaConstraint;
assert!(constraint.validate("abc").is_ok());
assert!(constraint.validate("ABC").is_ok());
assert!(constraint.validate("abc123").is_err());
assert!(constraint.validate("abc-def").is_err());
}
#[test]
fn test_alphanum_constraint() {
let constraint = AlphaNumConstraint;
assert!(constraint.validate("abc123").is_ok());
assert!(constraint.validate("ABC").is_ok());
assert!(constraint.validate("123").is_ok());
assert!(constraint.validate("abc-def").is_err());
assert!(constraint.validate("abc 123").is_err());
}
#[test]
fn test_uuid_constraint() {
let constraint = UuidConstraint;
assert!(
constraint
.validate("550e8400-e29b-41d4-a716-446655440000")
.is_ok()
);
assert!(constraint.validate("not-a-uuid").is_err());
assert!(constraint.validate("12345").is_err());
}
#[test]
fn test_email_constraint() {
let constraint = EmailConstraint;
assert!(constraint.validate("user@example.com").is_ok());
assert!(constraint.validate("test.user@domain.co.uk").is_ok());
assert!(constraint.validate("invalid-email").is_err());
assert!(constraint.validate("@example.com").is_err());
}
#[test]
fn test_length_constraint() {
let constraint = LengthConstraint::new(Some(3), Some(10));
assert!(constraint.validate("hello").is_ok());
assert!(constraint.validate("hi").is_err());
assert!(constraint.validate("verylongstring").is_err());
}
#[test]
fn test_length_constraint_min() {
let constraint = LengthConstraint::min(5);
assert!(constraint.validate("hello").is_ok());
assert!(constraint.validate("verylongstring").is_ok());
assert!(constraint.validate("hi").is_err());
}
#[test]
fn test_length_constraint_max() {
let constraint = LengthConstraint::max(10);
assert!(constraint.validate("hello").is_ok());
assert!(constraint.validate("hi").is_ok());
assert!(constraint.validate("verylongstring").is_err());
}
#[test]
fn test_length_constraint_exact() {
let constraint = LengthConstraint::exact(5);
assert!(constraint.validate("hello").is_ok());
assert!(constraint.validate("hi").is_err());
assert!(constraint.validate("toolong").is_err());
}
#[test]
fn test_range_constraint() {
let constraint = RangeConstraint::new(Some(1), Some(100));
assert!(constraint.validate("50").is_ok());
assert!(constraint.validate("1").is_ok());
assert!(constraint.validate("100").is_ok());
assert!(constraint.validate("0").is_err());
assert!(constraint.validate("101").is_err());
assert!(constraint.validate("abc").is_err());
}
#[test]
fn test_enum_constraint() {
let constraint = EnumConstraint::new(vec![
"active".to_string(),
"inactive".to_string(),
"pending".to_string(),
]);
assert!(constraint.validate("active").is_ok());
assert!(constraint.validate("pending").is_ok());
assert!(constraint.validate("unknown").is_err());
}
#[test]
fn test_route_constraints() {
let constraints = RouteConstraints::new()
.add("id", Box::new(IntConstraint))
.add("name", Box::new(AlphaConstraint));
let mut params = crate::RouteParams::new();
params.push((
crate::param_intern::intern("id"),
Bytes::from_static(b"123"),
));
params.push((
crate::param_intern::intern("name"),
Bytes::from_static(b"john"),
));
assert!(constraints.validate(¶ms).is_ok());
let mut bad_params = crate::RouteParams::new();
bad_params.push((
crate::param_intern::intern("id"),
Bytes::from_static(b"abc"),
));
bad_params.push((
crate::param_intern::intern("name"),
Bytes::from_static(b"john"),
));
assert!(constraints.validate(&bad_params).is_err());
}
#[test]
fn test_non_utf8_param_is_rejected_not_skipped() {
let constraints = RouteConstraints::new().add("id", Box::new(IntConstraint));
let mut params = crate::RouteParams::new();
params.push((
crate::param_intern::intern("id"),
Bytes::from_static(&[0xff, 0xfe]),
));
let err = constraints.validate(¶ms).unwrap_err();
assert!(
matches!(&err, Error::BadRequest(msg) if msg.contains("'id'")),
"{err:?}"
);
let mut other = crate::RouteParams::new();
other.push((
crate::param_intern::intern("slug"),
Bytes::from_static(&[0xff, 0xfe]),
));
assert!(constraints.validate(&other).is_ok());
}
}