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

1"""ACB ValidationService integration for FastBlocks. 

2 

3This module provides validation capabilities using ACB's ValidationService, 

4with graceful degradation when ACB validation is not available. 

5 

6Author: lesleslie <les@wedgwoodwebworks.com> 

7Created: 2025-10-01 

8 

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 

16 

17Usage: 

18 # Template context validation 

19 @validate_template_context 

20 async def render_template(request, template, context): 

21 ... 

22 

23 # Form input validation and sanitization 

24 @validate_form_input 

25 async def handle_form_submit(request, form_data): 

26 ... 

27 

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 

34 

35import functools 

36import typing as t 

37from contextlib import suppress 

38from dataclasses import dataclass 

39from enum import Enum 

40 

41from acb.depends import depends 

42 

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 

54 

55with suppress(ImportError): 

56 from acb.services.validation import ( # type: ignore[no-redef] 

57 InputSanitizer, 

58 OutputValidator, 

59 ) 

60 

61 ACB_VALIDATION_AVAILABLE = True 

62 

63 

64class ValidationType(str, Enum): 

65 """Types of validation performed by FastBlocks.""" 

66 

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" 

73 

74 

75@dataclass 

76class ValidationConfig: 

77 """Configuration for FastBlocks validation integration.""" 

78 

79 # Enable/disable specific validation types 

80 validate_templates: bool = True 

81 validate_forms: bool = True 

82 validate_api: bool = True 

83 

84 # Security settings 

85 prevent_xss: bool = True 

86 prevent_sql_injection: bool = True 

87 prevent_path_traversal: bool = True 

88 

89 # Performance settings 

90 cache_validation_results: bool = True 

91 validation_timeout_ms: float = 100.0 

92 

93 # Reporting 

94 collect_validation_metrics: bool = True 

95 log_validation_failures: bool = True 

96 

97 

98class FastBlocksValidationService: 

99 """FastBlocks wrapper for ACB ValidationService with graceful degradation.""" 

100 

101 _instance: t.ClassVar["FastBlocksValidationService | None"] = None 

102 _config: t.ClassVar[ValidationConfig] = ValidationConfig() 

103 

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 

109 

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 

117 

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] 

125 

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 

130 

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. 

138 

139 Args: 

140 key: Context key 

141 value: Context value 

142 errors: Error list to append to 

143 

144 Returns: 

145 Sanitized value 

146 """ 

147 # Skip non-string values (they're safe) 

148 if not isinstance(value, str): 

149 return value 

150 

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 

158 

159 return value 

160 

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. 

168 

169 Args: 

170 sanitized: Sanitized context data 

171 errors: Error list to append to 

172 strict: If True, return False immediately on finding issues 

173 

174 Returns: 

175 False if strict and issues found, True otherwise 

176 """ 

177 if not self._config.prevent_sql_injection: 

178 return True 

179 

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 

185 

186 return True 

187 

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. 

195 

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 

200 

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, [] 

207 

208 errors: list[str] = [] 

209 

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 } 

216 

217 # Check for SQL injection patterns 

218 if not self._check_sql_injection_in_context(sanitized, errors, strict): 

219 return False, context, errors 

220 

221 # Validation passed (or warnings only) 

222 return len(errors) == 0 or not strict, sanitized, errors 

223 

224 except Exception as e: 

225 errors.append(f"Validation error: {e}") 

226 return False, context, errors 

227 

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. 

235 

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 

240 

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, [] 

247 

248 errors: list[str] = [] 

249 

250 try: 

251 # Sanitize and validate fields 

252 sanitized = self._sanitize_form_fields(form_data, errors) 

253 

254 # Apply schema validation if provided 

255 if schema: 

256 self._apply_schema_validation(sanitized, schema, errors) 

257 

258 return len(errors) == 0, sanitized, errors 

259 

260 except Exception as e: 

261 errors.append(f"Validation error: {e}") 

262 return False, form_data, errors 

263 

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. 

270 

271 Args: 

272 form_data: Raw form data 

273 errors: Error list to append to 

274 

275 Returns: 

276 Sanitized form data 

277 """ 

278 sanitized = {} 

279 

280 for key, value in form_data.items(): 

281 sanitized[key] = self._sanitize_field(key, value, errors) 

282 

283 # Security checks for string values 

284 if isinstance(value, str): 

285 self._check_security_issues(key, value, errors) 

286 

287 return sanitized 

288 

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. 

296 

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) 

305 

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. 

312 

313 Args: 

314 request_data: Request data to validate 

315 schema: Validation schema (Pydantic, msgspec, etc.) 

316 

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, [] 

323 

324 errors: list[str] = [] 

325 

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 

332 

333 # Fallback: basic sanitization 

334 sanitized = self._sanitize_api_data(request_data) 

335 return True, sanitized, [] 

336 

337 except Exception as e: 

338 errors.append(f"Validation error: {e}") 

339 return False, request_data, errors 

340 

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. 

348 

349 Args: 

350 data: Data to validate 

351 schema: Validation schema 

352 errors: Error list to append to 

353 

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 

365 

366 # Try msgspec validation 

367 if hasattr(schema, "__struct_fields__"): 

368 try: 

369 import msgspec 

370 

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 

376 

377 return None 

378 

379 def _sanitize_api_data(self, data: dict[str, t.Any]) -> dict[str, t.Any]: 

