This commit is contained in:
Tobe O 2018-06-28 12:34:05 -04:00
parent 0ca81479ff
commit eb38637a6b
12 changed files with 312 additions and 62 deletions

View file

@ -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.

View file

@ -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';

View file

@ -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<String> 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<ValidationResult> asyncApplyValidator(
Validator validator, Map data, Angel app) async {
var result = validator.check(data);
if (result.errors.isNotEmpty) return result;
var errantKeys = <String>[], errors = <String>[];
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<String, dynamic>.from(result.data);
for (var key in errantKeys) {
m.remove(key);
}
return result.withData(m).withErrors(errors);
}

View file

@ -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<bool> Function(Object, Angel) f,
AngelMatcher predicateWithAngel(
FutureOr<bool> Function(String, Object, Angel) f,
[String description = 'satisfies function']) =>
new _PredicateWithAngel(f, description);
@ -19,26 +17,38 @@ AngelMatcher predicateWithAngel(FutureOr<bool> Function(Object, Angel) f,
/// to the input.
///
/// Use this to match values against configuration, injections, etc.
AngelMatcher matchWithAngel(FutureOr<Matcher> Function(Object, Angel) f,
AngelMatcher matchWithAngel(FutureOr<Matcher> Function(Object, Map, Angel) f,
[String description = 'satisfies asynchronously created matcher']) =>
new _MatchWithAngel(f, description);
/// Calls [matchWithAngel] without the initial parameter.
AngelMatcher matchWithAngelBinary(
FutureOr<Matcher> 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<Matcher> 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<Matcher> 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<Matcher> Function(Object) matcher, FutureOr Function() feature,
AngelMatcher matchAsync(FutureOr<Matcher> 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<bool> matchesAsync(item, Map matchState, Angel app);
abstract class AngelMatcher extends ContextAwareMatcher {
Future<bool> 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<bool> matchesWithAngel(
item, String key, Map context, Map matchState, Angel app) async {
return matcher.matchesWithContext(item, key, context, matchState);
}
}
class _MatchWithAngel extends AngelMatcher {
final FutureOr<Matcher> Function(Object, Angel) f;
final FutureOr<Matcher> 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<bool> matchesAsync(item, Map matchState, Angel app) {
return new Future.sync(() => f(item, app)).then((result) {
Future<bool> 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<bool> Function(Object, Angel) predicate;
final FutureOr<bool> 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<bool> matchesAsync(item, Map matchState, Angel app) {
return new Future<bool>.sync(() => predicate(item, app));
Future<bool> matchesWithAngel(
item, String key, Map context, Map matchState, Angel app) {
return new Future<bool>.sync(() => predicate(key, item, app));
}
}
class _MatchAsync extends AngelMatcher {
final FutureOr<Matcher> Function(Object) matcher;
final FutureOr<Matcher> 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<bool> matchesAsync(item, Map matchState, Angel app) async {
Future<bool> 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);
}
}

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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<String> 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<String> 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<String> keys) =>
_require((ctx) => !keys.every(ctx.containsKey));
/// Assert that a key `x` is present, if *none* of the given [keys] are.
ContextValidator requiredWithoutAll(Iterable<String> 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".'),
);
}

View file

@ -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<String> 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<String, dynamic> _data;
final Map<String, dynamic> _data = {};
final List<String> _errors = [];
/// The successfully validated data, filtered from the original input.
Map<String, dynamic> get data => _data;
Map<String, dynamic> get data => new Map<String, dynamic>.unmodifiable(_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);
ValidationResult withData(Map<String, dynamic> data) => new ValidationResult()
.._data.addAll(data)
.._errors.addAll(_errors);
ValidationResult withErrors(Iterable<String> errors) =>
new ValidationResult().._data.addAll(_data).._errors.addAll(errors);
}
/// Occurs when user-provided data is invalid.

View file

@ -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 <thosakwe@gmail.com>
homepage: https://github.com/angel-dart/validate
environment:

0
test/async_test.dart Normal file
View file

View file

@ -31,6 +31,7 @@ main() {
expect(() {
todoSchema
.enforce({'id': 'fool', 'text': 'Hello, world!', 'completed': 4});
// ignore: deprecated_member_use
}, throwsA(new isInstanceOf<ValidationException>()));
});

View file