It begins.
This commit is contained in:
parent
b5a5d54157
commit
f5666e8664
8 changed files with 311 additions and 16 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -25,3 +25,5 @@ doc/api/
|
|||
# Don't commit pubspec lock file
|
||||
# (Library packages only! Remove pattern if developing an application package)
|
||||
pubspec.lock
|
||||
|
||||
log.txt
|
26
README.md
26
README.md
|
@ -2,6 +2,8 @@
|
|||
[![version 0.0.0](https://img.shields.io/badge/pub-v0.0.0-red.svg)](https://pub.dartlang.org/packages/angel_validate)
|
||||
[![build status](https://travis-ci.org/angel-dart/validate.svg)](https://travis-ci.org/angel-dart/validate)
|
||||
|
||||
(Not yet production ready, still missing several tests and a few matchers)
|
||||
|
||||
Validation library based on the `matcher` library, with Angel support.
|
||||
Why re-invent the wheel, when you can use the same validators you already
|
||||
use for tests?
|
||||
|
@ -9,6 +11,19 @@ use for tests?
|
|||
This library runs both on the server, and on the client. Thus, you can use
|
||||
the same validation rules for forms on the server, and on the frontend.
|
||||
|
||||
For convenience's sake, this library also exports `matcher`.
|
||||
|
||||
* [Examples](#examples)
|
||||
* [Creating a Validator](#creating-a-validator)
|
||||
* [Validating Data](#validating-data)
|
||||
* [Required Fields](#required-fields)
|
||||
* [Default Values](#default-values)
|
||||
* [Custom Validator Functions](#custom-validator-functions)
|
||||
* [Extending Validators](#extending-validators)
|
||||
* [Bundled Matchers](#bundled-matchers)
|
||||
* [Nested Validators](#nested-validators)
|
||||
* [Use with Angel](#use-with-angel)
|
||||
|
||||
# Examples
|
||||
|
||||
## Creating a Validator
|
||||
|
@ -160,9 +175,7 @@ including:
|
|||
* `isBool`: Asserts that a value either equals `true` or `false`.
|
||||
* `isEmail`: Asserts a `String` complies to the RFC 5322 e-mail standard.
|
||||
* `isInt`: Asserts a value is an `int`.
|
||||
* `isNegative`: Asserts a `num` is less than `0`.
|
||||
* `isNum`: Asserts a value is a `num`.
|
||||
* `isPositive`: Asserts a `num` is greater than `0`.
|
||||
* `isString`: Asserts that a value is a `String`.
|
||||
|
||||
The remaining functionality is
|
||||
|
@ -170,11 +183,8 @@ The remaining functionality is
|
|||
|
||||
# Nested Validators
|
||||
Very often, the data we validate contains other data within. You can pass
|
||||
a `Validator` instance to the constructor, and it will be wrapped within
|
||||
a `Matcher` instance.
|
||||
|
||||
The class also exposes a `toMatcher()` method that creates a Matcher that
|
||||
validates data using the instance.
|
||||
a `Validator` instance to the constructor, because it extends the
|
||||
`Matcher` class.
|
||||
|
||||
```dart
|
||||
main() {
|
||||
|
@ -198,7 +208,7 @@ main() {
|
|||
'bio*': bio,
|
||||
'books*': [
|
||||
isList,
|
||||
everyElement(book.toMatcher())
|
||||
everyElement(book)
|
||||
]
|
||||
}, defaultValues: {
|
||||
'books': []
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
/// Cross-platform validation library based on `matcher`.
|
||||
library angel_validate;
|
||||
|
||||
export 'package:matcher/matcher.dart';
|
||||
export 'src/matchers.dart';
|
||||
export 'src/validator.dart';
|
13
lib/src/matchers.dart
Normal file
13
lib/src/matchers.dart
Normal file
|
@ -0,0 +1,13 @@
|
|||
import 'package:matcher/matcher.dart';
|
||||
|
||||
/// Asserts that a value either equals `true` or `false`.
|
||||
final Matcher isBool = predicate((value) => value is String, 'a bool ');
|
||||
|
||||
/// Asserts that a value is an `int`.
|
||||
final Matcher isInt = predicate((value) => value is String, 'an integer ');
|
||||
|
||||
/// Asserts that a value is a `num`.
|
||||
final Matcher isNumber = predicate((value) => value is String, 'a number ');
|
||||
|
||||
/// Asserts that a value is a `String`.
|
||||
final Matcher isString = predicate((value) => value is String, 'a String ');
|
|
@ -1,24 +1,224 @@
|
|||
import 'package:matcher/matcher.dart';
|
||||
|
||||
final RegExp _asterisk = new RegExp(r'\*$');
|
||||
final RegExp _optional = new RegExp(r'\?$');
|
||||
|
||||
/// Returns a value based the result of a computation.
|
||||
typedef DefaultValueFunction();
|
||||
|
||||
/// Determines if a value is valid.
|
||||
typedef bool Filter(value);
|
||||
|
||||
/// Enforces the validity of input data, according to [Matcher]s.
|
||||
class Validator {
|
||||
class Validator extends Matcher {
|
||||
/// Values that will be filled for fields if they are not present.
|
||||
final Map<String, dynamic> defaultValues = {};
|
||||
|
||||
/// 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 key in schema.keys) {
|
||||
var fieldName = key.replaceAll(_asterisk, '');
|
||||
var isRequired = _asterisk.hasMatch(key);
|
||||
|
||||
if (isRequired) {
|
||||
requiredFields.add(fieldName);
|
||||
}
|
||||
|
||||
Iterable iterable = schema[key] is Iterable ? schema[key] : [schema[key]];
|
||||
|
||||
for (var rule in iterable) {
|
||||
if (rule is Matcher) {
|
||||
addRule(fieldName, rule);
|
||||
} else if (rule is Filter) {
|
||||
addRule(fieldName, predicate(rule));
|
||||
} else {
|
||||
throw new ArgumentError(
|
||||
'Cannot use a(n) ${rule.runtimeType} as a validation rule.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Validator.empty();
|
||||
|
||||
Validator(Map<String, dynamic> schema,
|
||||
{Map<String, dynamic> defaultValues: const {}}) {
|
||||
this.defaultValues.addAll(defaultValues ?? {});
|
||||
_importSchema(schema);
|
||||
}
|
||||
|
||||
/// Validates, and filters input data.
|
||||
ValidationResult check(Map inputData) {}
|
||||
ValidationResult check(Map inputData) {
|
||||
List<String> errors = [];
|
||||
var input = new Map.from(inputData);
|
||||
Map<String, dynamic> data = {};
|
||||
|
||||
for (String key in defaultValues.keys) {
|
||||
if (!input.containsKey(key)) {
|
||||
var value = defaultValues[key];
|
||||
input[key] = value is DefaultValueFunction ? value() : value;
|
||||
}
|
||||
}
|
||||
|
||||
for (String field in requiredFields) {
|
||||
if (!input.containsKey(field)) {
|
||||
errors.add("'$field' is required.");
|
||||
}
|
||||
}
|
||||
|
||||
for (var key in input.keys) {
|
||||
if (key is String && rules.containsKey(key)) {
|
||||
var valid = true;
|
||||
var value = input[key];
|
||||
var description = new StringDescription("Field '$key': expected ");
|
||||
|
||||
for (Matcher matcher in rules[key]) {
|
||||
try {
|
||||
if (matcher is Validator) {
|
||||
var result = matcher.check(value);
|
||||
|
||||
if (result.errors.isNotEmpty) {
|
||||
errors.addAll(result.errors);
|
||||
valid = false;
|
||||
}
|
||||
} else {
|
||||
if (!matcher.matches(value, {})) {
|
||||
errors.add(matcher.describe(description).toString().trim());
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
errors.add(e.toString());
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (valid) {
|
||||
data[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.isNotEmpty) {
|
||||
return new ValidationResult().._errors.addAll(errors);
|
||||
}
|
||||
|
||||
return new ValidationResult().._data = data;
|
||||
}
|
||||
|
||||
/// Validates input data, and throws an error if it is invalid.
|
||||
///
|
||||
///
|
||||
/// Otherwise, the filtered data is returned.
|
||||
Map enforce(Map inputData, {String errorMessage: 'Invalid data.'}) {}
|
||||
Map<String, dynamic> enforce(Map inputData,
|
||||
{String errorMessage: 'Invalid data.'}) {
|
||||
var result = check(inputData);
|
||||
|
||||
if (result._errors.isNotEmpty) {
|
||||
throw new ValidationException(errorMessage, errors: result._errors);
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/// Creates a copy with additional validation rules.
|
||||
Validator extend(Map<String, dynamic> schema,
|
||||
{Map<String, dynamic> defaultValues: const {}, bool overwrite: false}) {
|
||||
Map<String, dynamic> _schema = {};
|
||||
var child = new Validator.empty()
|
||||
..defaultValues.addAll(this.defaultValues)
|
||||
..defaultValues.addAll(defaultValues ?? {})
|
||||
..requiredFields.addAll(requiredFields)
|
||||
..rules.addAll(rules);
|
||||
|
||||
if (overwrite) {
|
||||
for (var key in schema.keys) {
|
||||
var fieldName = key.replaceAll(_asterisk, '').replaceAll(_optional, '');
|
||||
var isOptional = _optional.hasMatch(key);
|
||||
var isRequired = _asterisk.hasMatch(key);
|
||||
|
||||
if (isOptional)
|
||||
child.requiredFields.remove(fieldName);
|
||||
else if (isRequired) child.requiredFields.add(fieldName);
|
||||
|
||||
if (child.rules.containsKey(key)) child.rules.remove(key);
|
||||
|
||||
_schema[fieldName] = schema[key];
|
||||
}
|
||||
|
||||
return child;
|
||||
} else {
|
||||
for (var key in schema.keys) {
|
||||
var fieldName = key.replaceAll(_asterisk, '').replaceAll(_optional, '');
|
||||
var isOptional = _optional.hasMatch(key);
|
||||
var isRequired = _asterisk.hasMatch(key);
|
||||
|
||||
if (isOptional)
|
||||
child.requiredFields.remove(fieldName);
|
||||
else if (isRequired) child.requiredFields.add(fieldName);
|
||||
|
||||
_schema[fieldName] = schema[key];
|
||||
}
|
||||
}
|
||||
|
||||
return child.._importSchema(_schema);
|
||||
}
|
||||
|
||||
/// Adds a [rule].
|
||||
void addRule(String key, Matcher rule) {
|
||||
if (!rules.containsKey(key)) {
|
||||
rules[key] = [rule];
|
||||
return;
|
||||
}
|
||||
|
||||
rules[key].add(rule);
|
||||
}
|
||||
|
||||
/// Adds all given [rules].
|
||||
void addRules(String key, Iterable<Matcher> rules) {
|
||||
rules.forEach((rule) => addRule(key, rule));
|
||||
}
|
||||
|
||||
/// Removes a [rule].
|
||||
void removeRule(String key, Matcher rule) {
|
||||
if (rules.containsKey(key)) {
|
||||
rules[key].remove(rule);
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes all given [rules].
|
||||
void removeRules(String key, Iterable<Matcher> rules) {
|
||||
rules.forEach((rule) => removeRule(key, rule));
|
||||
}
|
||||
|
||||
@override
|
||||
Description describe(Description description) =>
|
||||
description.add(' passes the provided validation schema: $rules');
|
||||
|
||||
@override
|
||||
bool matches(item, Map matchState) {
|
||||
enforce(item);
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'Validation schema: $rules';
|
||||
}
|
||||
|
||||
/// The result of attempting to validate input data.
|
||||
class ValidationResult {
|
||||
Map _data;
|
||||
Map<String, dynamic> _data;
|
||||
final List<String> _errors = [];
|
||||
|
||||
/// The successfully validated data, filtered from the original input.
|
||||
Map get data => _data;
|
||||
Map<String, dynamic> get data => _data;
|
||||
|
||||
/// A list of errors that resulted in the given data being marked invalid.
|
||||
///
|
||||
///
|
||||
/// This is empty if validation was successful.
|
||||
List<String> get errors => new List<String>.unmodifiable(_errors);
|
||||
}
|
||||
|
@ -36,7 +236,7 @@ class ValidationException {
|
|||
}
|
||||
|
||||
@override
|
||||
String get toString {
|
||||
String toString() {
|
||||
if (errors.isEmpty) {
|
||||
return message;
|
||||
}
|
||||
|
|
|
@ -9,4 +9,6 @@ dependencies:
|
|||
angel_framework: ^1.0.0-dev
|
||||
matcher: ^0.12.0
|
||||
dev_dependencies:
|
||||
angel_diagnostics: ^1.0.0-dev
|
||||
angel_test: ^1.0.0-dev
|
||||
test: ^0.12.18
|
19
test/basic_test.dart
Normal file
19
test/basic_test.dart
Normal file
|
@ -0,0 +1,19 @@
|
|||
import 'package:angel_validate/angel_validate.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
final Validator todoSchema = new Validator({
|
||||
'id': [isInt, isPositive],
|
||||
'text*': isString,
|
||||
'completed*': isBool
|
||||
}, defaultValues: {
|
||||
'completed': false
|
||||
});
|
||||
|
||||
main() {
|
||||
test('todo', () {
|
||||
expect(() {
|
||||
todoSchema
|
||||
.enforce({'id': 'fool', 'text': 'Hello, world!', 'completed': 4});
|
||||
}, throwsA(new isInstanceOf<ValidationException>()));
|
||||
});
|
||||
}
|
47
test/server_test.dart
Normal file
47
test/server_test.dart
Normal file
|
@ -0,0 +1,47 @@
|
|||
import 'dart:io';
|
||||
import 'package:angel_diagnostics/angel_diagnostics.dart';
|
||||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:angel_test/angel_test.dart';
|
||||
import 'package:angel_validate/server.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
final Validator echoSchema = new Validator({'message*': isString});
|
||||
|
||||
main() {
|
||||
Angel app;
|
||||
TestClient client;
|
||||
|
||||
setUp(() async {
|
||||
app = new Angel();
|
||||
|
||||
app.chain(validate(echoSchema)).post('/echo',
|
||||
(RequestContext req, res) async {
|
||||
res.write('Hello, ${req.body['message']}!');
|
||||
});
|
||||
|
||||
client = await connectTo(new DiagnosticsServer(app, new File('log.txt')));
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await client.close();
|
||||
app = null;
|
||||
client = null;
|
||||
});
|
||||
|
||||
group('echo', () {
|
||||
test('validate', () async {
|
||||
var response = await client.post('/echo',
|
||||
body: {'message': 'world'}, headers: {HttpHeaders.ACCEPT: '*/*'});
|
||||
print('Response: ${response.body}');
|
||||
expect(response, hasStatus(HttpStatus.OK));
|
||||
expect(response.body, equals('Hello, world!'));
|
||||
});
|
||||
|
||||
test('enforce', () async {
|
||||
var response = await client.post('/echo',
|
||||
body: {'foo': 'bar'}, headers: {HttpHeaders.ACCEPT: '*/*'});
|
||||
print('Response: ${response.body}');
|
||||
expect(response, hasStatus(HttpStatus.BAD_REQUEST));
|
||||
});
|
||||
});
|
||||
}
|
Loading…
Reference in a new issue