From f5666e866424a3abf536ec0538409e82cb5557e8 Mon Sep 17 00:00:00 2001 From: thosakwe Date: Sun, 25 Dec 2016 20:09:24 -0500 Subject: [PATCH] It begins. --- .gitignore | 2 + README.md | 26 +++-- lib/angel_validate.dart | 2 + lib/src/matchers.dart | 13 +++ lib/src/validator.dart | 216 ++++++++++++++++++++++++++++++++++++++-- pubspec.yaml | 2 + test/basic_test.dart | 19 ++++ test/server_test.dart | 47 +++++++++ 8 files changed, 311 insertions(+), 16 deletions(-) create mode 100644 lib/src/matchers.dart create mode 100644 test/basic_test.dart create mode 100644 test/server_test.dart diff --git a/.gitignore b/.gitignore index 7c280441..2aa48381 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/README.md b/README.md index f8112fd3..0bb56c5f 100644 --- a/README.md +++ b/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': [] diff --git a/lib/angel_validate.dart b/lib/angel_validate.dart index 28b741ba..a9b29e88 100644 --- a/lib/angel_validate.dart +++ b/lib/angel_validate.dart @@ -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'; \ No newline at end of file diff --git a/lib/src/matchers.dart b/lib/src/matchers.dart new file mode 100644 index 00000000..8e1bfebe --- /dev/null +++ b/lib/src/matchers.dart @@ -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 '); diff --git a/lib/src/validator.dart b/lib/src/validator.dart index 137b6ccd..47b2c299 100644 --- a/lib/src/validator.dart +++ b/lib/src/validator.dart @@ -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 defaultValues = {}; + + /// Conditions that must be met for input data to be considered valid. + final Map> rules = {}; + + /// Fields that must be present for data to be considered valid. + final List requiredFields = []; + + void _importSchema(Map schema) { + for (var 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 schema, + {Map defaultValues: const {}}) { + this.defaultValues.addAll(defaultValues ?? {}); + _importSchema(schema); + } + /// Validates, and filters input data. - ValidationResult check(Map inputData) {} + ValidationResult check(Map inputData) { + List errors = []; + var input = new Map.from(inputData); + Map data = {}; + + for (String key in defaultValues.keys) { + if (!input.containsKey(key)) { + var value = defaultValues[key]; + input[key] = value is DefaultValueFunction ? value() : value; + } + } + + for (String field in 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 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 schema, + {Map defaultValues: const {}, bool overwrite: false}) { + Map _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 rules) { + rules.forEach((rule) => addRule(key, rule)); + } + + /// Removes a [rule]. + void removeRule(String key, Matcher rule) { + if (rules.containsKey(key)) { + rules[key].remove(rule); + } + } + + /// Removes all given [rules]. + void removeRules(String key, Iterable rules) { + rules.forEach((rule) => removeRule(key, rule)); + } + + @override + Description describe(Description description) => + description.add(' passes the provided validation schema: $rules'); + + @override + bool matches(item, Map matchState) { + enforce(item); + return true; + } + + @override + String toString() => 'Validation schema: $rules'; } /// The result of attempting to validate input data. class ValidationResult { - Map _data; + Map _data; final List _errors = []; /// The successfully validated data, filtered from the original input. - Map get data => _data; + Map get data => _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); } @@ -36,7 +236,7 @@ class ValidationException { } @override - String get toString { + String toString() { if (errors.isEmpty) { return message; } diff --git a/pubspec.yaml b/pubspec.yaml index 05701825..46c7603f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 \ No newline at end of file diff --git a/test/basic_test.dart b/test/basic_test.dart new file mode 100644 index 00000000..e3384d19 --- /dev/null +++ b/test/basic_test.dart @@ -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())); + }); +} diff --git a/test/server_test.dart b/test/server_test.dart new file mode 100644 index 00000000..f056d3f6 --- /dev/null +++ b/test/server_test.dart @@ -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)); + }); + }); +}