diff --git a/example/main.dart b/example/main.dart index f9ed89b5..8b137891 100644 --- a/example/main.dart +++ b/example/main.dart @@ -1,27 +1 @@ -import 'package:angel_validate/angel_validate.dart'; -main() { - var bio = Validator({ - 'age*': [isInt, greaterThanOrEqualTo(0)], - 'birthYear*': isInt, - 'countryOfOrigin': isString - }); - - var book = Validator({ - 'title*': isString, - 'year*': [ - isNum, - (year) { - return year <= DateTime.now().year; - } - ] - }); - - // ignore: unused_local_variable - var author = Validator({ - 'bio*': bio, - 'books*': [isList, everyElement(book)] - }, defaultValues: { - 'books': [] - }); -} diff --git a/lib/angel_forms.dart b/lib/angel_forms.dart new file mode 100644 index 00000000..da383990 --- /dev/null +++ b/lib/angel_forms.dart @@ -0,0 +1,2 @@ +export 'src/field.dart'; +export 'src/form.dart'; diff --git a/lib/angel_validate.dart b/lib/angel_validate.dart deleted file mode 100644 index 0fa2c34d..00000000 --- a/lib/angel_validate.dart +++ /dev/null @@ -1,14 +0,0 @@ -/// Cross-platform validation library based on `matcher`. -library angel_validate; - -export 'package:matcher/matcher.dart'; -export 'src/context_aware.dart'; -export 'src/matchers.dart'; -export 'src/validator.dart'; - -/// Marks a field name as required. -String requireField(String field) => '$field*'; - -/// Marks multiple fields as required. -String requireFields(Iterable fields) => - fields.map(requireField).join(', '); diff --git a/lib/server.dart b/lib/server.dart deleted file mode 100644 index 9ae41185..00000000 --- a/lib/server.dart +++ /dev/null @@ -1,144 +0,0 @@ -/// Support for using `angel_validate` with the Angel Framework. -library angel_validate.server; - -import 'dart:async'; - -import 'package:angel_framework/angel_framework.dart'; -import 'src/async.dart'; -import 'angel_validate.dart'; -export 'src/async.dart'; -export 'angel_validate.dart'; - -/// Auto-parses numbers in `req.bodyAsMap`. -RequestHandler autoParseBody(List fields) { - return (RequestContext req, res) async { - await req.parseBody(); - req.bodyAsMap.addAll(autoParse(req.bodyAsMap, fields)); - return true; - }; -} - -/// Auto-parses numbers in `req.queryParameters`. -RequestHandler autoParseQuery(List fields) { - return (RequestContext req, res) async { - req.queryParameters.addAll(autoParse(req.queryParameters, fields)); - return true; - }; -} - -/// Filters unwanted data out of `req.bodyAsMap`. -RequestHandler filterBody(Iterable only) { - return (RequestContext req, res) async { - await req.parseBody(); - var filtered = filter(req.bodyAsMap, only); - req.bodyAsMap - ..clear() - ..addAll(filtered); - return true; - }; -} - -/// Filters unwanted data out of `req.queryParameters`. -RequestHandler filterQuery(Iterable only) { - return (RequestContext req, res) async { - var filtered = filter(req.queryParameters, only); - req.queryParameters - ..clear() - ..addAll(filtered); - return true; - }; -} - -/// Validates the data in `req.bodyAsMap`, and sets the body to -/// filtered data before continuing the response. -RequestHandler validate(Validator validator, - {String errorMessage = 'Invalid data.'}) { - return (RequestContext req, res) async { - await req.parseBody(); - var result = await asyncApplyValidator(validator, req.bodyAsMap, req.app); - - if (result.errors.isNotEmpty) { - throw AngelHttpException.badRequest( - message: errorMessage, errors: result.errors); - } - - req.bodyAsMap - ..clear() - ..addAll(result.data); - - return true; - }; -} - -/// Validates the data in `req.queryParameters`, and sets the query to -/// filtered data before continuing the response. -RequestHandler validateQuery(Validator validator, - {String errorMessage = 'Invalid data.'}) { - return (RequestContext req, res) async { - var result = - await asyncApplyValidator(validator, req.queryParameters, req.app); - - if (result.errors.isNotEmpty) { - throw AngelHttpException.badRequest( - message: errorMessage, errors: result.errors); - } - - req.queryParameters - ..clear() - ..addAll(result.data); - - return true; - }; -} - -/// Validates the data in `e.data`, and sets the data to -/// filtered data before continuing the service event. -HookedServiceEventListener validateEvent(Validator validator, - {String errorMessage = 'Invalid data.'}) { - return (HookedServiceEvent e) async { - var result = await asyncApplyValidator( - validator, e.data as Map, (e.request?.app ?? e.service.app)); - - if (result.errors.isNotEmpty) { - throw AngelHttpException.badRequest( - message: errorMessage, errors: result.errors); - } - - e.data - ..clear() - ..addAll(result.data); - }; -} - -/// Asynchronously apply a [validator], running any [AngelMatcher]s. -Future asyncApplyValidator( - Validator validator, Map data, Angel app) async { - var result = validator.check(data); - if (result.errors.isNotEmpty) return result; - - var errantKeys = [], errors = []; - - for (var key in result.data.keys) { - var value = result.data[key]; - var description = StringDescription("'$key': expected "); - - for (var rule in validator.rules[key]) { - if (rule is AngelMatcher) { - var r = await rule.matchesWithAngel(value, key, result.data, {}, app); - - if (!r) { - errors.add(rule.describe(description).toString().trim()); - errantKeys.add(key); - break; - } - } - } - } - - var m = Map.from(result.data); - for (var key in errantKeys) { - m.remove(key); - } - - return result.withData(m).withErrors(errors); -} diff --git a/lib/src/async.dart b/lib/src/async.dart deleted file mode 100644 index 11099fb2..00000000 --- a/lib/src/async.dart +++ /dev/null @@ -1,162 +0,0 @@ -import 'dart:async'; -import 'package:angel_framework/angel_framework.dart'; -import 'package:angel_http_exception/angel_http_exception.dart'; -import 'package:matcher/matcher.dart'; -import 'context_aware.dart'; - -/// Returns an [AngelMatcher] that uses an arbitrary function that returns -/// true or false for the actual value. -/// -/// Analogous to the synchronous [predicate] matcher. -AngelMatcher predicateWithAngel( - FutureOr Function(String, Object, Angel) f, - [String description = 'satisfies function']) => - _PredicateWithAngel(f, description); - -/// Returns an [AngelMatcher] that applies an asynchronously-created [Matcher] -/// to the input. -/// -/// Use this to match values against configuration, injections, etc. -AngelMatcher matchWithAngel(FutureOr Function(Object, Map, Angel) f, - [String description = 'satisfies asynchronously created matcher']) => - _MatchWithAngel(f, description); - -/// Calls [matchWithAngel] without the initial parameter. -AngelMatcher matchWithAngelBinary( - FutureOr Function(Map context, Angel) f, - [String description = 'satisfies asynchronously created matcher']) => - matchWithAngel((_, context, app) => f(context, app)); - -/// Calls [matchWithAngel] without the initial two parameters. -AngelMatcher matchWithAngelUnary(FutureOr Function(Angel) f, - [String description = 'satisfies asynchronously created matcher']) => - matchWithAngelBinary((_, app) => f(app)); - -/// Calls [matchWithAngel] without any parameters. -AngelMatcher matchWithAngelNullary(FutureOr Function() f, - [String description = 'satisfies asynchronously created matcher']) => - matchWithAngelUnary((_) => f()); - -/// Returns an [AngelMatcher] that represents [x]. -/// -/// If [x] is an [AngelMatcher], then it is returned, unmodified. -AngelMatcher wrapAngelMatcher(x) { - if (x is AngelMatcher) return x; - if (x is ContextAwareMatcher) return _WrappedAngelMatcher(x); - return wrapAngelMatcher(wrapContextAwareMatcher(x)); -} - -/// Returns an [AngelMatcher] that asynchronously resolves a [feature], builds a [matcher], and executes it. -AngelMatcher matchAsync(FutureOr Function(String, Object) matcher, - FutureOr Function() feature, - [String description = 'satisfies asynchronously created matcher']) { - return _MatchAsync(matcher, feature, description); -} - -/// Returns an [AngelMatcher] that verifies that an item with the given [idField] -/// exists in the service at [servicePath], without throwing a `404` or returning `null`. -AngelMatcher idExistsInService(String servicePath, - {String idField = 'id', String description}) { - return predicateWithAngel( - (key, item, app) async { - try { - var result = await app.findService(servicePath)?.read(item); - return result != null; - } on AngelHttpException catch (e) { - if (e.statusCode == 404) { - return false; - } else { - rethrow; - } - } - }, - description ?? 'exists in service $servicePath', - ); -} - -/// An asynchronous [Matcher] that runs in the context of an [Angel] app. -abstract class AngelMatcher extends ContextAwareMatcher { - Future matchesWithAngel( - item, String key, Map context, Map matchState, Angel app); - - @override - bool matchesWithContext(item, String key, Map context, Map matchState) { - return true; - } -} - -class _WrappedAngelMatcher extends AngelMatcher { - final ContextAwareMatcher matcher; - - _WrappedAngelMatcher(this.matcher); - - @override - Description describe(Description description) => - matcher.describe(description); - - @override - Future matchesWithAngel( - item, String key, Map context, Map matchState, Angel app) async { - return matcher.matchesWithContext(item, key, context, matchState); - } -} - -class _MatchWithAngel extends AngelMatcher { - final FutureOr Function(Object, Map, Angel) f; - final String description; - - _MatchWithAngel(this.f, this.description); - - @override - Description describe(Description description) => this.description == null - ? description - : description.add(this.description); - - @override - Future matchesWithAngel( - item, String key, Map context, Map matchState, Angel app) { - return Future.sync(() => f(item, context, app)).then((result) { - return result.matches(item, matchState); - }); - } -} - -class _PredicateWithAngel extends AngelMatcher { - final FutureOr Function(String, Object, Angel) predicate; - final String description; - - _PredicateWithAngel(this.predicate, this.description); - - @override - Description describe(Description description) => this.description == null - ? description - : description.add(this.description); - - @override - Future matchesWithAngel( - item, String key, Map context, Map matchState, Angel app) { - return Future.sync(() => predicate(key, item, app)); - } -} - -class _MatchAsync extends AngelMatcher { - final FutureOr Function(String, Object) matcher; - final FutureOr Function() feature; - final String description; - - _MatchAsync(this.matcher, this.feature, this.description); - - @override - Description describe(Description description) => this.description == null - ? description - : description.add(this.description); - - @override - Future matchesWithAngel( - item, String key, Map context, Map matchState, Angel app) async { - var f = await feature(); - var m = await matcher(key, f); - var c = wrapAngelMatcher(m); - return await c.matchesWithAngel(item, key, context, matchState, app); - } -} diff --git a/lib/src/context_aware.dart b/lib/src/context_aware.dart deleted file mode 100644 index d9d3de61..00000000 --- a/lib/src/context_aware.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:matcher/matcher.dart'; - -/// Returns a [ContextAwareMatcher] for the given predicate. -ContextAwareMatcher predicateWithContext( - bool Function(Object, String, Map, Map) f, - [String description = 'satisfies function']) { - return _PredicateWithContext(f, description); -} - -/// Wraps [x] in a [ContextAwareMatcher]. -ContextAwareMatcher wrapContextAwareMatcher(x) { - if (x is ContextAwareMatcher) { - return x; - } else if (x is Matcher) return _WrappedContextAwareMatcher(x); - return wrapContextAwareMatcher(wrapMatcher(x)); -} - -/// A special [Matcher] that is aware of the context in which it is being executed. -abstract class ContextAwareMatcher extends Matcher { - bool matchesWithContext(item, String key, Map context, Map matchState); - - @override - bool matches(item, Map matchState) => true; -} - -class _WrappedContextAwareMatcher extends ContextAwareMatcher { - final Matcher matcher; - - _WrappedContextAwareMatcher(this.matcher); - - @override - Description describe(Description description) => - matcher.describe(description); - - @override - bool matchesWithContext(item, String key, Map context, Map matchState) => - matcher.matches(item, matchState); -} - -class _PredicateWithContext extends ContextAwareMatcher { - final bool Function(Object, String, Map, Map) f; - final String desc; - - _PredicateWithContext(this.f, this.desc); - - @override - Description describe(Description description) => - desc == null ? description : description.add(desc); - - @override - bool matchesWithContext(item, String key, Map context, Map matchState) => - f(item, key, context, matchState); -} diff --git a/lib/src/context_validator.dart b/lib/src/context_validator.dart deleted file mode 100644 index 13ea24c5..00000000 --- a/lib/src/context_validator.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:matcher/matcher.dart'; - -/// A [Matcher] directly invoked by `package:angel_serialize` to validate the context. -class ContextValidator extends Matcher { - final bool Function(String, Map) validate; - final Description Function(Description, String, Map) errorMessage; - - ContextValidator(this.validate, this.errorMessage); - - @override - Description describe(Description description) => description; - - @override - bool matches(item, Map matchState) => true; -} diff --git a/lib/src/field.dart b/lib/src/field.dart new file mode 100644 index 00000000..01de3314 --- /dev/null +++ b/lib/src/field.dart @@ -0,0 +1,66 @@ +import 'dart:async'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:matcher/matcher.dart'; +import 'form_renderer.dart'; + +class FieldReadResult { + final bool isSuccess; + final T value; + final Iterable errors; + + FieldReadResult.success(this.value) + : isSuccess = true, + errors = null; + + FieldReadResult.failure(this.errors) + : isSuccess = false, + value = null; +} + +abstract class Field { + final String name; + final String label; + final bool isRequired; + + Field(this.name, {this.label, this.isRequired = false}); + + FutureOr> read(RequestContext req); + + FutureOr accept(FormRenderer renderer); + + Field match(Iterable matchers) => _MatchedField(this, matchers); +} + +class _MatchedField extends Field { + final Field inner; + final Iterable matchers; + + _MatchedField(this.inner, this.matchers) + : super(inner.name, label: inner.label, isRequired: inner.isRequired) { + assert(matchers.isNotEmpty); + } + + @override + FutureOr accept(FormRenderer renderer) => inner.accept(renderer); + + @override + Future> read(RequestContext req) async { + var result = await inner.read(req); + if (!result.isSuccess) { + return result; + } else { + var errors = []; + for (var matcher in matchers) { + if (!matcher.matches(result.value, {})) { + var desc = matcher.describe(StringDescription()); + errors.add('Expected $desc for field "${inner.name}".'); + } + } + if (errors.isEmpty) { + return result; + } else { + return FieldReadResult.failure(errors); + } + } + } +} diff --git a/lib/src/form.dart b/lib/src/form.dart new file mode 100644 index 00000000..15e1f37f --- /dev/null +++ b/lib/src/form.dart @@ -0,0 +1,97 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:matcher/matcher.dart'; +import 'field.dart'; + +/// A utility that combines multiple [Field]s to read and +/// validate web forms in a type-safe manner. +/// +/// Example: +/// ```dart +/// import 'package:angel_forms/angel_forms.dart'; +/// import 'package:angel_validate/angel_validate.dart'; +/// +/// var myForm = Form(fields: [ +/// TextField('username').match([]), +/// TextField('password', confirmedAs: 'confirm_password'), +/// ]) +/// +/// app.post('/login', (req, res) async { +/// var loginRequest = +/// await myForm.decode(req, loginRequestSerializer); +/// // Do something with the decode object... +/// }); +/// ``` +class Form { + final String errorMessage; + + final List _fields = []; + final List _errors = []; + + static const String defaultErrorMessage = + 'There were errors in your submission. ' + 'Please make sure all fields entered correctly, and submit it again.'; + + Form({this.errorMessage = defaultErrorMessage, Iterable fields}) { + fields?.forEach(addField); + } + + List get fields => _fields; + + List get errors => _errors; + + Field addField(Field field, {Iterable matchers}) { + if (matchers != null) { + field = field.match(matchers); + } + _fields.add(field); + return field; + } + + Future deserialize( + RequestContext req, T Function(Map) f) { + return validate(req).then(f); + } + + Future decode(RequestContext req, Codec codec) { + return deserialize(req, codec.decode); + } + + Future> validate(RequestContext req) async { + var result = await read(req); + if (!result.isSuccess) { + throw AngelHttpException.badRequest( + message: errorMessage, errors: result.errors.toList()); + } else { + return result.value; + } + } + + /// Reads the body of the [RequestContext], and returns an object detailing + /// whether valid values were provided for all [fields]. + /// + /// In most cases, you'll want to use [validate] instead. + Future>> read(RequestContext req) async { + var out = {}; + var errors = []; + await req.parseBody(); + + for (var field in fields) { + var result = await field.read(req); + if (result == null && field.isRequired) { + errors.add('The field "${field.name}" is required.'); + } else if (!result.isSuccess) { + errors.addAll(result.errors); + } else { + out[field.name] = result.value; + } + } + + if (errors.isNotEmpty) { + return FieldReadResult.failure(errors); + } else { + return FieldReadResult.success(out); + } + } +} diff --git a/lib/src/form_renderer.dart b/lib/src/form_renderer.dart new file mode 100644 index 00000000..fa733ea4 --- /dev/null +++ b/lib/src/form_renderer.dart @@ -0,0 +1,6 @@ +import 'dart:async'; +import 'field.dart'; + +abstract class FormRenderer { + const FormRenderer(); +} diff --git a/lib/src/matchers.dart b/lib/src/matchers.dart deleted file mode 100644 index 3a6c7be0..00000000 --- a/lib/src/matchers.dart +++ /dev/null @@ -1,133 +0,0 @@ -import 'package:matcher/matcher.dart'; -import 'context_aware.dart'; -import 'context_validator.dart'; - -final RegExp _alphaDash = RegExp(r'^[A-Za-z0-9_-]+$'); -final RegExp _alphaNum = RegExp(r'^[A-Za-z0-9]+$'); -final RegExp _email = RegExp( - r"^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$"); -final RegExp _url = RegExp( - r'https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)'); - -/// Asserts that a `String` is alphanumeric, but also lets it contain dashes or underscores. -final Matcher isAlphaDash = predicate( - (value) => value is String && _alphaDash.hasMatch(value), - 'alphanumeric (dashes and underscores are allowed)'); - -/// Asserts that a `String` is alphanumeric, but also lets it contain dashes or underscores. -/// -final Matcher isAlphaNum = predicate( - (value) => value is String && _alphaNum.hasMatch(value), 'alphanumeric'); - -/// Asserts that a value either equals `true` or `false`. -final Matcher isBool = predicate((value) => value is bool, 'a bool'); - -/// Asserts that a `String` complies to the RFC 5322 e-mail standard. -final Matcher isEmail = predicate( - (value) => value is String && _email.hasMatch(value), - 'a valid e-mail address'); - -/// Asserts that a value is an `int`. -final Matcher isInt = predicate((value) => value is int, 'an integer'); - -/// Asserts that a value is a `num`. -final Matcher isNum = predicate((value) => value is num, 'a number'); - -/// Asserts that a value is a `String`. -final Matcher isString = predicate((value) => value is String, 'a string'); - -/// Asserts that a value is a non-empty `String`. -final Matcher isNonEmptyString = predicate( - (value) => value is String && value.trim().isNotEmpty, - 'a non-empty string'); - -/// Asserts that a value, presumably from a checkbox, is positive. -final Matcher isChecked = - isIn(const ['yes', 'checked', 'on', '1', 1, 1.0, true, 'true']); - -/// Ensures that a string is an ISO-8601 date string. -final Matcher isIso8601DateString = predicate( - (x) { - try { - return x is String && DateTime.parse(x) != null; - } catch (_) { - return false; - } - }, - 'a valid ISO-8601 date string.', -); - -/// Asserts that a `String` is an `http://` or `https://` URL. -/// -/// The regular expression used: -/// ``` -/// https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*) -/// ``` -final Matcher isUrl = predicate( - (value) => value is String && _url.hasMatch(value), - 'a valid url, starting with http:// or https://'); - -/// Use [isUrl] instead. -@deprecated -final Matcher isurl = isUrl; - -/// Enforces a minimum length on a string. -Matcher minLength(int length) => predicate( - (value) => value is String && value.length >= length, - 'a string at least $length character(s) long'); - -/// Limits the maximum length of a string. -Matcher maxLength(int length) => predicate( - (value) => value is String && value.length <= length, - 'a string no longer than $length character(s) long'); - -/// Asserts that for a key `x`, the context contains an identical item `x_confirmed`. -ContextAwareMatcher isConfirmed = predicateWithContext( - (item, key, context, matchState) { - return equals(item).matches(context['${key}_confirmed'], matchState); - }, - 'is confirmed', -); - -/// Asserts that for a key `x`, the value of `x` is **not equal to** the value for [key]. -ContextAwareMatcher differentFrom(String key) { - return predicateWithContext( - (item, key, context, matchState) { - return !equals(item).matches(context[key], matchState); - }, - 'is different from the value of "$key"', - ); -} - -/// Asserts that for a key `x`, the value of `x` is **equal to** the value for [key]. -ContextAwareMatcher sameAs(String key) { - return predicateWithContext( - (item, key, context, matchState) { - return equals(item).matches(context[key], matchState); - }, - 'is equal to the value of "$key"', - ); -} - -/// Assert that a key `x` is present, if *all* of the given [keys] are as well. -ContextValidator requiredIf(Iterable keys) => - _require((ctx) => keys.every(ctx.containsKey)); - -/// Assert that a key `x` is present, if *any* of the given [keys] are as well. -ContextValidator requiredAny(Iterable keys) => - _require((ctx) => keys.any(ctx.containsKey)); - -/// Assert that a key `x` is present, if *at least one* of the given [keys] is not. -ContextValidator requiredWithout(Iterable keys) => - _require((ctx) => !keys.every(ctx.containsKey)); - -/// Assert that a key `x` is present, if *none* of the given [keys] are. -ContextValidator requiredWithoutAll(Iterable keys) => - _require((ctx) => !keys.any(ctx.containsKey)); - -ContextValidator _require(bool Function(Map) f) { - return ContextValidator( - (key, context) => f(context) && context.containsKey(key), - (desc, key, _) => StringDescription('Missing required field "$key".'), - ); -} diff --git a/lib/src/validator.dart b/lib/src/validator.dart deleted file mode 100644 index 5b524e6e..00000000 --- a/lib/src/validator.dart +++ /dev/null @@ -1,408 +0,0 @@ -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(); - -/// 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 autoParse(Map inputData, Iterable fields) { - Map 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 = []; - - _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) { - List errors = []; - var input = Map.from(inputData); - Map 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 (!_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 (Matcher 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}) { - Map _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. - final List errors = []; - - /// A descriptive message describing the error. - final String message; - - ValidationException(this.message, {Iterable errors = const []}) - : super(FormatException(message), - statusCode: 400, - errors: (errors ?? []).toSet().toList(), - stackTrace: StackTrace.current) { - if (errors != null) 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'] - ..addAll(errors.map((error) => '* $error')); - - return messages.join('\n'); - } -} diff --git a/pubspec.yaml b/pubspec.yaml index baa18662..8b477633 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,19 +1,19 @@ name: angel_validate -description: Cross-platform request body validation library based on `matcher`. -version: 2.0.2 -author: Tobe O -homepage: https://github.com/angel-dart/validate +version: 3.0.0-alpha +description: Strongly-typed form handlers and validators for Angel. +homepage: https://github/angel-dart/validate environment: - sdk: ">=2.0.0-dev <3.0.0" + sdk: ">=2.0.0 <3.0.0" dependencies: - angel_framework: ^2.0.0-alpha - angel_http_exception: ^1.0.0 - matcher: ^0.12.0 + angel_framework: ^2.0.0 + html_builder: ^1.0.0 + matcher: ^0.12.5 dev_dependencies: - angel_test: ^2.0.0-alpha - build_runner: ^0.10.0 - build_web_compilers: ^0.4.0 - logging: ^0.11.0 - mock_request: + angel_orm: ^2.1.0-beta + angel_orm_generator: ^2.1.0-beta + angel_serialize: ^2.0.0 + angel_serialize_generator: ^2.0.0 + build_runner: ^1.0.0 pedantic: ^1.0.0 + pretty_logging: ^1.0.0 test: ^1.0.0 \ No newline at end of file diff --git a/test/async_test.dart b/test/async_test.dart deleted file mode 100644 index 363737a7..00000000 --- a/test/async_test.dart +++ /dev/null @@ -1 +0,0 @@ -void main() {} diff --git a/test/basic_test.dart b/test/basic_test.dart deleted file mode 100644 index 567696b2..00000000 --- a/test/basic_test.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:angel_validate/angel_validate.dart'; -import 'package:test/test.dart'; - -final Validator emailSchema = - Validator({'to': isEmail}, customErrorMessages: {'to': 'Hello, world!'}); - -final Validator todoSchema = Validator({ - 'id': [isInt, isPositive], - 'text*': isString, - 'completed*': isBool, - 'foo,bar': [isTrue] -}, defaultValues: { - 'completed': false -}); - -main() { - test('custom error message', () { - var result = emailSchema.check({'to': 2}); - - expect(result.errors, isList); - expect(result.errors, hasLength(1)); - expect(result.errors.first, equals('Hello, world!')); - }); - - test('requireField', () => expect(requireField('foo'), 'foo*')); - - test('requireFields', - () => expect(requireFields(['foo', 'bar']), 'foo*, bar*')); - - test('todo', () { - expect(() { - todoSchema - .enforce({'id': 'fool', 'text': 'Hello, world!', 'completed': 4}); - // ignore: deprecated_member_use - }, throwsA(isInstanceOf())); - }); - - test('filter', () { - var inputData = {'foo': 'bar', 'a': 'b', '1': 2}; - var only = filter(inputData, ['foo']); - expect(only, equals({'foo': 'bar'})); - }); - - test('comma in schema', () { - expect(todoSchema.rules.keys, allOf(contains('foo'), contains('bar'))); - expect([todoSchema.rules['foo'].first, todoSchema.rules['bar'].first], - everyElement(predicate((x) => x == isTrue))); - }); -} diff --git a/test/context_aware_test.dart b/test/context_aware_test.dart deleted file mode 100644 index 363737a7..00000000 --- a/test/context_aware_test.dart +++ /dev/null @@ -1 +0,0 @@ -void main() {} diff --git a/test/server_test.dart b/test/server_test.dart deleted file mode 100644 index 3e84f4ce..00000000 --- a/test/server_test.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'package:angel_framework/angel_framework.dart'; -import 'package:angel_framework/http.dart'; -import 'package:angel_test/angel_test.dart'; -import 'package:angel_validate/server.dart'; -import 'package:logging/logging.dart'; -import 'package:mock_request/mock_request.dart'; -import 'package:test/test.dart'; - -final Validator echoSchema = Validator({'message*': isString}); - -void printRecord(LogRecord rec) { - print(rec); - if (rec.error != null) print(rec.error); - if (rec.stackTrace != null) print(rec.stackTrace); -} - -main() { - Angel app; - AngelHttp http; - TestClient client; - - setUp(() async { - app = Angel(); - http = AngelHttp(app, useZone: false); - - app.chain([validate(echoSchema)]).post('/echo', - (RequestContext req, res) async { - await req.parseBody(); - res.write('Hello, ${req.bodyAsMap['message']}!'); - }); - - app.logger = Logger('angel')..onRecord.listen(printRecord); - client = await connectTo(app); - }); - - tearDown(() async { - await client.close(); - await http.close(); - app = null; - client = null; - }); - - group('echo', () { - test('validate', () async { - var response = await client.post('/echo', - body: {'message': 'world'}, headers: {'accept': '*/*'}); - print('Response: ${response.body}'); - expect(response, hasStatus(200)); - expect(response.body, equals('Hello, world!')); - }); - - test('enforce', () async { - var rq = MockHttpRequest('POST', Uri(path: '/echo')) - ..headers.add('accept', '*/*') - ..headers.add('content-type', 'application/json') - ..write(json.encode({'foo': 'bar'})); - - scheduleMicrotask(() async { - await rq.close(); - await http.handleRequest(rq); - }); - - var responseBody = await rq.response.transform(utf8.decoder).join(); - print('Response: ${responseBody}'); - expect(rq.response.statusCode, 400); - }); - }); -} diff --git a/validate.iml b/validate.iml deleted file mode 100644 index 0854fb6e..00000000 --- a/validate.iml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/web/index.html b/web/index.html deleted file mode 100644 index f3621c7a..00000000 --- a/web/index.html +++ /dev/null @@ -1,37 +0,0 @@ - - - - - angel_validate - - - - -

Passport Registration

-Validation Example -
    -
    - - -

    - -

    - -

    - -

    - -
    - - - - \ No newline at end of file diff --git a/web/main.dart b/web/main.dart deleted file mode 100644 index e2c90e5a..00000000 --- a/web/main.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'dart:html'; -import 'package:angel_validate/angel_validate.dart'; - -final $errors = querySelector('#errors') as UListElement; -final $form = querySelector('#form') as FormElement; -final $blank = querySelector('[name="blank"]') as InputElement; - -final Validator formSchema = Validator({ - 'firstName*': [isString, isNotEmpty], - 'lastName*': [isString, isNotEmpty], - 'age*': [isInt, greaterThanOrEqualTo(18)], - 'familySize': [isInt, greaterThanOrEqualTo(1)], - 'blank!': [] -}, defaultValues: { - 'familySize': 1 -}, customErrorMessages: { - 'age': (age) { - if (age is int && age < 18) { - return 'Only adults can register for passports. Sorry, kid!'; - } else if (age == null || (age is String && age.trim().isEmpty)) { - return 'Age is required.'; - } else { - return 'Age must be a positive integer. Unless you are a monster...'; - } - }, - 'blank': - "I told you to leave that field blank, but instead you typed '{{value}}'..." -}); - -main() { - $form.onSubmit.listen((e) { - e.preventDefault(); - $errors.children.clear(); - - var formData = {}; - - ['firstName', 'lastName', 'age', 'familySize'].forEach((key) { - formData[key] = (querySelector('[name="$key"]') as InputElement).value; - }); - - if ($blank.value.isNotEmpty) formData['blank'] = $blank.value; - - print('Form data: $formData'); - - try { - var passportInfo = - formSchema.enforceParsed(formData, ['age', 'familySize']); - - $errors.children - ..add(success('Successfully registered for a passport.')) - ..add(success('First Name: ${passportInfo["firstName"]}')) - ..add(success('Last Name: ${passportInfo["lastName"]}')) - ..add(success('Age: ${passportInfo["age"]} years old')) - ..add(success( - 'Number of People in Family: ${passportInfo["familySize"]}')); - } on ValidationException catch (e) { - $errors.children.addAll(e.errors.map((error) { - return LIElement()..text = error; - })); - } - }); -} - -LIElement success(String str) => LIElement() - ..classes.add('success') - ..text = str;