From eb38637a6be1b5e4a084625a111c14c3e2296f63 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Thu, 28 Jun 2018 12:34:05 -0400 Subject: [PATCH] beta --- CHANGELOG.md | 4 +- lib/angel_validate.dart | 1 + lib/server.dart | 45 ++++++++++++++-- lib/src/async.dart | 79 +++++++++++++++++++--------- lib/src/context_aware.dart | 53 +++++++++++++++++++ lib/src/context_validator.dart | 16 ++++++ lib/src/matchers.dart | 77 +++++++++++++++++++++++++-- lib/src/validator.dart | 96 ++++++++++++++++++++++++---------- pubspec.yaml | 2 +- test/async_test.dart | 0 test/basic_test.dart | 1 + test/context_aware_test.dart | 0 12 files changed, 312 insertions(+), 62 deletions(-) create mode 100644 lib/src/context_aware.dart create mode 100644 lib/src/context_validator.dart create mode 100644 test/async_test.dart create mode 100644 test/context_aware_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 109c3eeb..612218af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ -# 1.0.5 +# 1.0.5-beta * Use `wrapMatcher` on explicit values instead of throwing. +* Add async matchers. +* Add context-aware matchers. # 1.0.4 * `isNonEmptyString` trims strings. diff --git a/lib/angel_validate.dart b/lib/angel_validate.dart index bcb55eea..0fa2c34d 100644 --- a/lib/angel_validate.dart +++ b/lib/angel_validate.dart @@ -2,6 +2,7 @@ library angel_validate; export 'package:matcher/matcher.dart'; +export 'src/context_aware.dart'; export 'src/matchers.dart'; export 'src/validator.dart'; diff --git a/lib/server.dart b/lib/server.dart index b4e5d18d..ed135ed8 100644 --- a/lib/server.dart +++ b/lib/server.dart @@ -1,6 +1,8 @@ /// 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'; @@ -50,7 +52,8 @@ RequestMiddleware filterQuery(Iterable only) { RequestMiddleware validate(Validator validator, {String errorMessage: 'Invalid data.'}) { return (RequestContext req, res) async { - var result = validator.check(await req.lazyBody()); + var result = + await asyncApplyValidator(validator, await req.lazyBody(), req.app); if (result.errors.isNotEmpty) { throw new AngelHttpException.badRequest( @@ -70,7 +73,7 @@ RequestMiddleware validate(Validator validator, RequestMiddleware validateQuery(Validator validator, {String errorMessage: 'Invalid data.'}) { return (RequestContext req, res) async { - var result = validator.check(req.query); + var result = await asyncApplyValidator(validator, req.query, req.app); if (result.errors.isNotEmpty) { throw new AngelHttpException.badRequest( @@ -89,8 +92,9 @@ RequestMiddleware validateQuery(Validator validator, /// filtered data before continuing the service event. HookedServiceEventListener validateEvent(Validator validator, {String errorMessage: 'Invalid data.'}) { - return (HookedServiceEvent e) { - var result = validator.check(e.data as Map); + return (HookedServiceEvent e) async { + var result = await asyncApplyValidator( + validator, e.data as Map, (e.request?.app ?? e.service.app) as Angel); if (result.errors.isNotEmpty) { throw new AngelHttpException.badRequest( @@ -102,3 +106,36 @@ HookedServiceEventListener validateEvent(Validator validator, ..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 = new 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 = new 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 index 538dd22e..9a7381e6 100644 --- a/lib/src/async.dart +++ b/lib/src/async.dart @@ -2,16 +2,14 @@ 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. -/// -/// For example: -/// -/// expect(v, predicate((x) => ((x % 2) == 0), "is even")) -AngelMatcher predicateWithAngel(FutureOr Function(Object, Angel) f, +AngelMatcher predicateWithAngel( + FutureOr Function(String, Object, Angel) f, [String description = 'satisfies function']) => new _PredicateWithAngel(f, description); @@ -19,26 +17,38 @@ AngelMatcher predicateWithAngel(FutureOr Function(Object, Angel) f, /// to the input. /// /// Use this to match values against configuration, injections, etc. -AngelMatcher matchWithAngel(FutureOr Function(Object, Angel) f, +AngelMatcher matchWithAngel(FutureOr Function(Object, Map, Angel) f, [String description = 'satisfies asynchronously created matcher']) => new _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']) => - matchWithAngel((_, app) => f(app)); + 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; - return matchWithAngel((_, app) => wrapMatcher(x)); + if (x is ContextAwareMatcher) return new _WrappedAngelMatcher(x); + return wrapAngelMatcher(wrapContextAwareMatcher(x)); } /// Returns an [AngelMatcher] that asynchronously resolves a [feature], builds a [matcher], and executes it. -AngelMatcher matchAsync( - FutureOr Function(Object) matcher, FutureOr Function() feature, +AngelMatcher matchAsync(FutureOr Function(String, Object) matcher, + FutureOr Function() feature, [String description = 'satisfies asynchronously created matcher']) { return new _MatchAsync(matcher, feature, description); } @@ -48,7 +58,7 @@ AngelMatcher matchAsync( AngelMatcher idExistsInService(String servicePath, {String idField: 'id', String description}) { return predicateWithAngel( - (item, app) async { + (key, item, app) async { try { var result = await app.service(servicePath)?.read(item); return result != null; @@ -65,17 +75,34 @@ AngelMatcher idExistsInService(String servicePath, } /// An asynchronous [Matcher] that runs in the context of an [Angel] app. -abstract class AngelMatcher extends Matcher { - Future matchesAsync(item, Map matchState, Angel app); +abstract class AngelMatcher extends ContextAwareMatcher { + Future matchesWithAngel( + item, String key, Map context, Map matchState, Angel app); @override - bool matches(item, Map matchState) { + 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, Angel) f; + final FutureOr Function(Object, Map, Angel) f; final String description; _MatchWithAngel(this.f, this.description); @@ -86,15 +113,16 @@ class _MatchWithAngel extends AngelMatcher { : description.add(this.description); @override - Future matchesAsync(item, Map matchState, Angel app) { - return new Future.sync(() => f(item, app)).then((result) { + Future matchesWithAngel( + item, String key, Map context, Map matchState, Angel app) { + return new Future.sync(() => f(item, context, app)).then((result) { return result.matches(item, matchState); }); } } class _PredicateWithAngel extends AngelMatcher { - final FutureOr Function(Object, Angel) predicate; + final FutureOr Function(String, Object, Angel) predicate; final String description; _PredicateWithAngel(this.predicate, this.description); @@ -105,13 +133,14 @@ class _PredicateWithAngel extends AngelMatcher { : description.add(this.description); @override - Future matchesAsync(item, Map matchState, Angel app) { - return new Future.sync(() => predicate(item, app)); + Future matchesWithAngel( + item, String key, Map context, Map matchState, Angel app) { + return new Future.sync(() => predicate(key, item, app)); } } class _MatchAsync extends AngelMatcher { - final FutureOr Function(Object) matcher; + final FutureOr Function(String, Object) matcher; final FutureOr Function() feature; final String description; @@ -123,9 +152,11 @@ class _MatchAsync extends AngelMatcher { : description.add(this.description); @override - Future matchesAsync(item, Map matchState, Angel app) async { + Future matchesWithAngel( + item, String key, Map context, Map matchState, Angel app) async { var f = await feature(); - var m = await matcher(f); - return m.matches(item, matchState); + 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 new file mode 100644 index 00000000..9970d773 --- /dev/null +++ b/lib/src/context_aware.dart @@ -0,0 +1,53 @@ +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 new _PredicateWithContext(f, description); +} + +/// Wraps [x] in a [ContextAwareMatcher]. +ContextAwareMatcher wrapContextAwareMatcher(x) { + if (x is ContextAwareMatcher) + return x; + else if (x is Matcher) return new _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 new file mode 100644 index 00000000..19f22914 --- /dev/null +++ b/lib/src/context_validator.dart @@ -0,0 +1,16 @@ +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; + +} \ No newline at end of file diff --git a/lib/src/matchers.dart b/lib/src/matchers.dart index 53fff6e2..2ba96302 100644 --- a/lib/src/matchers.dart +++ b/lib/src/matchers.dart @@ -1,4 +1,6 @@ import 'package:matcher/matcher.dart'; +import 'context_aware.dart'; +import 'context_validator.dart'; final RegExp _alphaDash = new RegExp(r'^[A-Za-z0-9_-]+$'); final RegExp _alphaNum = new RegExp(r'^[A-Za-z0-9]+$'); @@ -13,6 +15,7 @@ final Matcher isAlphaDash = predicate( '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'); @@ -21,7 +24,8 @@ 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'); + (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'); @@ -30,12 +34,28 @@ final Matcher isInt = predicate((value) => value is int, 'an integer'); 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'); +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'); + '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. /// @@ -60,3 +80,54 @@ Matcher minLength(int length) => predicate( 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 new ContextValidator( + (key, context) => f(context) && context.containsKey(key), + (desc, key, _) => new StringDescription('Missing required field "$key".'), + ); +} diff --git a/lib/src/validator.dart b/lib/src/validator.dart index fea5eac4..46a533c2 100644 --- a/lib/src/validator.dart +++ b/lib/src/validator.dart @@ -1,5 +1,7 @@ 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 = new RegExp(r'\*$'); final RegExp _forbidden = new RegExp(r'!$'); @@ -113,6 +115,9 @@ class Validator extends Matcher { _importSchema(schema); } + static bool _hasContextValidators(Iterable it) => + it.any((x) => x is ContextValidator); + /// Validates, and filters input data. ValidationResult check(Map inputData) { List errors = []; @@ -136,42 +141,68 @@ class Validator extends Matcher { } for (String field in requiredFields) { - if (!input.containsKey(field)) { - if (!customErrorMessages.containsKey(field)) - errors.add("'$field' is required."); - else - errors.add(customError(field, 'none')); + 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 = new StringDescription("'$key': expected "); - 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 { - if (!matcher.matches(value, {})) { - if (!customErrorMessages.containsKey(key)) - errors.add(matcher.describe(description).toString().trim()); - valid = false; - break; - } + 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; } - } catch (e) { - errors.add(e.toString()); - valid = false; - break; } } @@ -187,7 +218,7 @@ class Validator extends Matcher { return new ValidationResult().._errors.addAll(errors); } - return new ValidationResult().._data = data; + return new ValidationResult().._data.addAll(data); } /// Validates, and filters input data after running [autoParse]. @@ -320,16 +351,23 @@ class Validator extends Matcher { /// The result of attempting to validate input data. class ValidationResult { - Map _data; + final Map _data = {}; final List _errors = []; /// The successfully validated data, filtered from the original input. - Map get data => _data; + Map get data => new 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 => new List.unmodifiable(_errors); + + ValidationResult withData(Map data) => new ValidationResult() + .._data.addAll(data) + .._errors.addAll(_errors); + + ValidationResult withErrors(Iterable errors) => + new ValidationResult().._data.addAll(_data).._errors.addAll(errors); } /// Occurs when user-provided data is invalid. diff --git a/pubspec.yaml b/pubspec.yaml index d65fd2cf..2bf57780 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: angel_validate description: Cross-platform validation library based on `matcher`. -version: 1.0.4 +version: 1.0.5-beta author: Tobe O homepage: https://github.com/angel-dart/validate environment: diff --git a/test/async_test.dart b/test/async_test.dart new file mode 100644 index 00000000..e69de29b diff --git a/test/basic_test.dart b/test/basic_test.dart index 3bc2b6ed..47f28d3f 100644 --- a/test/basic_test.dart +++ b/test/basic_test.dart @@ -31,6 +31,7 @@ main() { expect(() { todoSchema .enforce({'id': 'fool', 'text': 'Hello, world!', 'completed': 4}); + // ignore: deprecated_member_use }, throwsA(new isInstanceOf())); }); diff --git a/test/context_aware_test.dart b/test/context_aware_test.dart new file mode 100644 index 00000000..e69de29b