import 'package:matcher/matcher.dart'; final RegExp _asterisk = new RegExp(r'\*$'); final RegExp _forbidden = new RegExp(r'!$'); final RegExp _optional = new RegExp(r'\?$'); /// Returns a value based the result of a computation. typedef DefaultValueFunction(); /// Generates an error message based on the given input. typedef String CustomErrorMessageFunction(item); /// Determines if a value is valid. typedef bool Filter(value); /// Converts the desired fields to their numeric representations, if present. Map<String, dynamic> autoParse(Map inputData, Iterable<String> fields) { Map<String, dynamic> data = {}; for (var key in inputData.keys) { if (!fields.contains(key)) { data[key] = inputData[key]; } else { try { var n = inputData[key] is num ? inputData[key] : num.parse(inputData[key].toString()); data[key] = n == n.toInt() ? n.toInt() : n; } catch (e) { // Invalid number, don't pass it } } } return data; } /// Removes undesired fields from a `Map`. Map<String, dynamic> filter(Map inputData, Iterable<String> only) { return inputData.keys.fold(<String, dynamic>{}, (map, key) { if (only.contains(key)) map[key] = 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<String, dynamic> customErrorMessages = {}; /// Values that will be filled for fields if they are not present. final Map<String, dynamic> defaultValues = {}; /// Fields that cannot be present in valid data. final List<String> forbiddenFields = []; /// Conditions that must be met for input data to be considered valid. final Map<String, List<Matcher>> rules = {}; /// Fields that must be present for data to be considered valid. final List<String> requiredFields = []; void _importSchema(Map<String, dynamic> 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); } Iterable _iterable = schema[keys] is Iterable ? schema[keys] : [schema[keys]]; var iterable = []; _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 { throw new ArgumentError( 'Cannot use a(n) ${rule.runtimeType} as a validation rule.'); } } } } } Validator.empty(); Validator(Map<String, dynamic> schema, {Map<String, dynamic> defaultValues: const {}, Map<String, dynamic> customErrorMessages: const {}}) { this.defaultValues.addAll(defaultValues ?? {}); this.customErrorMessages.addAll(customErrorMessages ?? {}); _importSchema(schema); } /// Validates, and filters input data. ValidationResult check(Map inputData) { List<String> errors = []; var input = new Map.from(inputData); Map<String, dynamic> data = {}; for (String key in defaultValues.keys) { if (!input.containsKey(key)) { var value = defaultValues[key]; input[key] = value is DefaultValueFunction ? value() : value; } } for (String field in forbiddenFields) { if (input.containsKey(field)) { if (!customErrorMessages.containsKey(field)) errors.add("'$field' is forbidden."); else errors.add(customError(field, input[field])); } } for (String field in requiredFields) { if (!input.containsKey(field)) { if (!customErrorMessages.containsKey(field)) errors.add("'$field' is required."); else errors.add(customError(field, 'none')); } } for (var key in input.keys) { if (key is String && rules.containsKey(key)) { var valid = true; var value = input[key]; var description = new StringDescription("'$key': expected "); for (Matcher matcher in rules[key]) { try { if (matcher is Validator) { var result = matcher.check(value); if (result.errors.isNotEmpty) { errors.addAll(result.errors); valid = false; break; } } else { if (!matcher.matches(value, {})) { 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 new ValidationResult().._errors.addAll(errors); } return new ValidationResult().._data = data; } /// Validates, and filters input data after running [autoParse]. ValidationResult checkParsed(Map inputData, List<String> fields) => check(autoParse(inputData, fields)); /// Renders the given custom error. String customError(String key, value) { if (!customErrorMessages.containsKey(key)) throw new 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 new 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<String, dynamic> enforce(Map inputData, {String errorMessage: 'Invalid data.'}) { var result = check(inputData); if (result._errors.isNotEmpty) { throw new 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<String, dynamic> enforceParsed(Map inputData, List<String> fields) => enforce(autoParse(inputData, fields)); /// Creates a copy with additional validation rules. Validator extend(Map<String, dynamic> schema, {Map<String, dynamic> defaultValues: const {}, Map<String, dynamic> customErrorMessages: const {}, bool overwrite: false}) { Map<String, dynamic> _schema = {}; var child = new 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<Matcher> 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<Matcher> 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); return true; } @override String toString() => 'Validation schema: $rules'; } /// The result of attempting to validate input data. class ValidationResult { Map<String, dynamic> _data; final List<String> _errors = []; /// The successfully validated data, filtered from the original input. Map<String, dynamic> get data => _data; /// A list of errors that resulted in the given data being marked invalid. /// /// This is empty if validation was successful. List<String> get errors => new List<String>.unmodifiable(_errors); } /// Occurs when user-provided data is invalid. class ValidationException { /// A list of errors that resulted in the given data being marked invalid. final List<String> errors = []; /// A descriptive message describing the error. final String message; ValidationException(this.message, {List<String> errors: const []}) { if (errors != null) this.errors.addAll(errors); } @override String toString() { if (errors.isEmpty) { return message; } if (errors.length == 1) { return 'Validation error: ${errors.first}'; } var messages = ['${errors.length} validation errors:\n'] ..addAll(errors.map((error) => '* $error')); return messages.join('\n'); } }