Exercise: Validation Errors for AI

ch05-01-validation-errors
⭐⭐ intermediate ⏱️ 25 min

You're improving an MCP server that has poor validation. When AI clients send invalid parameters, they get unhelpful errors like "Invalid input" and can't self-correct. Your task is to implement AI-friendly validation with clear, actionable error messages.

🎯 Learning Objectives

Thinking

Doing

💬 Discussion

  • When an AI gets "Invalid input", what can it do? What about "expected: 2024-11-15, received: November 15"?
  • Why is silent coercion (using defaults for invalid values) bad for AI clients?
  • How might an AI use error codes like RATE_LIMITED vs NOT_FOUND differently?
src/validation.rs

💡 Hints

Hint 1: ValidationError structure

Design the struct with fields that help AI understand and fix the error:

#![allow(unused)]
fn main() {
#[derive(Debug, Serialize)]
pub struct ValidationError {
    pub code: String,       // e.g., "MISSING_REQUIRED_FIELD"
    pub field: String,      // e.g., "date_range.start"
    pub message: String,    // Human-readable explanation
    pub expected: Option<String>,  // What was expected
    pub received: Option<String>,  // What was sent
}
}
Hint 2: Constructor patterns

Create constructors for common error types:

#![allow(unused)]
fn main() {
impl ValidationError {
    pub fn missing_field(field: &str) -> Self {
        Self {
            code: "MISSING_REQUIRED_FIELD".to_string(),
            field: field.to_string(),
            message: format!("At least one filter is required: {}", field),
            expected: Some("A value for one of the filter fields".to_string()),
            received: None,
        }
    }
pub fn invalid_format(field: &amp;str, expected_format: &amp;str, example: &amp;str, received: &amp;str) -&gt; Self {
    Self {
        code: "INVALID_FORMAT".to_string(),
        field: field.to_string(),
        message: format!("Field '{}' has invalid format", field),
        expected: Some(format!("{} (e.g., {})", expected_format, example)),
        received: Some(received.to_string()),
    }
}
}

}

Hint 3: Date validation

For validating ISO 8601 dates:

#![allow(unused)]
fn main() {
fn is_valid_iso_date(s: &str) -> bool {
    // Simple check: YYYY-MM-DD format
    if s.len() != 10 {
        return false;
    }
    let parts: Vec<&str> = s.split('-').collect();
    if parts.len() != 3 {
        return false;
    }
    parts[0].len() == 4 && parts[1].len() == 2 && parts[2].len() == 2
        && parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit()))
}
}
⚠️ Try the exercise first! Show Solution
#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

#[derive(Debug, Serialize)] pub struct ValidationError { pub code: String, pub field: String, pub message: String, #[serde(skip_serializing_if = "Option::is_none")] pub expected: Option<String>, #[serde(skip_serializing_if = "Option::is_none")] pub received: Option<String>, }

