Phase 1.4: Enhanced input validation system

- Add comprehensive validation methods for dates, UUIDs, ranges, files
- Domain-specific validation functions for bulletins, events, hymnal search
- Enhanced recurring pattern validation with specific error types
- Better date range validation with improved error messages
- Foundation for consistent input validation across all endpoints
- All using new specific error types for better debugging
This commit is contained in:
Benjamin Slingo 2025-08-28 21:25:36 -04:00
parent ad43a19f7c
commit e0fffbc4b9
2 changed files with 126 additions and 1 deletions

View file

@ -212,6 +212,10 @@ impl ApiError {
Self::InvalidDateRange(format!("Invalid date range: {} to {}", start, end))
}
pub fn invalid_recurring_pattern(pattern: impl std::fmt::Display) -> Self {
Self::InvalidRecurringPattern(format!("Invalid recurring pattern: {}", pattern))
}
pub fn duplicate_entry(resource: &str, identifier: impl std::fmt::Display) -> Self {
Self::DuplicateEntry(format!("{} already exists: {}", resource, identifier))
}

View file

@ -1,5 +1,7 @@
use crate::error::{ApiError, Result};
use regex::Regex;
use chrono::{NaiveDate, NaiveDateTime};
use uuid::Uuid;
#[derive(Clone)]
pub struct ValidationBuilder {
@ -59,6 +61,63 @@ impl ValidationBuilder {
self
}
pub fn validate_date(mut self, date_str: &str, field_name: &str) -> Self {
if !date_str.is_empty() {
if let Err(_) = NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
self.errors.push(format!("{} must be a valid date in YYYY-MM-DD format", field_name));
}
}
self
}
pub fn validate_datetime(mut self, datetime_str: &str, field_name: &str) -> Self {
if !datetime_str.is_empty() {
if let Err(_) = NaiveDateTime::parse_from_str(datetime_str, "%Y-%m-%dT%H:%M:%S") {
self.errors.push(format!("{} must be a valid datetime in ISO format", field_name));
}
}
self
}
pub fn validate_uuid(mut self, uuid_str: &str, field_name: &str) -> Self {
if !uuid_str.is_empty() {
if let Err(_) = Uuid::parse_str(uuid_str) {
self.errors.push(format!("{} must be a valid UUID", field_name));
}
}
self
}
pub fn validate_positive_number(mut self, num: i32, field_name: &str) -> Self {
if num <= 0 {
self.errors.push(format!("{} must be a positive number", field_name));
}
self
}
pub fn validate_range(mut self, num: i32, field_name: &str, min: i32, max: i32) -> Self {
if num < min || num > max {
self.errors.push(format!("{} must be between {} and {}", field_name, min, max));
}
self
}
pub fn validate_file_extension(mut self, filename: &str, field_name: &str, allowed_extensions: &[&str]) -> Self {
if !filename.is_empty() {
let extension = filename.split('.').last().unwrap_or("").to_lowercase();
if !allowed_extensions.contains(&extension.as_str()) {
self.errors.push(format!("{} must have one of these extensions: {}", field_name, allowed_extensions.join(", ")));
}
}
self
}
pub fn validate_content_length(mut self, content: &str, field_name: &str, max_length: usize) -> Self {
if content.len() > max_length {
self.errors.push(format!("{} content is too long (max {} characters)", field_name, max_length));
}
self
}
pub fn build(self) -> Result<()> {
if self.errors.is_empty() {
@ -76,13 +135,75 @@ pub fn validate_recurring_type(recurring_type: &Option<String>) -> Result<()> {
if let Some(rt) = recurring_type {
match rt.as_str() {
"none" | "daily" | "weekly" | "biweekly" | "monthly" | "first_tuesday" | "2nd/3rd Saturday Monthly" | "2nd_3rd_saturday_monthly" => Ok(()),
_ => Err(ApiError::ValidationError("Invalid recurring type. Must be one of: none, daily, weekly, biweekly, monthly, first_tuesday, 2nd_3rd_saturday_monthly".to_string())),
_ => Err(ApiError::invalid_recurring_pattern(rt)),
}
} else {
Ok(())
}
}
/// Domain-specific validation functions using our enhanced error types
pub fn validate_date_range(start_date: &str, end_date: &str) -> Result<()> {
let start = NaiveDate::parse_from_str(start_date, "%Y-%m-%d")
.map_err(|_| ApiError::ValidationError("Invalid start date format".to_string()))?;
let end = NaiveDate::parse_from_str(end_date, "%Y-%m-%d")
.map_err(|_| ApiError::ValidationError("Invalid end date format".to_string()))?;
if end < start {
return Err(ApiError::invalid_date_range(start_date, end_date));
}
Ok(())
}
/// Validate bulletin-specific fields
pub fn validate_bulletin_data(title: &str, date_str: &str, content: &Option<String>) -> Result<()> {
ValidationBuilder::new()
.require(title, "title")
.validate_length(title, "title", 1, 200)
.validate_date(date_str, "date")
.build()?;
if let Some(content) = content {
ValidationBuilder::new()
.validate_content_length(content, "content", 50000)
.build()?;
}
Ok(())
}
/// Validate event-specific fields
pub fn validate_event_data(title: &str, description: &str, location: &str, category: &str) -> Result<()> {
ValidationBuilder::new()
.require(title, "title")
.require(description, "description")
.require(location, "location")
.require(category, "category")
.validate_length(title, "title", 1, 200)
.validate_length(description, "description", 1, 2000)
.validate_length(location, "location", 1, 200)
.validate_length(category, "category", 1, 50)
.build()
}
/// Validate hymnal search parameters
pub fn validate_hymnal_search(query: &Option<String>, hymnal_code: &Option<String>, number: &Option<i32>) -> Result<()> {
if let Some(q) = query {
ValidationBuilder::new()
.validate_length(q, "search query", 1, 100)
.build()?;
}
if let Some(num) = number {
ValidationBuilder::new()
.validate_range(*num, "hymn number", 1, 9999)
.build()?;
}
Ok(())
}
pub fn get_valid_recurring_types() -> Vec<&'static str> {
vec!["none", "daily", "weekly", "biweekly", "monthly", "first_tuesday", "2nd_3rd_saturday_monthly"]
}