From e0fffbc4b9480bbe6badc535c27961e685d12ed2 Mon Sep 17 00:00:00 2001 From: Benjamin Slingo Date: Thu, 28 Aug 2025 21:25:36 -0400 Subject: [PATCH] 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 --- src/error.rs | 4 ++ src/utils/validation.rs | 123 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/src/error.rs b/src/error.rs index 81622b5..4d2d055 100644 --- a/src/error.rs +++ b/src/error.rs @@ -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)) } diff --git a/src/utils/validation.rs b/src/utils/validation.rs index 6bc2e36..02ab55d 100644 --- a/src/utils/validation.rs +++ b/src/utils/validation.rs @@ -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) -> 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) -> 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, hymnal_code: &Option, number: &Option) -> 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"] }