"""
Parameter validation utilities for Congressional MCP APIs.
This module provides standardized validation functions to prevent hanging issues,
ensure data consistency, and provide user-friendly error messages across all APIs.
"""
import re
from typing import Optional, List, Dict, Any
from dataclasses import dataclass
from datetime import datetime
import logging
logger = logging.getLogger(__name__)
@dataclass
class ValidationResult:
"""Result of a parameter validation check."""
is_valid: bool
error_message: Optional[str] = None
suggestions: Optional[List[str]] = None
sanitized_value: Optional[Any] = None
class ParameterValidator:
"""Centralized parameter validation for all Congressional APIs."""
# Congressional data ranges based on API testing
MIN_CONGRESS_NUMBER = 1
MAX_CONGRESS_NUMBER = 119 # Current as of 2024
# API-specific year ranges (can be overridden per API)
DEFAULT_MIN_YEAR = 1789 # First Congress
DEFAULT_MAX_YEAR = datetime.now().year
# Bound Congressional Record specific range (based on testing)
BOUND_RECORD_MIN_YEAR = 1873
BOUND_RECORD_MAX_YEAR = 1997
# Standard limit ranges
DEFAULT_MAX_LIMIT = 250
MIN_LIMIT = 1
@staticmethod
def validate_congress_number(congress: Optional[int]) -> ValidationResult:
"""
Validate congress numbers.
Args:
congress: Congress number to validate
Returns:
ValidationResult with validation status and error details
"""
if congress is None:
return ValidationResult(is_valid=True)
try:
congress_int = int(congress)
if congress_int < ParameterValidator.MIN_CONGRESS_NUMBER:
return ValidationResult(
is_valid=False,
error_message=f"Congress number {congress} is too low. The first Congress was {ParameterValidator.MIN_CONGRESS_NUMBER}.",
suggestions=[f"Try a congress number between {ParameterValidator.MIN_CONGRESS_NUMBER} and {ParameterValidator.MAX_CONGRESS_NUMBER}"]
)
if congress_int > ParameterValidator.MAX_CONGRESS_NUMBER:
return ValidationResult(
is_valid=False,
error_message=f"Congress number {congress} is too high. The current Congress is {ParameterValidator.MAX_CONGRESS_NUMBER}.",
suggestions=[f"Try a congress number between {ParameterValidator.MIN_CONGRESS_NUMBER} and {ParameterValidator.MAX_CONGRESS_NUMBER}"]
)
return ValidationResult(is_valid=True, sanitized_value=congress_int)
except (ValueError, TypeError):
return ValidationResult(
is_valid=False,
error_message=f"Invalid congress number format: {congress}. Please provide a valid integer.",
suggestions=["Congress numbers are integers, e.g., 118 for the 118th Congress"]
)
@staticmethod
def validate_year_range(year: Optional[str], min_year: int = None, max_year: int = None) -> ValidationResult:
"""
Validate year parameters with configurable ranges.
Args:
year: Year string to validate
min_year: Minimum allowed year (defaults to DEFAULT_MIN_YEAR)
max_year: Maximum allowed year (defaults to DEFAULT_MAX_YEAR)
Returns:
ValidationResult with validation status and error details
"""
if year is None:
return ValidationResult(is_valid=True)
if min_year is None:
min_year = ParameterValidator.DEFAULT_MIN_YEAR
if max_year is None:
max_year = ParameterValidator.DEFAULT_MAX_YEAR
try:
year_int = int(year)
if year_int < min_year or year_int > max_year:
return ValidationResult(
is_valid=False,
error_message=f"Year {year} is outside the available range. Please try a year between {min_year} and {max_year}.",
suggestions=[f"Valid year range: {min_year}-{max_year}"]
)
return ValidationResult(is_valid=True, sanitized_value=year_int)
except (ValueError, TypeError):
return ValidationResult(
is_valid=False,
error_message=f"Invalid year format: {year}. Please provide a valid 4-digit year.",
suggestions=["Years should be 4-digit integers, e.g., 2023"]
)
@staticmethod
def validate_month(month: Optional[str]) -> ValidationResult:
"""
Validate month parameter.
Args:
month: Month string to validate (1-12)
Returns:
ValidationResult with validation status and error details
"""
if month is None:
return ValidationResult(is_valid=True)
try:
month_int = int(month)
if month_int < 1 or month_int > 12:
return ValidationResult(
is_valid=False,
error_message=f"Invalid month: {month}. Please provide a month between 1 and 12.",
suggestions=["Months are numbered 1-12 (1=January, 12=December)"]
)
return ValidationResult(is_valid=True, sanitized_value=month_int)
except (ValueError, TypeError):
return ValidationResult(
is_valid=False,
error_message=f"Invalid month format: {month}. Please provide a valid month number.",
suggestions=["Months should be integers 1-12"]
)
@staticmethod
def validate_day(day: Optional[str]) -> ValidationResult:
"""
Validate day parameter.
Args:
day: Day string to validate (1-31)
Returns:
ValidationResult with validation status and error details
"""
if day is None:
return ValidationResult(is_valid=True)
try:
day_int = int(day)
if day_int < 1 or day_int > 31:
return ValidationResult(
is_valid=False,
error_message=f"Invalid day: {day}. Please provide a day between 1 and 31.",
suggestions=["Days should be between 1 and 31"]
)
return ValidationResult(is_valid=True, sanitized_value=day_int)
except (ValueError, TypeError):
return ValidationResult(
is_valid=False,
error_message=f"Invalid day format: {day}. Please provide a valid day number.",
suggestions=["Days should be integers 1-31"]
)
@staticmethod
def validate_date_components(year: Optional[str], month: Optional[str], day: Optional[str]) -> ValidationResult:
"""
Validate year/month/day combinations for logical consistency.
Args:
year: Year string
month: Month string
day: Day string
Returns:
ValidationResult with validation status and error details
"""
# Validate individual components first
year_result = ParameterValidator.validate_year_range(year)
if not year_result.is_valid:
return year_result
month_result = ParameterValidator.validate_month(month)
if not month_result.is_valid:
return month_result
day_result = ParameterValidator.validate_day(day)
if not day_result.is_valid:
return day_result
# If all components are provided, validate the date combination
if year and month and day:
try:
# Check if the date is valid (handles leap years, month lengths)
datetime(year_result.sanitized_value, month_result.sanitized_value, day_result.sanitized_value)
return ValidationResult(is_valid=True)
except ValueError as e:
return ValidationResult(
is_valid=False,
error_message=f"Invalid date combination: {year}-{month}-{day}. {str(e)}",
suggestions=["Check that the day exists in the specified month and year"]
)
return ValidationResult(is_valid=True)
@staticmethod
def validate_limit_range(limit: int, max_limit: int = None) -> ValidationResult:
"""
Validate limit parameters.
Args:
limit: Limit value to validate
max_limit: Maximum allowed limit (defaults to DEFAULT_MAX_LIMIT)
Returns:
ValidationResult with validation status and sanitized limit
"""
if max_limit is None:
max_limit = ParameterValidator.DEFAULT_MAX_LIMIT
try:
limit_int = int(limit)
if limit_int < ParameterValidator.MIN_LIMIT:
# Auto-correct to minimum
return ValidationResult(
is_valid=True,
sanitized_value=ParameterValidator.MIN_LIMIT,
error_message=f"Limit {limit} was too low, adjusted to {ParameterValidator.MIN_LIMIT}"
)
if limit_int > max_limit:
# Auto-correct to maximum
return ValidationResult(
is_valid=True,
sanitized_value=max_limit,
error_message=f"Limit {limit} was too high, adjusted to {max_limit}"
)
return ValidationResult(is_valid=True, sanitized_value=limit_int)
except (ValueError, TypeError):
return ValidationResult(
is_valid=False,
error_message=f"Invalid limit format: {limit}. Please provide a valid integer.",
suggestions=[f"Limits should be integers between {ParameterValidator.MIN_LIMIT} and {max_limit}"]
)
@staticmethod
def validate_bill_type(bill_type: Optional[str]) -> ValidationResult:
"""
Validate bill type parameters.
Args:
bill_type: Bill type to validate (hr, s, hjres, sjres, hconres, sconres, hres, sres)
Returns:
ValidationResult with validation status and error details
"""
if bill_type is None:
return ValidationResult(is_valid=True)
valid_bill_types = ['hr', 's', 'hjres', 'sjres', 'hconres', 'sconres', 'hres', 'sres']
bill_type_lower = bill_type.lower().strip()
if bill_type_lower not in valid_bill_types:
return ValidationResult(
is_valid=False,
error_message=f"Invalid bill type: {bill_type}. Valid types are: {', '.join(valid_bill_types)}",
suggestions=[
"hr = House Bill",
"s = Senate Bill",
"hjres = House Joint Resolution",
"sjres = Senate Joint Resolution"
]
)
return ValidationResult(is_valid=True, sanitized_value=bill_type_lower)
@staticmethod
def validate_amendment_type(amendment_type: Optional[str]) -> ValidationResult:
"""
Validate amendment type parameters.
Args:
amendment_type: Amendment type to validate (samdt, hamdt)
Returns:
ValidationResult with validation status and error details
"""
if amendment_type is None:
return ValidationResult(is_valid=True)
valid_amendment_types = ['samdt', 'hamdt']
amendment_type_lower = amendment_type.lower().strip()
if amendment_type_lower not in valid_amendment_types:
return ValidationResult(
is_valid=False,
error_message=f"Invalid amendment type: {amendment_type}. Valid types are: {', '.join(valid_amendment_types)}",
suggestions=[
"samdt = Senate Amendment",
"hamdt = House Amendment"
]
)
return ValidationResult(is_valid=True, sanitized_value=amendment_type_lower)
@staticmethod
def validate_content_type(content_type: str) -> ValidationResult:
"""
Validate content type parameters.
Args:
content_type: Content type to validate (e.g., "text", "summary")
Returns:
ValidationResult with validation status
"""
if not content_type:
return ValidationResult(
is_valid=False,
error_message="Content type cannot be empty",
suggestions=["Use 'text' for bill text or 'summary' for bill summaries"]
)
valid_types = ["text", "summary"]
content_type_lower = content_type.lower().strip()
if content_type_lower not in valid_types:
return ValidationResult(
is_valid=False,
error_message=f"Invalid content type: {content_type}",
suggestions=[f"Valid content types are: {', '.join(valid_types)}"]
)
return ValidationResult(
is_valid=True,
sanitized_value=content_type_lower
)
@staticmethod
def validate_jacket_number(jacket_number: Optional[int]) -> ValidationResult:
"""
Validate jacket number parameters for committee prints.
Args:
jacket_number: Jacket number to validate
Returns:
ValidationResult with validation status and error details
"""
if jacket_number is None:
return ValidationResult(
is_valid=False,
error_message="Jacket number is required",
suggestions=["Provide a valid jacket number (positive integer)"]
)
if not isinstance(jacket_number, int) or jacket_number <= 0:
return ValidationResult(
is_valid=False,
error_message=f"Invalid jacket number: {jacket_number}. Must be a positive integer.",
suggestions=["Use a positive integer for the jacket number (e.g., 48144)"]
)
# Jacket numbers are typically 5-6 digits based on API examples
if jacket_number > 999999:
return ValidationResult(
is_valid=False,
error_message=f"Jacket number {jacket_number} seems unusually large",
suggestions=["Verify the jacket number is correct (typically 5-6 digits)"]
)
return ValidationResult(is_valid=True, sanitized_value=jacket_number)
@staticmethod
def validate_bill_number(bill_number: int) -> ValidationResult:
"""
Validate bill number parameters.
Args:
bill_number: Bill number to validate
Returns:
ValidationResult with validation status and error details
"""
if bill_number is None:
return ValidationResult(
is_valid=False,
error_message="Bill number is required",
suggestions=["Provide a valid bill number (positive integer)"]
)
if not isinstance(bill_number, int) or bill_number <= 0:
return ValidationResult(
is_valid=False,
error_message=f"Invalid bill number: {bill_number}. Must be a positive integer.",
suggestions=["Use a positive integer for the bill number (e.g., 1, 100, 5376)"]
)
# Bill numbers are typically 1-5 digits based on API examples
if bill_number > 99999:
return ValidationResult(
is_valid=False,
error_message=f"Bill number {bill_number} seems unusually large",
suggestions=["Verify the bill number is correct (typically 1-5 digits)"]
)
return ValidationResult(is_valid=True, sanitized_value=bill_number)
@staticmethod
def validate_chamber(chamber: str, allow_nochamber: bool = True) -> ValidationResult:
"""
Validate chamber parameter for Congressional APIs.
Args:
chamber: Chamber name to validate
allow_nochamber: Whether to allow 'nochamber' as valid option
Returns:
ValidationResult with validation status and sanitized value
"""
if not isinstance(chamber, str):
return ValidationResult(
is_valid=False,
error_message="Chamber must be a string",
suggestions=["Provide chamber as 'house', 'senate'" + (", or 'nochamber'" if allow_nochamber else "")]
)
chamber_lower = chamber.lower().strip()
valid_chambers = ["house", "senate"]
if allow_nochamber:
valid_chambers.append("nochamber")
if chamber_lower not in valid_chambers:
return ValidationResult(
is_valid=False,
error_message=f"Invalid chamber: {chamber}",
suggestions=[f"Must be one of: {', '.join(valid_chambers)}"]
)
return ValidationResult(is_valid=True, sanitized_value=chamber_lower)
@staticmethod
def validate_report_type(report_type: str) -> ValidationResult:
"""
Validate committee report types.
Args:
report_type: Report type to validate (e.g., 'hrpt', 'srpt', 'erpt')
Returns:
ValidationResult with validation status and sanitized value
"""
if not isinstance(report_type, str):
return ValidationResult(
is_valid=False,
error_message="Report type must be a string",
suggestions=["Provide report type as 'hrpt', 'srpt', or 'erpt'"]
)
report_type_lower = report_type.lower().strip()
valid_types = ["hrpt", "srpt", "erpt"] # House, Senate, Executive reports
if report_type_lower not in valid_types:
return ValidationResult(
is_valid=False,
error_message=f"Invalid report type: {report_type}",
suggestions=[f"Must be one of: {', '.join(valid_types)}"]
)
return ValidationResult(is_valid=True, sanitized_value=report_type_lower)
@staticmethod
def validate_report_number(report_number: int) -> ValidationResult:
"""
Validate committee report numbers.
Args:
report_number: Report number to validate
Returns:
ValidationResult with validation status
"""
if not isinstance(report_number, int):
return ValidationResult(
is_valid=False,
error_message="Report number must be an integer",
suggestions=["Provide a positive integer report number"]
)
if report_number <= 0:
return ValidationResult(
is_valid=False,
error_message=f"Report number must be positive: {report_number}",
suggestions=["Provide a positive integer report number (e.g., 1, 100, 500)"]
)
# Committee reports typically don't exceed 9999 per congress
if report_number > 9999:
return ValidationResult(
is_valid=False,
error_message=f"Report number seems unusually high: {report_number}",
suggestions=["Check the report number - typical range is 1-9999"]
)
return ValidationResult(is_valid=True)
@staticmethod
def validate_conference_filter(conference: str) -> ValidationResult:
"""
Validate conference report filter parameter.
Args:
conference: Conference filter value ('true' or 'false')
Returns:
ValidationResult with validation status and sanitized value
"""
if not isinstance(conference, str):
return ValidationResult(
is_valid=False,
error_message="Conference filter must be a string",
suggestions=["Provide 'true' or 'false'"]
)
conference_lower = conference.lower().strip()
valid_values = ["true", "false"]
if conference_lower not in valid_values:
return ValidationResult(
is_valid=False,
error_message=f"Invalid conference filter: {conference}",
suggestions=["Must be 'true' or 'false'"]
)
return ValidationResult(is_valid=True, sanitized_value=conference_lower)
@staticmethod
def validate_offset(offset: int) -> ValidationResult:
"""
Validate pagination offset parameter.
Args:
offset: Offset value for pagination
Returns:
ValidationResult with validation status
"""
if not isinstance(offset, int):
return ValidationResult(
is_valid=False,
error_message="Offset must be an integer",
suggestions=["Provide a non-negative integer for pagination offset"]
)
if offset < 0:
return ValidationResult(
is_valid=False,
error_message=f"Offset must be non-negative: {offset}",
suggestions=["Provide a non-negative integer (0 or greater)"]
)
# Reasonable upper limit for offset
if offset > 10000:
return ValidationResult(
is_valid=False,
error_message=f"Offset seems unusually high: {offset}",
suggestions=["Consider using a smaller offset value for better performance"]
)
return ValidationResult(is_valid=True)
@staticmethod
def validate_state_code(state_code: str) -> ValidationResult:
"""
Validate US state codes for Congressional member queries.
Args:
state_code: Two-letter state code to validate
Returns:
ValidationResult with validation status and sanitized value
"""
if not state_code:
return ValidationResult(
is_valid=False,
error_message="State code cannot be empty",
suggestions=["Provide a two-letter state code like 'CA', 'TX', 'NY'"]
)
# Sanitize input
state_code_clean = str(state_code).strip().upper()
# Valid US state and territory codes
valid_states = {
'AL', 'AK', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'FL', 'GA',
'HI', 'ID', 'IL', 'IN', 'IA', 'KS', 'KY', 'LA', 'ME', 'MD',
'MA', 'MI', 'MN', 'MS', 'MO', 'MT', 'NE', 'NV', 'NH', 'NJ',
'NM', 'NY', 'NC', 'ND', 'OH', 'OK', 'OR', 'PA', 'RI', 'SC',
'SD', 'TN', 'TX', 'UT', 'VT', 'VA', 'WA', 'WV', 'WI', 'WY',
'DC', 'AS', 'GU', 'MP', 'PR', 'VI' # Territories with Congressional representation
}
if len(state_code_clean) != 2:
return ValidationResult(
is_valid=False,
error_message=f"State code must be exactly 2 characters: '{state_code}'",
suggestions=["Use two-letter state codes like 'CA' for California, 'TX' for Texas"]
)
if state_code_clean not in valid_states:
return ValidationResult(
is_valid=False,
error_message=f"Invalid state code: '{state_code_clean}'",
suggestions=[
"Use valid US state codes like 'CA', 'TX', 'NY', 'FL'",
"Territories: 'DC', 'PR', 'VI', 'GU', 'AS', 'MP'"
]
)
return ValidationResult(is_valid=True, sanitized_value=state_code_clean)
@staticmethod
def validate_date_format(date_string: Optional[str]) -> ValidationResult:
"""
Validate ISO date format (YYYY-MM-DDTHH:MM:SSZ) for API parameters.
Args:
date_string: Date string to validate in ISO format
Returns:
ValidationResult with validation status and error details
"""
if date_string is None:
return ValidationResult(is_valid=True)
if not isinstance(date_string, str):
return ValidationResult(
is_valid=False,
error_message="Date must be a string",
suggestions=["Provide date in ISO format: YYYY-MM-DDTHH:MM:SSZ"]
)
# Check ISO format pattern: YYYY-MM-DDTHH:MM:SSZ
iso_pattern = r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$'
if not re.match(iso_pattern, date_string.strip()):
return ValidationResult(
is_valid=False,
error_message=f"Invalid date format: {date_string}. Date must be in format YYYY-MM-DDTHH:MM:SSZ",
suggestions=[
"Use ISO format: YYYY-MM-DDTHH:MM:SSZ",
"Example: 2023-01-01T00:00:00Z"
]
)
# Additional validation: try to parse the date to ensure it's valid
try:
datetime.strptime(date_string.strip(), '%Y-%m-%dT%H:%M:%SZ')
return ValidationResult(is_valid=True, sanitized_value=date_string.strip())
except ValueError as e:
return ValidationResult(
is_valid=False,
error_message=f"Invalid date: {date_string}. {str(e)}",
suggestions=[
"Check that the date values are valid (e.g., month 1-12, day 1-31)",
"Use format: YYYY-MM-DDTHH:MM:SSZ"
]
)
@staticmethod
def validate_senate_communication_type(communication_type: Optional[str]) -> ValidationResult:
"""
Validate senate communication type codes.
Args:
communication_type: Communication type code to validate
Returns:
ValidationResult with validation status and error details
"""
if communication_type is None:
return ValidationResult(is_valid=True)
if not isinstance(communication_type, str):
return ValidationResult(
is_valid=False,
error_message="Communication type must be a string",
suggestions=["Use valid communication type codes like 'ec', 'pm', 'pom'"]
)
# Valid senate communication types based on Congress.gov API
valid_types = {
'ec': 'Executive Communication',
'pm': 'Petition or Memorial',
'pom': 'Petition or Memorial'
}
communication_type_clean = communication_type.strip().lower()
if communication_type_clean not in valid_types:
return ValidationResult(
is_valid=False,
error_message=f"Invalid senate communication type: '{communication_type}'",
suggestions=[
f"Valid types: {', '.join(valid_types.keys())}",
"ec = Executive Communication",
"pm = Petition or Memorial",
"pom = Petition or Memorial"
]
)
return ValidationResult(is_valid=True, sanitized_value=communication_type_clean)
@staticmethod
def validate_communication_number(communication_number: Optional[int]) -> ValidationResult:
"""
Validate communication number.
Args:
communication_number: Communication number to validate
Returns:
ValidationResult with validation status and error details
"""
if communication_number is None:
return ValidationResult(is_valid=True)
if not isinstance(communication_number, int) or communication_number <= 0:
return ValidationResult(
is_valid=False,
error_message=f"Communication number must be a positive integer, got: {communication_number}",
suggestions=["Use a positive integer for communication number (e.g., 1, 2, 3, ...)"]
)
return ValidationResult(is_valid=True, sanitized_value=communication_number)
@staticmethod
def validate_treaty_number(treaty_number: Optional[int]) -> ValidationResult:
"""
Validate treaty number.
Args:
treaty_number: Treaty number to validate
Returns:
ValidationResult with validation status and error details
"""
if treaty_number is None:
return ValidationResult(is_valid=True)
if not isinstance(treaty_number, int) or treaty_number <= 0:
return ValidationResult(
is_valid=False,
error_message=f"Treaty number must be a positive integer, got: {treaty_number}",
suggestions=["Use a positive integer for treaty number (e.g., 1, 2, 3, ...)"]
)
return ValidationResult(is_valid=True, sanitized_value=treaty_number)
@staticmethod
def validate_treaty_suffix(treaty_suffix: Optional[str]) -> ValidationResult:
"""
Validate treaty suffix for partitioned treaties.
Args:
treaty_suffix: Treaty suffix to validate (e.g., 'A', 'B', 'C')
Returns:
ValidationResult with validation status and sanitized value
"""
if treaty_suffix is None:
return ValidationResult(is_valid=True)
if not isinstance(treaty_suffix, str):
return ValidationResult(
is_valid=False,
error_message=f"Treaty suffix must be a string, got: {type(treaty_suffix).__name__}",
suggestions=["Use a string for treaty suffix (e.g., 'A', 'B', 'C')"]
)
# Clean and validate suffix
clean_suffix = treaty_suffix.strip().upper()
if not clean_suffix:
return ValidationResult(
is_valid=False,
error_message="Treaty suffix cannot be empty",
suggestions=["Use a non-empty string for treaty suffix (e.g., 'A', 'B', 'C')"]
)
# Validate that it's a reasonable suffix (single letter or short string)
if len(clean_suffix) > 3:
return ValidationResult(
is_valid=False,
error_message=f"Treaty suffix '{clean_suffix}' is too long (max 3 characters)",
suggestions=["Use a short suffix like 'A', 'B', 'C', or 'AB'"]
)
return ValidationResult(is_valid=True, sanitized_value=clean_suffix)
# Convenience functions for specific APIs
class BoundCongressionalRecordValidator:
"""Specialized validator for Bound Congressional Record API."""
@staticmethod
def validate_parameters(year: Optional[str], month: Optional[str], day: Optional[str], limit: int) -> ValidationResult:
"""Validate all parameters for bound congressional record searches."""
# Validate year with bound record specific range
if year:
year_result = ParameterValidator.validate_year_range(
year,
ParameterValidator.BOUND_RECORD_MIN_YEAR,
ParameterValidator.BOUND_RECORD_MAX_YEAR
)
if not year_result.is_valid:
return year_result
# Validate date components
date_result = ParameterValidator.validate_date_components(year, month, day)
if not date_result.is_valid:
return date_result
# Validate limit
limit_result = ParameterValidator.validate_limit_range(limit)
if not limit_result.is_valid:
return limit_result
return ValidationResult(is_valid=True)