import 'package:angel_http_exception/angel_http_exception.dart'; import 'package:matcher/matcher.dart'; import 'context_aware.dart'; import 'context_validator.dart'; final RegExp _asterisk = RegExp(r'\*$'); final RegExp _forbidden = RegExp(r'!$'); final RegExp _optional = RegExp(r'\?$'); /// Returns a value based the result of a computation. typedef DefaultValueFunction = Function(); /// Generates an error message based on the given input. typedef CustomErrorMessageFunction = String Function(dynamic item); /// Determines if a value is valid. typedef Filter = bool Function(dynamic value); /// Converts the desired fields to their numeric representations, if present. Map autoParse(Map inputData, Iterable fields) { var data = {}; for (var key in inputData.keys) { if (!fields.contains(key)) { data[key.toString()] = inputData[key]; } else { try { var n = inputData[key] is num ? inputData[key] : num.parse(inputData[key].toString()); data[key.toString()] = n == n.toInt() ? n.toInt() : n; } catch (e) { // Invalid number, don't pass it } } } return data; } /// Removes undesired fields from a `Map`. Map filter(Map inputData, Iterable only) { return inputData.keys.fold({}, (map, key) { if (only.contains(key.toString())) map[key.toString()] = inputData[key]; return map; }); } /// Enforces the validity of input data, according to [Matcher]s. class Validator extends Matcher { /// Pre-defined error messages for certain fields. final Map customErrorMessages = {}; /// Values that will be filled for fields if they are not present. final Map defaultValues = {}; /// Fields that cannot be present in valid data. final List forbiddenFields = []; /// Conditions that must be met for input data to be considered valid. final Map> rules = {}; /// Fields that must be present for data to be considered valid. final List requiredFields = []; void _importSchema(Map schema) { for (var keys in schema.keys) { for (var key in keys.split(',').map((s) => s.trim())) { var fieldName = key .replaceAll(_asterisk, '') .replaceAll(_forbidden, '') .replaceAll(_optional, ''); var isForbidden = _forbidden.hasMatch(key), isRequired = _asterisk.hasMatch(key); if (isForbidden) { forbiddenFields.add(fieldName); } else if (isRequired) { requiredFields.add(fieldName); } var _iterable = schema[keys] is Iterable ? schema[keys] : [schema[keys]]; var iterable = []; void _addTo(x) { if (x is Iterable) { x.forEach(_addTo); } else { iterable.add(x); } } _iterable.forEach(_addTo); for (var rule in iterable) { if (rule is Matcher) { addRule(fieldName, rule); } else if (rule is Filter) { addRule(fieldName, predicate(rule)); } else { addRule(fieldName, wrapMatcher(rule)); } } } } } Validator.empty(); Validator(Map schema, {Map defaultValues = const {}, Map customErrorMessages = const {}}) { this.defaultValues.addAll(defaultValues); this.customErrorMessages.addAll(customErrorMessages); _importSchema(schema); } static bool _hasContextValidators(Iterable it) => it.any((x) => x is ContextValidator); /// Validates, and filters input data. ValidationResult check(Map inputData) { var errors = []; var input = Map.from(inputData); var data = {}; for (var key in defaultValues.keys) { if (!input.containsKey(key)) { var value = defaultValues[key]; input[key] = value is DefaultValueFunction ? value() : value; } } for (var field in forbiddenFields) { if (input.containsKey(field)) { if (!customErrorMessages.containsKey(field)) { errors.add("'$field' is forbidden."); } else { errors.add(customError(field, input[field])); } } } for (var field in requiredFields) { if (!_hasContextValidators(rules[field] ?? [])) { if (!input.containsKey(field)) { if (!customErrorMessages.containsKey(field)) { errors.add("'$field' is required."); } else { errors.add(customError(field, 'none')); } } } } // Run context validators. for (var key in input.keys) { if (key is String && rules.containsKey(key)) { var valid = true; var value = input[key]; var description = StringDescription("'$key': expected "); for (var matcher in rules[key]!) { if (matcher is ContextValidator) { if (!matcher.validate(key, input)) { errors.add(matcher .errorMessage(description, key, input) .toString() .trim()); valid = false; } } } if (valid) { for (var matcher in rules[key]!) { try { if (matcher is Validator) { var result = matcher.check(value as Map); if (result.errors.isNotEmpty) { errors.addAll(result.errors); valid = false; break; } } else { bool result; if (matcher is ContextAwareMatcher) { result = matcher.matchesWithContext(value, key, input, {}); } else { result = matcher.matches(value, {}); } if (!result) { if (!customErrorMessages.containsKey(key)) { errors.add(matcher.describe(description).toString().trim()); } valid = false; break; } } } catch (e) { errors.add(e.toString()); valid = false; break; } } } if (valid) { data[key] = value; } else if (customErrorMessages.containsKey(key)) { errors.add(customError(key, input[key])); } } } if (errors.isNotEmpty) { return ValidationResult().._errors.addAll(errors); } return ValidationResult().._data.addAll(data); } /// Validates, and filters input data after running [autoParse]. ValidationResult checkParsed(Map inputData, List fields) => check(autoParse(inputData, fields)); /// Renders the given custom error. String customError(String key, value) { if (!customErrorMessages.containsKey(key)) { throw ArgumentError("No custom error message registered for '$key'."); } var msg = customErrorMessages[key]; if (msg is String) { return msg.replaceAll('{{value}}', value.toString()); } else if (msg is CustomErrorMessageFunction) { return msg(value); } throw ArgumentError("Invalid custom error message '$key': $msg"); } /// Validates input data, and throws an error if it is invalid. /// /// Otherwise, the filtered data is returned. Map enforce(Map inputData, {String errorMessage = 'Invalid data.'}) { var result = check(inputData); if (result._errors.isNotEmpty) { throw ValidationException(errorMessage, errors: result._errors); } return result.data; } /// Validates, and filters input data after running [autoParse], and throws an error if it is invalid. /// /// Otherwise, the filtered data is returned. Map enforceParsed(Map inputData, List fields) => enforce(autoParse(inputData, fields)); /// Creates a copy with additional validation rules. Validator extend(Map schema, {Map defaultValues = const {}, Map customErrorMessages = const {}, bool overwrite = false}) { var _schema = {}; var child = Validator.empty() ..defaultValues.addAll(this.defaultValues) ..defaultValues.addAll(defaultValues) ..customErrorMessages.addAll(this.customErrorMessages) ..customErrorMessages.addAll(customErrorMessages) ..requiredFields.addAll(requiredFields) ..rules.addAll(rules); for (var key in schema.keys) { var fieldName = key .replaceAll(_asterisk, '') .replaceAll(_forbidden, '') .replaceAll(_optional, ''); var isForbidden = _forbidden.hasMatch(key); var isOptional = _optional.hasMatch(key); var isRequired = _asterisk.hasMatch(key); if (isForbidden) { child ..requiredFields.remove(fieldName) ..forbiddenFields.add(fieldName); } else if (isOptional) { child ..forbiddenFields.remove(fieldName) ..requiredFields.remove(fieldName); } else if (isRequired) { child ..forbiddenFields.remove(fieldName) ..requiredFields.add(fieldName); } if (overwrite) { if (child.rules.containsKey(fieldName)) child.rules.remove(fieldName); } _schema[fieldName] = schema[key]; } return child.._importSchema(_schema); } /// Adds a [rule]. void addRule(String key, Matcher rule) { if (!rules.containsKey(key)) { rules[key] = [rule]; return; } rules[key]!.add(rule); } /// Adds all given [rules]. void addRules(String key, Iterable rules) { rules.forEach((rule) => addRule(key, rule)); } /// Removes a [rule]. void removeRule(String key, Matcher rule) { if (rules.containsKey(key)) { rules[key]!.remove(rule); } } /// Removes all given [rules]. void removeRules(String key, Iterable rules) { rules.forEach((rule) => removeRule(key, rule)); } @override Description describe(Description description) => description.add(' passes the provided validation schema: $rules'); @override bool matches(item, Map matchState) { enforce(item as Map); return true; } @override String toString() => 'Validation schema: $rules'; } /// The result of attempting to validate input data. class ValidationResult { final Map _data = {}; final List _errors = []; /// The successfully validated data, filtered from the original input. Map get data => Map.unmodifiable(_data); /// A list of errors that resulted in the given data being marked invalid. /// /// This is empty if validation was successful. List get errors => List.unmodifiable(_errors); ValidationResult withData(Map data) => ValidationResult().._data.addAll(data).._errors.addAll(_errors); ValidationResult withErrors(Iterable errors) => ValidationResult().._data.addAll(_data).._errors.addAll(errors); } /// Occurs when user-provided data is invalid. class ValidationException extends AngelHttpException { /// A list of errors that resulted in the given data being marked invalid. @override final List errors = []; /// A descriptive message describing the error. @override final String message; ValidationException(this.message, {Iterable errors = const []}) : super(FormatException(message), statusCode: 400, errors: (errors).toSet().toList(), stackTrace: StackTrace.current) { this.errors.addAll(errors.toSet()); } @override String toString() { if (errors.isEmpty) { return message; } if (errors.length == 1) { return 'Validation error: ${errors.first}'; } var messages = [ '${errors.length} validation errors:\n', ...errors.map((error) => '* $error') ]; return messages.join('\n'); } }