Wipe out for v3
This commit is contained in:
parent
3b956230c6
commit
8f025080ac
20 changed files with 184 additions and 1207 deletions
|
@ -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': []
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
2
lib/angel_forms.dart
Normal file
2
lib/angel_forms.dart
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
export 'src/field.dart';
|
||||||
|
export 'src/form.dart';
|
|
@ -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<String> fields) =>
|
|
||||||
fields.map(requireField).join(', ');
|
|
144
lib/server.dart
144
lib/server.dart
|
@ -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<String> 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<String> 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<String> 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<String> 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<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 = 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<String, dynamic>.from(result.data);
|
|
||||||
for (var key in errantKeys) {
|
|
||||||
m.remove(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.withData(m).withErrors(errors);
|
|
||||||
}
|
|
|
@ -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<bool> 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<Matcher> Function(Object, Map, Angel) f,
|
|
||||||
[String description = 'satisfies asynchronously created matcher']) =>
|
|
||||||
_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']) =>
|
|
||||||
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;
|
|
||||||
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<Matcher> 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<bool> 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<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, 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<bool> 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<bool> 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<bool> matchesWithAngel(
|
|
||||||
item, String key, Map context, Map matchState, Angel app) {
|
|
||||||
return Future<bool>.sync(() => predicate(key, item, app));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _MatchAsync extends AngelMatcher {
|
|
||||||
final FutureOr<Matcher> 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<bool> 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);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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);
|
|
||||||
}
|
|
|
@ -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;
|
|
||||||
}
|
|
66
lib/src/field.dart
Normal file
66
lib/src/field.dart
Normal file
|
@ -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<T> {
|
||||||
|
final bool isSuccess;
|
||||||
|
final T value;
|
||||||
|
final Iterable<String> errors;
|
||||||
|
|
||||||
|
FieldReadResult.success(this.value)
|
||||||
|
: isSuccess = true,
|
||||||
|
errors = null;
|
||||||
|
|
||||||
|
FieldReadResult.failure(this.errors)
|
||||||
|
: isSuccess = false,
|
||||||
|
value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class Field<T> {
|
||||||
|
final String name;
|
||||||
|
final String label;
|
||||||
|
final bool isRequired;
|
||||||
|
|
||||||
|
Field(this.name, {this.label, this.isRequired = false});
|
||||||
|
|
||||||
|
FutureOr<FieldReadResult<T>> read(RequestContext req);
|
||||||
|
|
||||||
|
FutureOr<U> accept<U>(FormRenderer<U> renderer);
|
||||||
|
|
||||||
|
Field<T> match(Iterable<Matcher> matchers) => _MatchedField(this, matchers);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MatchedField<T> extends Field<T> {
|
||||||
|
final Field<T> inner;
|
||||||
|
final Iterable<Matcher> matchers;
|
||||||
|
|
||||||
|
_MatchedField(this.inner, this.matchers)
|
||||||
|
: super(inner.name, label: inner.label, isRequired: inner.isRequired) {
|
||||||
|
assert(matchers.isNotEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
FutureOr<U> accept<U>(FormRenderer<U> renderer) => inner.accept(renderer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<FieldReadResult<T>> read(RequestContext req) async {
|
||||||
|
var result = await inner.read(req);
|
||||||
|
if (!result.isSuccess) {
|
||||||
|
return result;
|
||||||
|
} else {
|
||||||
|
var errors = <String>[];
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
97
lib/src/form.dart
Normal file
97
lib/src/form.dart
Normal file
|
@ -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<Field> _fields = [];
|
||||||
|
final List<String> _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<Field> fields}) {
|
||||||
|
fields?.forEach(addField);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Field> get fields => _fields;
|
||||||
|
|
||||||
|
List<String> get errors => _errors;
|
||||||
|
|
||||||
|
Field<T> addField<T>(Field<T> field, {Iterable<Matcher> matchers}) {
|
||||||
|
if (matchers != null) {
|
||||||
|
field = field.match(matchers);
|
||||||
|
}
|
||||||
|
_fields.add(field);
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<T> deserialize<T>(
|
||||||
|
RequestContext req, T Function(Map<String, dynamic>) f) {
|
||||||
|
return validate(req).then(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<T> decode<T>(RequestContext req, Codec<T, Map> codec) {
|
||||||
|
return deserialize(req, codec.decode);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> 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<FieldReadResult<Map<String, dynamic>>> read(RequestContext req) async {
|
||||||
|
var out = <String, dynamic>{};
|
||||||
|
var errors = <String>[];
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
6
lib/src/form_renderer.dart
Normal file
6
lib/src/form_renderer.dart
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'field.dart';
|
||||||
|
|
||||||
|
abstract class FormRenderer<T> {
|
||||||
|
const FormRenderer();
|
||||||
|
}
|
|
@ -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<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 ContextValidator(
|
|
||||||
(key, context) => f(context) && context.containsKey(key),
|
|
||||||
(desc, key, _) => StringDescription('Missing required field "$key".'),
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -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<String, dynamic> autoParse(Map inputData, Iterable<String> fields) {
|
|
||||||
Map<String, dynamic> 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<String, dynamic> filter(Map inputData, Iterable<String> only) {
|
|
||||||
return inputData.keys.fold(<String, dynamic>{}, (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<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);
|
|
||||||
}
|
|
||||||
|
|
||||||
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<String, dynamic> schema,
|
|
||||||
{Map<String, dynamic> defaultValues = const {},
|
|
||||||
Map<String, dynamic> 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<String> errors = [];
|
|
||||||
var input = 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 (!_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<String> 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<String, dynamic> 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<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 = 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 as Map);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => 'Validation schema: $rules';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The result of attempting to validate input data.
|
|
||||||
class ValidationResult {
|
|
||||||
final Map<String, dynamic> _data = {};
|
|
||||||
final List<String> _errors = [];
|
|
||||||
|
|
||||||
/// The successfully validated data, filtered from the original input.
|
|
||||||
Map<String, dynamic> get data => 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 => List<String>.unmodifiable(_errors);
|
|
||||||
|
|
||||||
ValidationResult withData(Map<String, dynamic> data) =>
|
|
||||||
ValidationResult().._data.addAll(data).._errors.addAll(_errors);
|
|
||||||
|
|
||||||
ValidationResult withErrors(Iterable<String> 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<String> errors = [];
|
|
||||||
|
|
||||||
/// A descriptive message describing the error.
|
|
||||||
final String message;
|
|
||||||
|
|
||||||
ValidationException(this.message, {Iterable<String> errors = const []})
|
|
||||||
: super(FormatException(message),
|
|
||||||
statusCode: 400,
|
|
||||||
errors: (errors ?? <String>[]).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');
|
|
||||||
}
|
|
||||||
}
|
|
26
pubspec.yaml
26
pubspec.yaml
|
@ -1,19 +1,19 @@
|
||||||
name: angel_validate
|
name: angel_validate
|
||||||
description: Cross-platform request body validation library based on `matcher`.
|
version: 3.0.0-alpha
|
||||||
version: 2.0.2
|
description: Strongly-typed form handlers and validators for Angel.
|
||||||
author: Tobe O <thosakwe@gmail.com>
|
homepage: https://github/angel-dart/validate
|
||||||
homepage: https://github.com/angel-dart/validate
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=2.0.0-dev <3.0.0"
|
sdk: ">=2.0.0 <3.0.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
angel_framework: ^2.0.0-alpha
|
angel_framework: ^2.0.0
|
||||||
angel_http_exception: ^1.0.0
|
html_builder: ^1.0.0
|
||||||
matcher: ^0.12.0
|
matcher: ^0.12.5
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
angel_test: ^2.0.0-alpha
|
angel_orm: ^2.1.0-beta
|
||||||
build_runner: ^0.10.0
|
angel_orm_generator: ^2.1.0-beta
|
||||||
build_web_compilers: ^0.4.0
|
angel_serialize: ^2.0.0
|
||||||
logging: ^0.11.0
|
angel_serialize_generator: ^2.0.0
|
||||||
mock_request:
|
build_runner: ^1.0.0
|
||||||
pedantic: ^1.0.0
|
pedantic: ^1.0.0
|
||||||
|
pretty_logging: ^1.0.0
|
||||||
test: ^1.0.0
|
test: ^1.0.0
|
|
@ -1 +0,0 @@
|
||||||
void main() {}
|
|
|
@ -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<ValidationException>()));
|
|
||||||
});
|
|
||||||
|
|
||||||
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)));
|
|
||||||
});
|
|
||||||
}
|
|
|
@ -1 +0,0 @@
|
||||||
void main() {}
|
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
15
validate.iml
15
validate.iml
|
@ -1,15 +0,0 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<module type="WEB_MODULE" version="4">
|
|
||||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
|
||||||
<exclude-output />
|
|
||||||
<content url="file://$MODULE_DIR$">
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/.dart_tool" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/.pub" />
|
|
||||||
<excludeFolder url="file://$MODULE_DIR$/build" />
|
|
||||||
</content>
|
|
||||||
<orderEntry type="inheritedJdk" />
|
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
|
||||||
<orderEntry type="library" name="Dart SDK" level="project" />
|
|
||||||
<orderEntry type="library" name="Dart Packages" level="project" />
|
|
||||||
</component>
|
|
||||||
</module>
|
|
|
@ -1,37 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>angel_validate</title>
|
|
||||||
<meta name="viewport"
|
|
||||||
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
|
|
||||||
<style>
|
|
||||||
#errors li {
|
|
||||||
color: red;
|
|
||||||
}
|
|
||||||
|
|
||||||
#errors li.success {
|
|
||||||
color: green;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Passport Registration</h1>
|
|
||||||
<i>Validation Example</i>
|
|
||||||
<ul id="errors"></ul>
|
|
||||||
<form id="form">
|
|
||||||
<input placeholder="First Name*" name="firstName" type="text">
|
|
||||||
<input placeholder="Last Name*" name="lastName" type="text">
|
|
||||||
<br><br>
|
|
||||||
<input placeholder="Age*" name="age" type="number">
|
|
||||||
<br><br>
|
|
||||||
<input placeholder="Family Size" name="familySize" type="number">
|
|
||||||
<br><br>
|
|
||||||
<input placeholder="LEAVE THIS BLANK" name="blank">
|
|
||||||
<br><br>
|
|
||||||
<input type="submit" value="Submit">
|
|
||||||
</form>
|
|
||||||
<script src="main.dart" type="application/dart"></script>
|
|
||||||
<script src="packages/browser/dart.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
|
@ -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;
|
|
Loading…
Reference in a new issue