impl ValidationError { pub fn missing_field(field: &str) -> Self { Self { code: "MISSING_REQUIRED_FIELD".to_string(), field: field.to_string(), message: format!("Required field '{}' is missing or no filters provided", field), expected: Some(format!("A value for '{}'", field)), received: None, } }

pub fn invalid_type(field: &amp;str, expected: &amp;str, received: &amp;str) -&gt; Self {
    Self {
        code: &quot;INVALID_TYPE&quot;.to_string(),
        field: field.to_string(),
        message: format!(&quot;Field &#x27;{}&#x27; has wrong type&quot;, field),
        expected: Some(expected.to_string()),
        received: Some(received.to_string()),
    }
}

pub fn invalid_format(field: &amp;str, expected_format: &amp;str, example: &amp;str, received: &amp;str) -&gt; Self {
    Self {
        code: &quot;INVALID_FORMAT&quot;.to_string(),
        field: field.to_string(),
        message: format!(&quot;Field &#x27;{}&#x27; has invalid format&quot;, field),
        expected: Some(format!(&quot;{} (e.g., {})&quot;, expected_format, example)),
        received: Some(received.to_string()),
    }
}

pub fn business_rule(field: &amp;str, rule: &amp;str, received: &amp;str) -&gt; Self {
    Self {
        code: &quot;BUSINESS_RULE_VIOLATION&quot;.to_string(),
        field: field.to_string(),
        message: rule.to_string(),
        expected: None,
        received: Some(received.to_string()),
    }
}

pub fn invalid_value(field: &amp;str, message: &amp;str, valid_options: &amp;[&amp;str], received: &amp;str) -&gt; Self {
    Self {
        code: &quot;INVALID_VALUE&quot;.to_string(),
        field: field.to_string(),
        message: message.to_string(),
        expected: Some(format!(&quot;One of: {}&quot;, valid_options.join(&quot;, &quot;))),
        received: Some(received.to_string()),
    }
}

pub fn out_of_range(field: &amp;str, min: i64, max: i64, received: i64) -&gt; Self {
    Self {
        code: &quot;OUT_OF_RANGE&quot;.to_string(),
        field: field.to_string(),
        message: format!(&quot;Field &#x27;{}&#x27; must be between {} and {}&quot;, field, min, max),
        expected: Some(format!(&quot;{} to {}&quot;, min, max)),
        received: Some(received.to_string()),
    }
}

pub fn to_json(&amp;self) -&gt; Value {
    serde_json::to_value(self).expect(&quot;Serialization should not fail&quot;)
}
}

}

#[derive(Debug, Deserialize)] pub struct OrderQueryInput { pub customer_id: Option<String>, pub date_range: Option<DateRange>, pub status: Option<String>, pub limit: Option<i64>, }

#[derive(Debug, Deserialize)] pub struct DateRange { pub start: String, pub end: String, }

fn is_valid_iso_date(s: &str) -> bool { if s.len() != 10 { return false; } let parts: Vec<&str> = s.split('-').collect(); if parts.len() != 3 { return false; } parts[0].len() == 4 && parts[1].len() == 2 && parts[2].len() == 2 && parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit())) }

const VALID_STATUSES: &[&str] = &["pending", "shipped", "delivered", "cancelled"];

pub fn validate_order_query(input: &OrderQueryInput) -> Result<(), ValidationError> { // 1. Check at least one filter is provided if input.customer_id.is_none() && input.date_range.is_none() && input.status.is_none() { return Err(ValidationError { code: "MISSING_FILTER".to_string(), field: "customer_id, date_range, or status".to_string(), message: "At least one filter must be provided".to_string(), expected: Some("Provide customer_id, date_range, or status".to_string()), received: Some("No filters provided".to_string()), }); }

// 2. Validate date_range format
if let Some(ref date_range) = input.date_range {
    if !is_valid_iso_date(&amp;date_range.start) {
        return Err(ValidationError::invalid_format(
            &quot;date_range.start&quot;,
            &quot;ISO 8601 date (YYYY-MM-DD)&quot;,
            &quot;2024-11-15&quot;,
            &amp;date_range.start,
        ));
    }
    if !is_valid_iso_date(&amp;date_range.end) {
        return Err(ValidationError::invalid_format(
            &quot;date_range.end&quot;,
            &quot;ISO 8601 date (YYYY-MM-DD)&quot;,
            &quot;2024-11-20&quot;,
            &amp;date_range.end,
        ));
    }

    // 3. Check end is not before start
    if date_range.end &lt; date_range.start {
        return Err(ValidationError::business_rule(
            &quot;date_range&quot;,
            &quot;End date cannot be before start date&quot;,
            &amp;format!(&quot;start: {}, end: {}&quot;, date_range.start, date_range.end),
        ));
    }
}

// 4. Validate status
if let Some(ref status) = input.status {
    if !VALID_STATUSES.contains(&amp;status.as_str()) {
        return Err(ValidationError::invalid_value(
            &quot;status&quot;,
            &quot;Invalid order status&quot;,
            VALID_STATUSES,
            status,
        ));
    }
}

// 5. Validate limit range
if let Some(limit) = input.limit {
    if limit &lt; 1 || limit &gt; 1000 {
        return Err(ValidationError::out_of_range(&quot;limit&quot;, 1, 1000, limit));
    }
}

Ok(())

}