380 """Apply basic XSS sanitization to API data. 

381 

382 Args: 

383 data: Data to sanitize 

384 

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 

395 

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. 

402 

403 Args: 

404 response_data: Response data to validate 

405 schema: Validation schema (Pydantic, msgspec, etc.) 

406 

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, [] 

413 

414 errors: list[str] = [] 

415 

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 

424 

425 return True, response_data, [] 

426 

427 except Exception as e: 

428 errors.append(f"Validation error: {e}") 

429 return False, response_data, errors 

430 

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. 

438 

439 Args: 

440 data: Response data to validate 

441 schema: Validation schema 

442 errors: Error list to append to 

443 

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 

455 

456 # Try msgspec validation 

457 if hasattr(schema, "__struct_fields__"): 

458 try: 

459 import msgspec 

460 

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 

466 

467 return None 

468 

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) 

497 

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) 

502 

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. 

510 

511 Args: 

512 key: Field name 

513 value: Field value 

514 errors: Error list to append to 

515 

516 Returns: 

517 Sanitized value 

518 """ 

519 # Skip non-string values 

520 if not isinstance(value, str): 

521 return value 

522 

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 

530 

531 return value 

532 

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. 

540 

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 

548 

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}") 

553 

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}") 

558 

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. 

567 

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 

577 

578 # Guard clause: skip if value is None 

579 if value is None: 

580 return 

581 

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) 

586 

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. 

595 

596 Args: 

597 field_name: Field name 

598 value: Field value 

599 rules: Validation rules 

600 errors: Error list to append to 

601 

602 Returns: 

603 True if field is required and missing, False otherwise 

604 """ 

605 if not rules.get("required"): 

606 return False 

607 

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}") 

611 

612 return is_missing 

613 

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. 

622 

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 

631 

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 ) 

637 

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. 

646 

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 

655 

656 if "min_length" in rules and len(value) < rules["min_length"]: 

657 errors.append(f"{field_name} too short (min: {rules['min_length']})") 

658 

659 if "max_length" in rules and len(value) > rules["max_length"]: 

660 errors.append(f"{field_name} too long (max: {rules['max_length']})") 

661 

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. 

670 

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 

679 

680 import re 

681 

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") 

686 

687 

688# Singleton instance 

689_validation_service: FastBlocksValidationService | None = None 

690 

691 

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 

698 

699 

700# Decorators for automatic validation 

701 

702 

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) 

711 

712 

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 

721 

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 ) 

728 

729 

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 

741 

742 

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. 

749 

750 Refactored to reduce cognitive complexity. 

751 

752 Usage: 

753 @validate_template_context(strict=False) 

754 async def render_template(self, request, template, context): 

755 ... 

756 """ 

757 

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) 

765 

766 # Skip validation if context is empty 

767 if not context: 

768 return await func(*args, **kwargs) 

769 

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 ) 

781 

782 # Log validation errors if configured 

783 await _log_template_validation_errors(errors, template, service) 

784 

785 # Update with sanitized context 

786 args, kwargs = _update_context_in_args(args, kwargs, sanitized_context) 

787 

788 # Call original function 

789 return await func(*args, **kwargs) 

790 

791 return wrapper 

792 

793 return decorator 

794 

795 

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 {} 

802 

803 

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 

815 

816 

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 

826 

827 raise FastBlocksException( 

828 message=f"Form validation failed: {'; '.join(errors)}", 

829 category=ErrorCategory.VALIDATION, 

830 status_code=400, 

831 ) 

832 

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}") 

838 

839 

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. 

847 

848 Usage: 

849 @validate_form_input(schema={"email": {"required": True, "type": str}}) 

850 async def handle_form(self, request, form_data): 

851 ... 

852 """ 

853 

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) 

861 

862 # Skip validation if no form data 

863 if not form_data: 

864 return await func(*args, **kwargs) 

865 

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 ) 

873 

874 # Handle validation errors 

875 await _handle_form_validation_errors(is_valid, errors, service, strict) 

876 

877 # Update with sanitized data 

878 args, kwargs = _update_form_data(args, kwargs, sanitized_data) 

879 

880 # Call original function 

881 return await func(*args, **kwargs) 

882 

883 return wrapper 

884 

885 return decorator 

886 

887 

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 {} 

894 

895 

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 

909 

910 

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 ) 

921 

922 if not is_valid: 

923 from .exceptions import ErrorCategory, FastBlocksException 

924 

925 raise FastBlocksException( 

926 message=f"Request validation failed: {'; '.join(errors)}", 

927 category=ErrorCategory.VALIDATION, 

928 status_code=400, 

929 ) 

930 

931 return validated_data 

932 

933 

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 ) 

944 

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}") 

950 

951 return validated_response 

952 

953 

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. 

961 

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 """ 

970 

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() 

977 

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) 

986 

987 # Call original function 

988 result = await func(*args, **kwargs) 

989 

990 # Validate response if schema provided 

991 if response_schema and isinstance(result, dict): 

992 return await _validate_response(service, result, response_schema) 

993 

994 return result 

995 

996 return wrapper 

997 

998 return decorator 

999 

1000 

1001async def register_fastblocks_validation() -> bool: 

1002 """Register FastBlocks validation service with ACB. 

1003 

1004 Returns: 

1005 True if registration successful, False otherwise 

1006 """ 

1007 if not ACB_VALIDATION_AVAILABLE: 

1008 return False 

1009 

1010 try: 

1011 # Initialize validation service 

1012 validation_service = get_validation_service() 

1013 

1014 # Register with depends 

1015 depends.set("fastblocks_validation", validation_service) 

1016 

1017 return validation_service.available 

1018 

1019 except Exception: 

1020 return False 

1021 

1022 

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]