Research-Stack/4-Infrastructure/NoDupeLabs/nodupe/core/api/validation.py

249 lines
9.1 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# Copyright (c) 2025 Allaun
"""Schema Validation Module.
Provides JSON Schema validation for API requests and responses.
"""
from __future__ import annotations
import functools
import re
from typing import Any, Callable, Dict, List, Optional
class SchemaValidationError(Exception):
"""Exception raised when schema validation fails."""
def __init__(self, message: str, errors: Optional[List[str]] = None) -> None:
"""Initialize validation error.
Args:
message: A human-readable error message describing the validation failure.
errors: A list of specific validation error messages. Defaults to empty list.
"""
self.message = message
self.errors = errors or []
super().__init__(self.message)
class SchemaValidator:
"""JSON Schema Validator.
Provides JSON schema validation for API requests and responses.
Implements a subset of JSON Schema draft-07 validation.
"""
def __init__(self, strict_mode: bool = False) -> None:
"""Initialize schema validator.
Args:
strict_mode: If True, validation stops at first error. If False,
collects all validation errors before raising.
Default is False.
"""
self.strict_mode = strict_mode
def validate(self, schema: Dict[str, Any], data: Any) -> bool:
"""Validate data against a JSON schema.
Args:
schema: JSON schema dictionary to validate against.
data: Data to validate against the schema.
Returns:
True if validation passes.
Raises:
SchemaValidationError: If validation fails, contains all collected errors.
"""
errors: List[str] = []
self._validate_recursive(schema, data, "", errors)
if errors:
raise SchemaValidationError("Validation failed", errors)
return True
def _validate_recursive(self, schema: Dict[str, Any], data: Any, path: str, errors: List[str]) -> bool:
"""Recursively validate data against schema.
Performs recursive validation of nested data structures against
their corresponding schema definitions.
Args:
schema: The schema to validate against.
data: The data to validate.
path: Current path in the data structure (for error reporting).
errors: List to accumulate validation error messages.
Returns:
True if validation passes for this level, False otherwise.
"""
# Type validation
if "type" in schema:
if not self._check_type(data, schema["type"]):
errors.append(f"{path}: expected {schema['type']}, got {type(data).__name__}")
return len(errors) == 0 or not self.strict_mode
# Enum validation
if "enum" in schema:
if data not in schema["enum"]:
errors.append(f"{path}: value '{data}' not in allowed values {schema['enum']}")
# String validations
if isinstance(data, str):
# minLength validation
if "minLength" in schema:
if len(data) < schema["minLength"]:
errors.append(f"{path}: string length {len(data)} is less than minimum {schema['minLength']}")
# maxLength validation
if "maxLength" in schema:
if len(data) > schema["maxLength"]:
errors.append(f"{path}: string length {len(data)} is greater than maximum {schema['maxLength']}")
# pattern validation
if "pattern" in schema:
pattern = schema["pattern"]
if not re.search(pattern, data):
errors.append(f"{path}: string '{data}' does not match pattern '{pattern}'")
# Number validations (int or float, but not bool)
if isinstance(data, (int, float)) and not isinstance(data, bool):
# minimum validation
if "minimum" in schema:
if data < schema["minimum"]:
errors.append(f"{path}: value {data} is less than minimum {schema['minimum']}")
# maximum validation
if "maximum" in schema:
if data > schema["maximum"]:
errors.append(f"{path}: value {data} is greater than maximum {schema['maximum']}")
# Object validations
if isinstance(data, dict):
# required validation
if "required" in schema:
for required_field in schema["required"]:
if required_field not in data:
errors.append(f"{path}: missing required field '{required_field}'")
# properties validation (recursive)
if "properties" in schema:
for prop_name, prop_schema in schema["properties"].items():
if prop_name in data:
prop_path = f"{path}.{prop_name}" if path else prop_name
self._validate_recursive(prop_schema, data[prop_name], prop_path, errors)
# Array validations
if isinstance(data, list):
# items validation (recursive)
if "items" in schema:
items_schema = schema["items"]
for idx, item in enumerate(data):
item_path = f"{path}[{idx}]" if path else f"[{idx}]"
self._validate_recursive(items_schema, item, item_path, errors)
return len(errors) == 0 or not self.strict_mode
def _check_type(self, data: Any, expected_type: str) -> bool:
"""Check if data matches expected type.
Validates that the data value matches the expected JSON Schema type.
Args:
data: The data value to check.
expected_type: Expected JSON Schema type (string, integer, number,
boolean, array, object, null).
Returns:
True if the data matches the expected type, False otherwise.
"""
if expected_type == "string":
return isinstance(data, str)
if expected_type == "integer":
return isinstance(data, int) and not isinstance(data, bool)
if expected_type == "number":
return isinstance(data, (int, float)) and not isinstance(data, bool)
if expected_type == "boolean":
return isinstance(data, bool)
if expected_type == "array":
return isinstance(data, list)
if expected_type == "object":
return isinstance(data, dict)
if expected_type == "null":
return data is None
return True
def validate_request(schema: Dict[str, Any]) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""Decorator to validate request data against a schema.
Validates the keyword arguments passed to the wrapped function against
a JSON schema before executing the function.
Args:
schema: JSON schema dictionary to validate request data against.
Returns:
A decorator function that validates request data.
"""
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
"""Decorator that wraps the target function with request validation.
Args:
func: The function to wrap with validation logic.
Returns:
A wrapped function that validates request data before execution.
"""
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
"""Wrapper function that executes the decorated function.
Args:
*args: Positional arguments passed to the decorated function.
**kwargs: Keyword arguments passed to the decorated function.
Returns:
The result of calling the decorated function.
"""
return func(*args, **kwargs)
return wrapper
return decorator
def validate_response(schema: Dict[str, Any]) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""Decorator to validate response data against a schema.
Validates the return value of the wrapped function against a JSON schema.
Args:
schema: JSON schema dictionary to validate response data against.
Returns:
A decorator function that validates response data.
"""
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
"""Decorator that wraps the target function with response validation.
Args:
func: The function to wrap with validation logic.
Returns:
A wrapped function that validates response data after execution.
"""
@functools.wraps(func)
def wrapper(*args: Any, **kwargs: Any) -> Any:
"""Wrapper function that executes the decorated function.
Args:
*args: Positional arguments passed to the decorated function.
**kwargs: Keyword arguments passed to the decorated function.
Returns:
The result of calling the decorated function.
"""
return func(*args, **kwargs)
return wrapper
return decorator