#[cfg(test)] mod tests { use super::*;

#[test]
fn test_missing_all_filters() {
    let input = OrderQueryInput {
        customer_id: None,
        date_range: None,
        status: None,
        limit: None,
    };

    let err = validate_order_query(&amp;input).unwrap_err();
    assert_eq!(err.code, &quot;MISSING_FILTER&quot;);
}

#[test]
fn test_invalid_date_format() {
    let input = OrderQueryInput {
        customer_id: None,
        date_range: Some(DateRange {
            start: &quot;November 15, 2024&quot;.to_string(),
            end: &quot;November 20, 2024&quot;.to_string(),
        }),
        status: None,
        limit: None,
    };

    let err = validate_order_query(&amp;input).unwrap_err();
    assert_eq!(err.code, &quot;INVALID_FORMAT&quot;);
    assert!(err.expected.as_ref().unwrap().contains(&quot;2024-11-15&quot;));
    assert!(err.received.as_ref().unwrap().contains(&quot;November&quot;));
}

#[test]
fn test_end_before_start() {
    let input = OrderQueryInput {
        customer_id: None,
        date_range: Some(DateRange {
            start: &quot;2024-11-20&quot;.to_string(),
            end: &quot;2024-11-15&quot;.to_string(),
        }),
        status: None,
        limit: None,
    };

    let err = validate_order_query(&amp;input).unwrap_err();
    assert_eq!(err.code, &quot;BUSINESS_RULE_VIOLATION&quot;);
}

#[test]
fn test_invalid_status() {
    let input = OrderQueryInput {
        customer_id: None,
        date_range: None,
        status: Some(&quot;in_progress&quot;.to_string()),
        limit: None,
    };

    let err = validate_order_query(&amp;input).unwrap_err();
    assert_eq!(err.code, &quot;INVALID_VALUE&quot;);
    assert!(err.expected.as_ref().unwrap().contains(&quot;pending&quot;));
}

#[test]
fn test_limit_too_high() {
    let input = OrderQueryInput {
        customer_id: Some(&quot;CUST-001&quot;.to_string()),
        date_range: None,
        status: None,
        limit: Some(5000),
    };

    let err = validate_order_query(&amp;input).unwrap_err();
    assert_eq!(err.code, &quot;OUT_OF_RANGE&quot;);
    assert_eq!(err.field, &quot;limit&quot;);
}

#[test]
fn test_valid_input() {
    let input = OrderQueryInput {
        customer_id: Some(&quot;CUST-001&quot;.to_string()),
        date_range: Some(DateRange {
            start: &quot;2024-11-01&quot;.to_string(),
            end: &quot;2024-11-30&quot;.to_string(),
        }),
        status: Some(&quot;shipped&quot;.to_string()),
        limit: Some(100),
    };

    assert!(validate_order_query(&amp;input).is_ok());
}

}

Explanation

ValidationError Design: The struct includes all information an AI needs to self-correct:

  • code: Programmatic identifier (INVALID_FORMAT, OUT_OF_RANGE, etc.)
  • field: Exact field path (date_range.start, not just "input")
  • message: Human-readable explanation
  • expected: What the AI should have sent (with examples!)
  • received: What was actually sent (for comparison)

Validation Levels:

  1. Schema: Missing required fields
  2. Format: Date format validation
  3. Business: End date must be after start date
  4. Range: Limit between 1-1000

AI Feedback Loop: When an AI sends date_range.start: "November 15, 2024", it receives:

The AI can now retry with date_range.start: "2024-11-15" - it learned from the error!

🤔 Reflection

  • How would you handle validation for deeply nested objects?
  • Should you return the first error or collect all errors?
  • How might different error codes trigger different AI behaviors?
  • What's the balance between helpful detail and information leakage?