import 'schema_registry.dart'; import 'validation_result.dart'; import 'field_mapper.dart'; import '../utils/debug_logger.dart'; /// Comprehensive API request and response validator /// Validates against OpenAPI specification schemas class ApiValidator { static final ApiValidator _instance = ApiValidator._internal(); factory ApiValidator() => _instance; ApiValidator._internal(); final SchemaRegistry _schemaRegistry = SchemaRegistry(); final FieldMapper _fieldMapper = FieldMapper(); bool _initialized = false; bool get isInitialized => _initialized; /// Initialize validator with OpenAPI schemas Future initialize() async { if (_initialized) return; try { await _schemaRegistry.loadSchemas(); _initialized = true; DebugLogger.validation('Successfully initialized with schemas'); } catch (e) { DebugLogger.error('Failed to initialize', e); // Continue without validation if schemas can't be loaded } } /// Validate request payload before sending to API ValidationResult validateRequest( dynamic data, String endpoint, { String method = 'GET', }) { if (!_initialized) { return ValidationResult.warning( 'Validator not initialized - skipping validation', ); } try { final schema = _schemaRegistry.getRequestSchema(endpoint, method); if (schema == null) { return ValidationResult.warning( 'No schema found for $method $endpoint', ); } // Transform field names for API (camelCase -> snake_case) final transformedData = _fieldMapper.toApiFormat(data); // Validate against schema return _validateAgainstSchema(transformedData, schema, 'request'); } catch (e) { return ValidationResult.error('Request validation failed: $e'); } } /// Validate response payload after receiving from API ValidationResult validateResponse( dynamic data, String endpoint, { String method = 'GET', int? statusCode, }) { if (!_initialized) { return ValidationResult.warning( 'Validator not initialized - skipping validation', ); } try { final schema = _schemaRegistry.getResponseSchema( endpoint, method, statusCode, ); if (schema == null) { return ValidationResult.warning( 'No schema found for $method $endpoint response', ); } // Validate against schema first final validationResult = _validateAgainstSchema(data, schema, 'response'); if (!validationResult.isValid) { return validationResult; } // Transform field names from API (snake_case -> camelCase) final transformedData = _fieldMapper.fromApiFormat(data); return ValidationResult.success( 'Response validated successfully', data: transformedData, ); } catch (e) { return ValidationResult.error('Response validation failed: $e'); } } /// Validate data against a specific schema ValidationResult _validateAgainstSchema( dynamic data, Map schema, String context, ) { final errors = []; final warnings = []; try { _validateValue(data, schema, '', errors, warnings); if (errors.isNotEmpty) { return ValidationResult.error( 'Schema validation failed for $context', errors: errors, warnings: warnings, ); } if (warnings.isNotEmpty) { return ValidationResult.warning( 'Schema validation passed with warnings for $context', warnings: warnings, ); } return ValidationResult.success('Schema validation passed for $context'); } catch (e) { return ValidationResult.error('Schema validation error for $context: $e'); } } /// Recursively validate a value against schema void _validateValue( dynamic value, Map schema, String path, List errors, List warnings, ) { final type = schema['type'] as String?; final required = schema['required'] as List? ?? []; // Handle null values if (value == null) { if (required.isNotEmpty && path.isNotEmpty) { errors.add('Required field missing: $path'); } return; } // Type validation switch (type) { case 'object': _validateObject(value, schema, path, errors, warnings); break; case 'array': _validateArray(value, schema, path, errors, warnings); break; case 'string': _validateString(value, schema, path, errors, warnings); break; case 'number': case 'integer': _validateNumber(value, schema, path, errors, warnings); break; case 'boolean': _validateBoolean(value, schema, path, errors, warnings); break; default: // Unknown type - add warning but don't fail warnings.add('Unknown schema type "$type" at $path'); } } void _validateObject( dynamic value, Map schema, String path, List errors, List warnings, ) { if (value is! Map) { errors.add('Expected object at $path, got ${value.runtimeType}'); return; } final valueMap = value as Map; final properties = schema['properties'] as Map? ?? {}; final required = (schema['required'] as List? ?? []) .cast(); // Check required fields for (final requiredField in required) { if (!valueMap.containsKey(requiredField)) { errors.add( 'Required field missing: ${path.isEmpty ? '' : '$path.'}$requiredField', ); } } // Validate each property for (final entry in valueMap.entries) { final fieldName = entry.key; final fieldValue = entry.value; final fieldPath = path.isEmpty ? fieldName : '$path.$fieldName'; if (properties.containsKey(fieldName)) { _validateValue( fieldValue, properties[fieldName], fieldPath, errors, warnings, ); } else { // Additional property - warn but don't error warnings.add('Additional property found: $fieldPath'); } } } void _validateArray( dynamic value, Map schema, String path, List errors, List warnings, ) { if (value is! List) { errors.add('Expected array at $path, got ${value.runtimeType}'); return; } final array = value; final items = schema['items'] as Map?; final minItems = schema['minItems'] as int?; final maxItems = schema['maxItems'] as int?; // Validate array constraints if (minItems != null && array.length < minItems) { errors.add( 'Array at $path has ${array.length} items, minimum is $minItems', ); } if (maxItems != null && array.length > maxItems) { errors.add( 'Array at $path has ${array.length} items, maximum is $maxItems', ); } // Validate each item if (items != null) { for (int i = 0; i < array.length; i++) { _validateValue(array[i], items, '$path[$i]', errors, warnings); } } } void _validateString( dynamic value, Map schema, String path, List errors, List warnings, ) { if (value is! String) { errors.add('Expected string at $path, got ${value.runtimeType}'); return; } final string = value; final minLength = schema['minLength'] as int?; final maxLength = schema['maxLength'] as int?; final pattern = schema['pattern'] as String?; final format = schema['format'] as String?; if (minLength != null && string.length < minLength) { errors.add( 'String at $path is ${string.length} chars, minimum is $minLength', ); } if (maxLength != null && string.length > maxLength) { errors.add( 'String at $path is ${string.length} chars, maximum is $maxLength', ); } if (pattern != null) { try { final regex = RegExp(pattern); if (!regex.hasMatch(string)) { errors.add('String at $path does not match pattern: $pattern'); } } catch (e) { warnings.add('Invalid regex pattern at $path: $pattern'); } } // Validate common formats if (format != null) { _validateStringFormat(string, format, path, errors, warnings); } } void _validateStringFormat( String value, String format, String path, List errors, List warnings, ) { switch (format) { case 'email': final emailRegex = RegExp(r'^[^\s@]+@[^\s@]+\.[^\s@]+$'); if (!emailRegex.hasMatch(value)) { errors.add('Invalid email format at $path: $value'); } break; case 'uri': case 'url': try { Uri.parse(value); } catch (e) { errors.add('Invalid URL format at $path: $value'); } break; case 'date': try { DateTime.parse(value); } catch (e) { errors.add('Invalid date format at $path: $value'); } break; case 'date-time': try { DateTime.parse(value); } catch (e) { errors.add('Invalid datetime format at $path: $value'); } break; case 'uuid': final uuidRegex = RegExp( r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', caseSensitive: false, ); if (!uuidRegex.hasMatch(value)) { errors.add('Invalid UUID format at $path: $value'); } break; default: warnings.add('Unknown string format "$format" at $path'); } } void _validateNumber( dynamic value, Map schema, String path, List errors, List warnings, ) { if (value is! num) { errors.add('Expected number at $path, got ${value.runtimeType}'); return; } final number = value; final minimum = schema['minimum'] as num?; final maximum = schema['maximum'] as num?; final multipleOf = schema['multipleOf'] as num?; if (minimum != null && number < minimum) { errors.add('Number at $path is $number, minimum is $minimum'); } if (maximum != null && number > maximum) { errors.add('Number at $path is $number, maximum is $maximum'); } if (multipleOf != null && number % multipleOf != 0) { errors.add('Number at $path ($number) is not a multiple of $multipleOf'); } } void _validateBoolean( dynamic value, Map schema, String path, List errors, List warnings, ) { if (value is! bool) { errors.add('Expected boolean at $path, got ${value.runtimeType}'); } } /// Transform and validate data for API consumption Map transformForApi(Map data) { return _fieldMapper.toApiFormat(data); } /// Transform and validate data from API response Map transformFromApi(Map data) { return _fieldMapper.fromApiFormat(data); } }