Coverage for fastblocks/_validation_integration.py: 56%
362 statements
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 00:47 -0700
« prev ^ index » next coverage.py v7.10.7, created at 2025-10-09 00:47 -0700
1"""ACB ValidationService integration for FastBlocks.
3This module provides validation capabilities using ACB's ValidationService,
4with graceful degradation when ACB validation is not available.
6Author: lesleslie <les@wedgwoodwebworks.com>
7Created: 2025-10-01
9Key Features:
10- Template context validation and sanitization
11- Form input validation with XSS prevention
12- API contract validation for endpoints
13- Graceful degradation when ValidationService unavailable
14- Decorators for automatic validation
15- Custom validation schemas for FastBlocks patterns
17Usage:
18 # Template context validation
19 @validate_template_context
20 async def render_template(request, template, context):
21 ...
23 # Form input validation and sanitization
24 @validate_form_input
25 async def handle_form_submit(request, form_data):
26 ...
28 # API contract validation
29 @validate_api_contract(request_schema=UserCreateSchema, response_schema=UserSchema)
30 async def create_user(request):
31 ...
32"""
33# type: ignore # ACB validation service API stub - graceful degradation
35import functools
36import typing as t
37from contextlib import suppress
38from dataclasses import dataclass
39from enum import Enum
41from acb.depends import depends
43# Try to import ACB validation components
44ACB_VALIDATION_AVAILABLE = False
45ValidationService = None
46ValidationSchema = None
47InputSanitizer = None
48OutputValidator = None
49ValidationResult = None
50ValidationError = None
51validate_input_decorator = None
52validate_output_decorator = None
53sanitize_input_decorator = None
55with suppress(ImportError):
56 from acb.services.validation import ( # type: ignore[no-redef]
57 InputSanitizer,
58 OutputValidator,
59 )
61 ACB_VALIDATION_AVAILABLE = True
64class ValidationType(str, Enum):
65 """Types of validation performed by FastBlocks."""
67 TEMPLATE_CONTEXT = "template_context"
68 FORM_INPUT = "form_input"
69 API_REQUEST = "api_request"
70 API_RESPONSE = "api_response"
71 ROUTE_PARAMS = "route_params"
72 QUERY_PARAMS = "query_params"
75@dataclass
76class ValidationConfig:
77 """Configuration for FastBlocks validation integration."""
79 # Enable/disable specific validation types
80 validate_templates: bool = True
81 validate_forms: bool = True
82 validate_api: bool = True
84 # Security settings
85 prevent_xss: bool = True
86 prevent_sql_injection: bool = True
87 prevent_path_traversal: bool = True
89 # Performance settings
90 cache_validation_results: bool = True
91 validation_timeout_ms: float = 100.0
93 # Reporting
94 collect_validation_metrics: bool = True
95 log_validation_failures: bool = True
98class FastBlocksValidationService:
99 """FastBlocks wrapper for ACB ValidationService with graceful degradation."""
101 _instance: t.ClassVar["FastBlocksValidationService | None"] = None
102 _config: t.ClassVar[ValidationConfig] = ValidationConfig()
104 def __new__(cls) -> "FastBlocksValidationService":
105 """Singleton pattern - ensure only one instance exists."""
106 if cls._instance is None:
107 cls._instance = super().__new__(cls) # type: ignore[misc]
108 return cls._instance
110 def __init__(self) -> None:
111 """Initialize validation service with ACB integration."""
112 if not hasattr(self, "_initialized"):
113 self._service: t.Any = None # ValidationService when ACB available
114 self._sanitizer: t.Any = None # InputSanitizer when ACB available
115 self._validator: t.Any = None # OutputValidator when ACB available
116 self._initialized = True
118 # Try to get ACB ValidationService
119 if ACB_VALIDATION_AVAILABLE:
120 with suppress(Exception):
121 self._service = depends.get("validation_service")
122 if self._service:
123 self._sanitizer = InputSanitizer() # type: ignore[operator]
124 self._validator = OutputValidator() # type: ignore[operator]
126 @property
127 def available(self) -> bool:
128 """Check if ACB ValidationService is available."""
129 return ACB_VALIDATION_AVAILABLE and self._service is not None
131 def _sanitize_context_value(
132 self,
133 key: str,
134 value: t.Any,
135 errors: list[str],
136 ) -> t.Any:
137 """Sanitize a single context value.
139 Args:
140 key: Context key
141 value: Context value
142 errors: Error list to append to
144 Returns:
145 Sanitized value
146 """
147 # Skip non-string values (they're safe)
148 if not isinstance(value, str):
149 return value
151 # Sanitize string values for XSS
152 if self._config.prevent_xss and self._sanitizer:
153 try:
154 return self._sanitizer.sanitize_html(value)
155 except Exception as e:
156 errors.append(f"Failed to sanitize {key}: {e}")
157 return value
159 return value
161 def _check_sql_injection_in_context(
162 self,
163 sanitized: dict[str, t.Any],
164 errors: list[str],
165 strict: bool,
166 ) -> bool:
167 """Check for SQL injection patterns in sanitized context.
169 Args:
170 sanitized: Sanitized context data
171 errors: Error list to append to
172 strict: If True, return False immediately on finding issues
174 Returns:
175 False if strict and issues found, True otherwise
176 """
177 if not self._config.prevent_sql_injection:
178 return True
180 for key, value in sanitized.items():
181 if isinstance(value, str) and self._contains_sql_injection(value):
182 errors.append(f"Potential SQL injection in {key}")
183 if strict:
184 return False
186 return True
188 async def validate_template_context(
189 self,
190 context: dict[str, t.Any],
191 template_name: str,
192 strict: bool = False,
193 ) -> tuple[bool, dict[str, t.Any], list[str]]:
194 """Validate and sanitize template context data.
196 Args:
197 context: Template context dictionary
198 template_name: Name of the template being rendered
199 strict: If True, fail on any validation warnings
201 Returns:
202 Tuple of (is_valid, sanitized_context, error_messages)
203 """
204 # Guard clause: skip if validation unavailable or disabled
205 if not self.available or not self._config.validate_templates:
206 return True, context, []
208 errors: list[str] = []
210 try:
211 # Sanitize each context value
212 sanitized = {
213 key: self._sanitize_context_value(key, value, errors)
214 for key, value in context.items()
215 }
217 # Check for SQL injection patterns
218 if not self._check_sql_injection_in_context(sanitized, errors, strict):
219 return False, context, errors
221 # Validation passed (or warnings only)
222 return len(errors) == 0 or not strict, sanitized, errors
224 except Exception as e:
225 errors.append(f"Validation error: {e}")
226 return False, context, errors
228 async def validate_form_input(
229 self,
230 form_data: dict[str, t.Any],
231 schema: dict[str, t.Any] | None = None,
232 strict: bool = True,
233 ) -> tuple[bool, dict[str, t.Any], list[str]]:
234 """Validate and sanitize form input data.
236 Args:
237 form_data: Form data to validate
238 schema: Optional validation schema (field_name -> rules)
239 strict: If True, fail on any validation errors
241 Returns:
242 Tuple of (is_valid, sanitized_data, error_messages)
243 """
244 # Guard clause: skip if validation disabled
245 if not self._config.validate_forms:
246 return True, form_data, []
248 errors: list[str] = []
250 try:
251 # Sanitize and validate fields
252 sanitized = self._sanitize_form_fields(form_data, errors)
254 # Apply schema validation if provided
255 if schema:
256 self._apply_schema_validation(sanitized, schema, errors)
258 return len(errors) == 0, sanitized, errors
260 except Exception as e:
261 errors.append(f"Validation error: {e}")
262 return False, form_data, errors
264 def _sanitize_form_fields(
265 self,
266 form_data: dict[str, t.Any],
267 errors: list[str],
268 ) -> dict[str, t.Any]:
269 """Sanitize all form fields and perform security checks.
271 Args:
272 form_data: Raw form data
273 errors: Error list to append to
275 Returns:
276 Sanitized form data
277 """
278 sanitized = {}
280 for key, value in form_data.items():
281 sanitized[key] = self._sanitize_field(key, value, errors)
283 # Security checks for string values
284 if isinstance(value, str):
285 self._check_security_issues(key, value, errors)
287 return sanitized
289 def _apply_schema_validation(
290 self,
291 sanitized_data: dict[str, t.Any],
292 schema: dict[str, t.Any],
293 errors: list[str],
294 ) -> None:
295 """Apply schema validation rules to sanitized data.
297 Args:
298 sanitized_data: Sanitized form data
299 schema: Validation schema (field_name -> rules)
300 errors: Error list to append to
301 """
302 for field_name, rules in schema.items():
303 value = sanitized_data.get(field_name)
304 self._validate_field_schema(field_name, value, rules, errors)
306 async def validate_api_request(
307 self,
308 request_data: dict[str, t.Any],
309 schema: t.Any = None,
310 ) -> tuple[bool, dict[str, t.Any], list[str]]:
311 """Validate API request data against a schema.
313 Args:
314 request_data: Request data to validate
315 schema: Validation schema (Pydantic, msgspec, etc.)
317 Returns:
318 Tuple of (is_valid, validated_data, error_messages)
319 """
320 # Guard clause: skip if unavailable or disabled
321 if not self.available or not self._config.validate_api:
322 return True, request_data, []
324 errors: list[str] = []
326 try:
327 # Try schema validation first
328 if schema:
329 result = self._validate_with_schema(request_data, schema, errors)
330 if result is not None:
331 return result
333 # Fallback: basic sanitization
334 sanitized = self._sanitize_api_data(request_data)
335 return True, sanitized, []
337 except Exception as e:
338 errors.append(f"Validation error: {e}")
339 return False, request_data, errors
341 def _validate_with_schema(
342 self,
343 data: dict[str, t.Any],
344 schema: t.Any,
345 errors: list[str],
346 ) -> tuple[bool, dict[str, t.Any], list[str]] | None:
347 """Attempt validation with Pydantic or msgspec schema.
349 Args:
350 data: Data to validate
351 schema: Validation schema
352 errors: Error list to append to
354 Returns:
355 Validation result tuple if successful, None to continue with fallback
356 """
357 # Try Pydantic validation
358 if hasattr(schema, "model_validate"):
359 try:
360 validated = schema.model_validate(data)
361 return True, validated.model_dump(), []
362 except Exception as e:
363 errors.append(f"Pydantic validation failed: {e}")
364 return False, data, errors
366 # Try msgspec validation
367 if hasattr(schema, "__struct_fields__"):
368 try:
369 import msgspec
371 validated = msgspec.convert(data, type=schema)
372 return True, msgspec.to_builtins(validated), []
373 except Exception as e:
374 errors.append(f"msgspec validation failed: {e}")
375 return False, data, errors
377 return None
379 def _sanitize_api_data(self, data: dict[str, t.Any]) -> dict[str, t.Any]:
380 """Apply basic XSS sanitization to API data.
382 Args:
383 data: Data to sanitize
385 Returns:
386 Sanitized data
387 """
388 sanitized = {}
389 for key, value in data.items():
390 if isinstance(value, str) and self._config.prevent_xss and self._sanitizer:
391 sanitized[key] = self._sanitizer.sanitize_html(value)
392 else:
393 sanitized[key] = value
394 return sanitized
396 async def validate_api_response(
397 self,
398 response_data: dict[str, t.Any],
399 schema: t.Any = None,
400 ) -> tuple[bool, dict[str, t.Any], list[str]]:
401 """Validate API response data against a schema.
403 Args:
404 response_data: Response data to validate
405 schema: Validation schema (Pydantic, msgspec, etc.)
407 Returns:
408 Tuple of (is_valid, validated_data, error_messages)
409 """
410 # Guard clause: skip if unavailable or disabled
411 if not self.available or not self._config.validate_api:
412 return True, response_data, []
414 errors: list[str] = []
416 try:
417 # Try schema validation if provided
418 if schema:
419 result = self._validate_response_with_schema(
420 response_data, schema, errors
421 )
422 if result is not None:
423 return result
425 return True, response_data, []
427 except Exception as e:
428 errors.append(f"Validation error: {e}")
429 return False, response_data, errors
431 def _validate_response_with_schema(
432 self,
433 data: dict[str, t.Any],
434 schema: t.Any,
435 errors: list[str],
436 ) -> tuple[bool, dict[str, t.Any], list[str]] | None:
437 """Attempt response validation with Pydantic or msgspec schema.
439 Args:
440 data: Response data to validate
441 schema: Validation schema
442 errors: Error list to append to
444 Returns:
445 Validation result tuple if successful, None otherwise
446 """
447 # Try Pydantic validation
448 if hasattr(schema, "model_validate"):
449 try:
450 validated = schema.model_validate(data)
451 return True, validated.model_dump(), []
452 except Exception as e:
453 errors.append(f"Response validation failed: {e}")
454 return False, data, errors
456 # Try msgspec validation
457 if hasattr(schema, "__struct_fields__"):
458 try:
459 import msgspec
461 validated = msgspec.convert(data, type=schema)
462 return True, msgspec.to_builtins(validated), []
463 except Exception as e:
464 errors.append(f"Response validation failed: {e}")
465 return False, data, errors
467 return None
469 def _contains_sql_injection(self, value: str) -> bool:
470 """Check for common SQL injection patterns."""
471 sql_patterns = [
472 "union select",
473 "union all select",
474 "drop table",
475 "delete from",
476 "insert into",
477 "update set",
478 "'; --",
479 "'--",
480 "' or '1'='1",
481 "' or 1=1",
482 '" or "1"="1',
483 "or 1=1",
484 "' or 'x'='x",
485 '" or "x"="x',
486 "admin'--",
487 'admin"--',
488 "') or ('1'='1",
489 '") or ("1"="1',
490 "exec(",
491 "execute(",
492 "xp_cmdshell",
493 "sp_executesql",
494 ]
495 value_lower = value.lower()
496 return any(pattern in value_lower for pattern in sql_patterns)
498 def _contains_path_traversal(self, value: str) -> bool:
499 """Check for path traversal attempts."""
500 traversal_patterns = ["../", "..\\", "%2e%2e", "....//"]
501 return any(pattern in value.lower() for pattern in traversal_patterns)
503 def _sanitize_field(
504 self,
505 key: str,
506 value: t.Any,
507 errors: list[str],
508 ) -> t.Any:
509 """Sanitize a single form field.
511 Args:
512 key: Field name
513 value: Field value
514 errors: Error list to append to
516 Returns:
517 Sanitized value
518 """
519 # Skip non-string values
520 if not isinstance(value, str):
521 return value
523 # Sanitize for XSS (only if ACB available)
524 if self.available and self._config.prevent_xss and self._sanitizer:
525 try:
526 return self._sanitizer.sanitize_html(value)
527 except Exception as e:
528 errors.append(f"Failed to sanitize {key}: {e}")
529 return value
531 return value
533 def _check_security_issues(
534 self,
535 key: str,
536 value: str,
537 errors: list[str],
538 ) -> None:
539 """Check for security issues in form field.
541 Args:
542 key: Field name
543 value: Field value
544 errors: Error list to append to
545 """
546 if not self.available:
547 return
549 # Check for SQL injection
550 if self._config.prevent_sql_injection:
551 if self._contains_sql_injection(value):
552 errors.append(f"Potential SQL injection in {key}")
554 # Check for path traversal
555 if self._config.prevent_path_traversal:
556 if self._contains_path_traversal(value):
557 errors.append(f"Potential path traversal in {key}")
559 def _validate_field_schema(
560 self,
561 field_name: str,
562 value: t.Any,
563 rules: dict[str, t.Any],
564 errors: list[str],
565 ) -> None:
566 """Validate a field against its schema rules.
568 Args:
569 field_name: Field name
570 value: Field value
571 rules: Validation rules
572 errors: Error list to append to
573 """
574 # Check required field
575 if self._check_required_field(field_name, value, rules, errors):
576 return # Field missing, skip other validations
578 # Guard clause: skip if value is None
579 if value is None:
580 return
582 # Apply validation rules
583 self._validate_field_type(field_name, value, rules, errors)
584 self._validate_string_length(field_name, value, rules, errors)
585 self._validate_field_pattern(field_name, value, rules, errors)
587 def _check_required_field(
588 self,
589 field_name: str,
590 value: t.Any,
591 rules: dict[str, t.Any],
592 errors: list[str],
593 ) -> bool:
594 """Check if required field is missing.
596 Args:
597 field_name: Field name
598 value: Field value
599 rules: Validation rules
600 errors: Error list to append to
602 Returns:
603 True if field is required and missing, False otherwise
604 """
605 if not rules.get("required"):
606 return False
608 is_missing = value is None or (isinstance(value, str) and not value.strip())
609 if is_missing:
610 errors.append(f"Required field missing: {field_name}")
612 return is_missing
614 def _validate_field_type(
615 self,
616 field_name: str,
617 value: t.Any,
618 rules: dict[str, t.Any],
619 errors: list[str],
620 ) -> None:
621 """Validate field type.
623 Args:
624 field_name: Field name
625 value: Field value
626 rules: Validation rules
627 errors: Error list to append to
628 """
629 if "type" not in rules:
630 return
632 expected_type = rules["type"]
633 if not isinstance(value, expected_type):
634 errors.append(
635 f"Invalid type for {field_name}: expected {expected_type.__name__}"
636 )
638 def _validate_string_length(
639 self,
640 field_name: str,
641 value: t.Any,
642 rules: dict[str, t.Any],
643 errors: list[str],
644 ) -> None:
645 """Validate string length constraints.
647 Args:
648 field_name: Field name
649 value: Field value
650 rules: Validation rules
651 errors: Error list to append to
652 """
653 if not isinstance(value, str):
654 return
656 if "min_length" in rules and len(value) < rules["min_length"]:
657 errors.append(f"{field_name} too short (min: {rules['min_length']})")
659 if "max_length" in rules and len(value) > rules["max_length"]:
660 errors.append(f"{field_name} too long (max: {rules['max_length']})")
662 def _validate_field_pattern(
663 self,
664 field_name: str,
665 value: t.Any,
666 rules: dict[str, t.Any],
667 errors: list[str],
668 ) -> None:
669 """Validate field against regex pattern.
671 Args:
672 field_name: Field name
673 value: Field value
674 rules: Validation rules
675 errors: Error list to append to
676 """
677 if not value or "pattern" not in rules:
678 return
680 import re
682 if not re.match(
683 rules["pattern"], str(value)
684 ): # REGEX OK: User-provided validation pattern from schema
685 errors.append(f"{field_name} does not match required pattern")
688# Singleton instance
689_validation_service: FastBlocksValidationService | None = None
692def get_validation_service() -> FastBlocksValidationService:
693 """Get the singleton FastBlocksValidationService instance."""
694 global _validation_service
695 if _validation_service is None:
696 _validation_service = FastBlocksValidationService()
697 return _validation_service
700# Decorators for automatic validation
703def _extract_template_context(
704 args: tuple[t.Any, ...], kwargs: dict[str, t.Any]
705) -> tuple[dict[str, t.Any], str]:
706 """Extract context and template name from decorator arguments."""
707 raw_context: t.Any = kwargs.get("context") or (args[3] if len(args) > 3 else {})
708 context: dict[str, t.Any] = raw_context if isinstance(raw_context, dict) else {}
709 template = kwargs.get("template") or (args[2] if len(args) > 2 else "unknown")
710 return context, str(template)
713async def _log_template_validation_errors(
714 errors: list[str],
715 template: str,
716 service: FastBlocksValidationService,
717) -> None:
718 """Log validation errors if configured."""
719 if not (errors and service._config.log_validation_failures):
720 return
722 with suppress(Exception):
723 logger = depends.get("logger")
724 if logger:
725 logger.warning(
726 f"Template context validation warnings for {template}: {errors}"
727 )
730def _update_context_in_args(
731 args: tuple[t.Any, ...],
732 kwargs: dict[str, t.Any],
733 sanitized_context: dict[str, t.Any],
734) -> tuple[tuple[t.Any, ...], dict[str, t.Any]]:
735 """Update args/kwargs with sanitized context."""
736 if "context" in kwargs:
737 kwargs["context"] = sanitized_context
738 elif len(args) > 3:
739 args = (*args[:3], sanitized_context, *args[4:])
740 return args, kwargs
743def validate_template_context(
744 strict: bool = False,
745) -> t.Callable[
746 [t.Callable[..., t.Awaitable[t.Any]]], t.Callable[..., t.Awaitable[t.Any]]
747]:
748 """Decorator to validate template context before rendering.
750 Refactored to reduce cognitive complexity.
752 Usage:
753 @validate_template_context(strict=False)
754 async def render_template(self, request, template, context):
755 ...
756 """
758 def decorator(
759 func: t.Callable[..., t.Awaitable[t.Any]],
760 ) -> t.Callable[..., t.Awaitable[t.Any]]:
761 @functools.wraps(func)
762 async def wrapper(*args: t.Any, **kwargs: t.Any) -> t.Any:
763 # Extract context and template name
764 context, template = _extract_template_context(args, kwargs)
766 # Skip validation if context is empty
767 if not context:
768 return await func(*args, **kwargs)
770 # Validate context
771 service = get_validation_service()
772 (
773 is_valid,
774 sanitized_context,
775 errors,
776 ) = await service.validate_template_context(
777 context=context,
778 template_name=template,
779 strict=strict,
780 )
782 # Log validation errors if configured
783 await _log_template_validation_errors(errors, template, service)
785 # Update with sanitized context
786 args, kwargs = _update_context_in_args(args, kwargs, sanitized_context)
788 # Call original function
789 return await func(*args, **kwargs)
791 return wrapper
793 return decorator
796def _extract_form_data(
797 args: tuple[t.Any, ...], kwargs: dict[str, t.Any]
798) -> dict[str, t.Any]:
799 """Extract form data from decorator arguments."""
800 raw_data: t.Any = kwargs.get("form_data") or (args[2] if len(args) > 2 else {})
801 return raw_data if isinstance(raw_data, dict) else {}
804def _update_form_data(
805 args: tuple[t.Any, ...],
806 kwargs: dict[str, t.Any],
807 sanitized_data: dict[str, t.Any],
808) -> tuple[tuple[t.Any, ...], dict[str, t.Any]]:
809 """Update args/kwargs with sanitized form data."""
810 if "form_data" in kwargs:
811 kwargs["form_data"] = sanitized_data
812 elif len(args) > 2:
813 args = (*args[:2], sanitized_data, *args[3:])
814 return args, kwargs
817async def _handle_form_validation_errors(
818 is_valid: bool,
819 errors: list[str],
820 service: FastBlocksValidationService,
821 strict: bool,
822) -> None:
823 """Handle form validation errors (logging or raising exception)."""
824 if not is_valid and strict:
825 from .exceptions import ErrorCategory, FastBlocksException
827 raise FastBlocksException(
828 message=f"Form validation failed: {'; '.join(errors)}",
829 category=ErrorCategory.VALIDATION,
830 status_code=400,
831 )
833 if errors and service._config.log_validation_failures:
834 with suppress(Exception):
835 logger = depends.get("logger")
836 if logger:
837 logger.warning(f"Form validation errors: {errors}")
840def validate_form_input(
841 schema: dict[str, t.Any] | None = None,
842 strict: bool = True,
843) -> t.Callable[
844 [t.Callable[..., t.Awaitable[t.Any]]], t.Callable[..., t.Awaitable[t.Any]]
845]:
846 """Decorator to validate and sanitize form input.
848 Usage:
849 @validate_form_input(schema={"email": {"required": True, "type": str}})
850 async def handle_form(self, request, form_data):
851 ...
852 """
854 def decorator(
855 func: t.Callable[..., t.Awaitable[t.Any]],
856 ) -> t.Callable[..., t.Awaitable[t.Any]]:
857 @functools.wraps(func)
858 async def wrapper(*args: t.Any, **kwargs: t.Any) -> t.Any:
859 # Extract form data
860 form_data = _extract_form_data(args, kwargs)
862 # Skip validation if no form data
863 if not form_data:
864 return await func(*args, **kwargs)
866 # Validate form data
867 service = get_validation_service()
868 is_valid, sanitized_data, errors = await service.validate_form_input(
869 form_data=form_data,
870 schema=schema,
871 strict=strict,
872 )
874 # Handle validation errors
875 await _handle_form_validation_errors(is_valid, errors, service, strict)
877 # Update with sanitized data
878 args, kwargs = _update_form_data(args, kwargs, sanitized_data)
880 # Call original function
881 return await func(*args, **kwargs)
883 return wrapper
885 return decorator
888def _extract_request_data(
889 args: tuple[t.Any, ...], kwargs: dict[str, t.Any]
890) -> dict[str, t.Any]:
891 """Extract request data from args or kwargs."""
892 raw_data: t.Any = kwargs.get("data") or (args[2] if len(args) > 2 else {})
893 return raw_data if isinstance(raw_data, dict) else {}
896def _update_args_with_data(
897 args: tuple[t.Any, ...],
898 kwargs: dict[str, t.Any],
899 validated_data: dict[str, t.Any],
900) -> tuple[tuple[t.Any, ...], dict[str, t.Any]]:
901 """Update args/kwargs with validated data."""
902 if "data" in kwargs:
903 kwargs["data"] = validated_data
904 elif len(args) > 2:
905 args_list = list(args)
906 args_list[2] = validated_data
907 args = tuple(args_list)
908 return args, kwargs
911async def _validate_request(
912 service: FastBlocksValidationService,
913 request_data: dict[str, t.Any],
914 schema: t.Any,
915) -> dict[str, t.Any]:
916 """Validate request data and raise exception if invalid."""
917 is_valid, validated_data, errors = await service.validate_api_request(
918 request_data=request_data,
919 schema=schema,
920 )
922 if not is_valid:
923 from .exceptions import ErrorCategory, FastBlocksException
925 raise FastBlocksException(
926 message=f"Request validation failed: {'; '.join(errors)}",
927 category=ErrorCategory.VALIDATION,
928 status_code=400,
929 )
931 return validated_data
934async def _validate_response(
935 service: FastBlocksValidationService,
936 response_data: dict[str, t.Any],
937 schema: t.Any,
938) -> dict[str, t.Any]:
939 """Validate response data and log errors if invalid."""
940 is_valid, validated_response, errors = await service.validate_api_response(
941 response_data=response_data,
942 schema=schema,
943 )
945 if not is_valid:
946 with suppress(Exception):
947 logger = depends.get("logger")
948 if logger:
949 logger.error(f"Response validation failed: {errors}")
951 return validated_response
954def validate_api_contract(
955 request_schema: t.Any = None,
956 response_schema: t.Any = None,
957) -> t.Callable[
958 [t.Callable[..., t.Awaitable[t.Any]]], t.Callable[..., t.Awaitable[t.Any]]
959]:
960 """Decorator to validate API request/response contracts.
962 Usage:
963 @validate_api_contract(
964 request_schema=UserCreateSchema,
965 response_schema=UserSchema
966 )
967 async def create_user(self, request, data):
968 ...
969 """
971 def decorator(
972 func: t.Callable[..., t.Awaitable[t.Any]],
973 ) -> t.Callable[..., t.Awaitable[t.Any]]:
974 @functools.wraps(func)
975 async def wrapper(*args: t.Any, **kwargs: t.Any) -> t.Any:
976 service = get_validation_service()
978 # Validate request if schema provided
979 if request_schema:
980 request_data = _extract_request_data(args, kwargs)
981 if request_data:
982 validated_data = await _validate_request(
983 service, request_data, request_schema
984 )
985 args, kwargs = _update_args_with_data(args, kwargs, validated_data)
987 # Call original function
988 result = await func(*args, **kwargs)
990 # Validate response if schema provided
991 if response_schema and isinstance(result, dict):
992 return await _validate_response(service, result, response_schema)
994 return result
996 return wrapper
998 return decorator
1001async def register_fastblocks_validation() -> bool:
1002 """Register FastBlocks validation service with ACB.
1004 Returns:
1005 True if registration successful, False otherwise
1006 """
1007 if not ACB_VALIDATION_AVAILABLE:
1008 return False
1010 try:
1011 # Initialize validation service
1012 validation_service = get_validation_service()
1014 # Register with depends
1015 depends.set("fastblocks_validation", validation_service)
1017 return validation_service.available
1019 except Exception:
1020 return False
1023__all__ = [
1024 "FastBlocksValidationService",
1025 "ValidationConfig",
1026 "ValidationType",
1027 "get_validation_service",
1028 "validate_template_context",
1029 "validate_form_input",
1030 "validate_api_contract",
1031 "register_fastblocks_validation",
1032 "ACB_VALIDATION_AVAILABLE",
1033]