Add 'packages/validate/' from commit 'db616d128ad76796f43e66b8214aa54dd20e51d2'
git-subtree-dir: packages/validate git-subtree-mainline:5eef4314ec
git-subtree-split:db616d128a
This commit is contained in:
commit
71aa3464b9
16 changed files with 1000 additions and 0 deletions
32
packages/validate/.gitignore
vendored
Normal file
32
packages/validate/.gitignore
vendored
Normal file
|
@ -0,0 +1,32 @@
|
|||
# See https://www.dartlang.org/tools/private-files.html
|
||||
|
||||
# Files and directories created by pub
|
||||
.buildlog
|
||||
.packages
|
||||
.project
|
||||
.pub/
|
||||
build/
|
||||
**/packages/
|
||||
|
||||
# Files created by dart2js
|
||||
# (Most Dart developers will use pub build to compile Dart, use/modify these
|
||||
# rules if you intend to use dart2js directly
|
||||
# Convention is to use extension '.dart.js' for Dart compiled to Javascript to
|
||||
# differentiate from explicit Javascript files)
|
||||
*.dart.js
|
||||
*.part.js
|
||||
*.js.deps
|
||||
*.js.map
|
||||
*.info.json
|
||||
|
||||
# Directory created by dartdoc
|
||||
doc/api/
|
||||
|
||||
# Don't commit pubspec lock file
|
||||
# (Library packages only! Remove pattern if developing an application package)
|
||||
pubspec.lock
|
||||
|
||||
log.txt
|
||||
|
||||
.idea
|
||||
.dart_tool
|
1
packages/validate/.travis.yml
Normal file
1
packages/validate/.travis.yml
Normal file
|
@ -0,0 +1 @@
|
|||
language: dart
|
27
packages/validate/CHANGELOG.md
Normal file
27
packages/validate/CHANGELOG.md
Normal file
|
@ -0,0 +1,27 @@
|
|||
# 3.0.0-alpha
|
||||
* Rewrite, based on `Field` and `Form`, rather than strings.
|
||||
|
||||
# 2.0.2
|
||||
* Deduplicate error messages.
|
||||
|
||||
# 2.0.1+1
|
||||
* Fix bug in the implementation of `maxLength`.
|
||||
|
||||
# 2.0.1
|
||||
* Patch for updated body parsing.
|
||||
|
||||
# 2.0.0
|
||||
* Finish update for Angel 2.
|
||||
|
||||
# 2.0.0-alpha.1
|
||||
* Update for Angel 2.
|
||||
|
||||
# 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.
|
||||
* `ValidationException` extends `AngelHttpException`.
|
||||
* Added `requireField` and `requireFields`.
|
21
packages/validate/LICENSE
Normal file
21
packages/validate/LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2016 The Angel Framework
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
82
packages/validate/README.md
Normal file
82
packages/validate/README.md
Normal file
|
@ -0,0 +1,82 @@
|
|||
# validate
|
||||
[![Pub](https://img.shields.io/pub/v/angel_validate.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)
|
||||
|
||||
Strongly-typed form handlers and validators for Angel.
|
||||
|
||||
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?
|
||||
|
||||
For convenience's sake, this library also exports `matcher`.
|
||||
|
||||
# Field
|
||||
The basic unit is the `Field` class, which is a type-safe way to read
|
||||
values from a `RequestContext`. Here is a simple example of using a
|
||||
`TextField` instance to read a value from the URL query parameters:
|
||||
|
||||
```dart
|
||||
app.get('/hello', (req, res) async {
|
||||
var nameField = TextField('name');
|
||||
var name = await nameField.getValue(req, query: true); // String
|
||||
return 'Hello, $name!';
|
||||
});
|
||||
```
|
||||
|
||||
There are several included field types:
|
||||
* `TextField`
|
||||
* `BoolField`
|
||||
* `NumField`
|
||||
* `DoubleField`
|
||||
* `IntField`
|
||||
* `DateTimeField`
|
||||
* `FileField`
|
||||
* `ImageField`
|
||||
|
||||
# Forms
|
||||
The `Form` class lets you combine `Field` instances, and decode
|
||||
request bodies into `Map<String, dynamic>`. Unrecognized fields are
|
||||
stripped out of the body, so a `Form` is effectively a whitelist.
|
||||
|
||||
```dart
|
||||
var todoForm = Form(fields: [
|
||||
TextField('text'),
|
||||
BoolField('is_complete'),
|
||||
]);
|
||||
|
||||
// Validate a request body, and deserialize it immediately.
|
||||
var todo = await todoForm.deserialize(req, TodoSerializer.fromMap);
|
||||
|
||||
// Same as above, but with a Codec<Todo, Map> (i.e. via `angel_serialize`).
|
||||
var todo = await todoForm.decode(req, todoSerializer);
|
||||
|
||||
// Lower-level functionality, typically not called directly.
|
||||
// Use it if you want to handle validation errors directly, without
|
||||
// throwing exceptions.
|
||||
|
||||
@serializable
|
||||
class _Todo {
|
||||
String text;
|
||||
bool isComplete;
|
||||
}
|
||||
```
|
||||
|
||||
## Form Rendering
|
||||
TODO: Docs about this
|
||||
|
||||
# Bundled Matchers
|
||||
This library includes some `Matcher`s for common validations,
|
||||
including:
|
||||
|
||||
* `isAlphaDash`: Asserts that a `String` is alphanumeric, but also lets it contain dashes or underscores.
|
||||
* `isAlphaNum`: Asserts that a `String` is alphanumeric.
|
||||
* `isBool`: Asserts that a value either equals `true` or `false`.
|
||||
* `isEmail`: Asserts that a `String` complies to the RFC 5322 e-mail standard.
|
||||
* `isInt`: Asserts that a value is an `int`.
|
||||
* `isNum`: Asserts that a value is a `num`.
|
||||
* `isString`: Asserts that a value is a `String`.
|
||||
* `isNonEmptyString`: Asserts that a value is a non-empty `String`.
|
||||
* `isUrl`: Asserts that a `String` is an HTTPS or HTTP URL.
|
||||
|
||||
The remaining functionality is
|
||||
[effectively implemented by the `matcher` package](https://www.dartdocs.org/documentation/matcher/latest/matcher/matcher-library.html).
|
4
packages/validate/analysis_options.yaml
Normal file
4
packages/validate/analysis_options.yaml
Normal file
|
@ -0,0 +1,4 @@
|
|||
include: package:pedantic/analysis_options.yaml
|
||||
analyzer:
|
||||
strong-mode:
|
||||
implicit-casts: false
|
96
packages/validate/example/main.dart
Normal file
96
packages/validate/example/main.dart
Normal file
|
@ -0,0 +1,96 @@
|
|||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:angel_framework/http.dart';
|
||||
import 'package:angel_validate/angel_validate.dart';
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:pretty_logging/pretty_logging.dart';
|
||||
|
||||
main() async {
|
||||
Logger.root
|
||||
..level = Level.ALL
|
||||
..onRecord.listen(prettyLog);
|
||||
|
||||
var app = Angel(logger: Logger('angel_validate'));
|
||||
var http = AngelHttp(app);
|
||||
var todos = <Todo>[];
|
||||
|
||||
/// We can combine fields into a form; this is most
|
||||
/// useful when we immediately deserialize the form into
|
||||
/// something else.
|
||||
var todoForm = Form(fields: [
|
||||
TextField('text'),
|
||||
BoolField('is_complete'),
|
||||
]);
|
||||
|
||||
/// We can directly use a `Form` to deserialize a
|
||||
/// request body into a `Map<String, dynamic>`.
|
||||
///
|
||||
/// By calling `deserialize` or `decode`, we can populate
|
||||
/// concrete Dart objects.
|
||||
app.post('/', (req, res) async {
|
||||
var todo = await todoForm.deserialize(req, Todo.fromMap);
|
||||
todos.add(todo);
|
||||
await res.redirect('/');
|
||||
});
|
||||
|
||||
/// You can also use `Field`s to read directly from the
|
||||
/// request, without `as` casts.
|
||||
///
|
||||
/// In this handler, we read the value of `name` from the query.
|
||||
app.get('/hello', (req, res) async {
|
||||
var nameField = TextField('name');
|
||||
var name = await nameField.getValue(req, query: true);
|
||||
return 'Hello, $name!';
|
||||
});
|
||||
|
||||
/// Simple page displaying a form and some state.
|
||||
app.get('/', (req, res) {
|
||||
res
|
||||
..contentType = MediaType('text', 'html')
|
||||
..write('''
|
||||
<!doctype html>
|
||||
<html>
|
||||
<body>
|
||||
<h1>angel_validate</h1>
|
||||
<ul>
|
||||
${todos.map((t) {
|
||||
return '<li>${t.text} (isComplete=${t.isComplete})</li>';
|
||||
}).join()}
|
||||
</ul>
|
||||
<form method="POST">
|
||||
<label for="text">Text:</label>
|
||||
<input id="text" name="text">
|
||||
<br>
|
||||
<label for="is_complete">Complete?</label>
|
||||
<input id="is_complete" name="is_complete" type="checkbox">
|
||||
<br>
|
||||
<button type="submit">Add Todo</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
''');
|
||||
});
|
||||
|
||||
app.fallback((req, res) => throw AngelHttpException.notFound());
|
||||
|
||||
app.errorHandler = (e, req, res) {
|
||||
res.writeln('Error ${e.statusCode}: ${e.message}');
|
||||
for (var error in e.errors) {
|
||||
res.writeln('* $error');
|
||||
}
|
||||
};
|
||||
|
||||
await http.startServer('127.0.0.1', 3000);
|
||||
print('Listening at ${http.uri}');
|
||||
}
|
||||
|
||||
class Todo {
|
||||
final String text;
|
||||
final bool isComplete;
|
||||
|
||||
Todo(this.text, this.isComplete);
|
||||
|
||||
static Todo fromMap(Map map) {
|
||||
return Todo(map['text'] as String, map['is_complete'] as bool);
|
||||
}
|
||||
}
|
2
packages/validate/lib/angel_validate.dart
Normal file
2
packages/validate/lib/angel_validate.dart
Normal file
|
@ -0,0 +1,2 @@
|
|||
export 'package:matcher/matcher.dart';
|
||||
export 'without_matcher.dart';
|
352
packages/validate/lib/src/common_fields.dart
Normal file
352
packages/validate/lib/src/common_fields.dart
Normal file
|
@ -0,0 +1,352 @@
|
|||
import 'dart:async';
|
||||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:http_parser/http_parser.dart';
|
||||
import 'package:image/image.dart';
|
||||
import 'field.dart';
|
||||
import 'form_renderer.dart';
|
||||
|
||||
/// A [Field] that accepts plain text.
|
||||
class TextField extends Field<String> {
|
||||
/// If `true` (default), then renderers will produce a `<textarea>` element.
|
||||
final bool isTextArea;
|
||||
|
||||
/// If `true` (default), then the input will be trimmed before validation.
|
||||
final bool trim;
|
||||
|
||||
/// If not `null`, then if the value of [confirmedAs] in the body is not
|
||||
/// identical, an error will be returned.
|
||||
final String confirmedAs;
|
||||
|
||||
TextField(String name,
|
||||
{String label,
|
||||
bool isRequired = true,
|
||||
this.isTextArea = false,
|
||||
this.trim = true,
|
||||
this.confirmedAs,
|
||||
String type = 'text'})
|
||||
: super(name, type, label: label, isRequired: isRequired);
|
||||
|
||||
@override
|
||||
FutureOr<U> accept<U>(FormRenderer<U> renderer) =>
|
||||
renderer.visitTextField(this);
|
||||
|
||||
String _normalize(String s) {
|
||||
if (trim) {
|
||||
s = s?.trim();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOr<FieldReadResult<String>> read(RequestContext req,
|
||||
Map<String, dynamic> fields, Iterable<UploadedFile> files) {
|
||||
var value = _normalize(fields[name] as String);
|
||||
if (value == null) {
|
||||
return null;
|
||||
} else if (trim && value.isEmpty) {
|
||||
return null;
|
||||
} else {
|
||||
if (confirmedAs != null) {
|
||||
var confirmed = _normalize(fields[confirmedAs] as String);
|
||||
if (confirmed != value) {
|
||||
return FieldReadResult.failure(
|
||||
['"$name" and "$confirmedAs" must be identical.']);
|
||||
}
|
||||
}
|
||||
|
||||
return FieldReadResult.success(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A [Field] that checks simply for its presence in the given data.
|
||||
/// Typically used for checkboxes.
|
||||
class BoolField extends Field<bool> {
|
||||
BoolField(String name,
|
||||
{String label, bool isRequired = true, String type = 'checkbox'})
|
||||
: super(name, type, label: label, isRequired: isRequired);
|
||||
|
||||
@override
|
||||
FutureOr<U> accept<U>(FormRenderer<U> renderer) =>
|
||||
renderer.visitBoolField(this);
|
||||
|
||||
@override
|
||||
FutureOr<FieldReadResult<bool>> read(RequestContext req,
|
||||
Map<String, dynamic> fields, Iterable<UploadedFile> files) {
|
||||
if (fields.containsKey(name)) {
|
||||
return FieldReadResult.success(true);
|
||||
} else {
|
||||
return FieldReadResult.success(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A [Field] that parses its value as a [num].
|
||||
class NumField<T extends num> extends Field<T> {
|
||||
// Reuse text validation logic.
|
||||
TextField _textField;
|
||||
|
||||
/// The minimum/maximum value for the field.
|
||||
final T min, max;
|
||||
|
||||
/// The amount for a form field to increment by.
|
||||
final num step;
|
||||
|
||||
NumField(String name,
|
||||
{String label,
|
||||
String type = 'number',
|
||||
bool isRequired = true,
|
||||
this.max,
|
||||
this.min,
|
||||
this.step})
|
||||
: super(name, type, label: label, isRequired: isRequired) {
|
||||
_textField = TextField(name, label: label, isRequired: isRequired);
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOr<U> accept<U>(FormRenderer<U> renderer) =>
|
||||
renderer.visitNumField(this);
|
||||
|
||||
@override
|
||||
Future<FieldReadResult<T>> read(RequestContext req,
|
||||
Map<String, dynamic> fields, Iterable<UploadedFile> files) async {
|
||||
var result = await _textField.read(req, fields, files);
|
||||
if (result == null) {
|
||||
return null;
|
||||
} else if (result.isSuccess != true) {
|
||||
return FieldReadResult.failure(result.errors);
|
||||
} else {
|
||||
var value = num.tryParse(result.value);
|
||||
if (value != null) {
|
||||
if (min != null && value < min) {
|
||||
return FieldReadResult.failure(['"$name" can be no less than $min.']);
|
||||
} else if (max != null && value > max) {
|
||||
return FieldReadResult.failure(
|
||||
['"$name" can be no greater than $max.']);
|
||||
} else {
|
||||
return FieldReadResult.success(value as T);
|
||||
}
|
||||
} else {
|
||||
return FieldReadResult.failure(['"$name" must be a number.']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A [NumField] that coerces its value to a [double].
|
||||
class DoubleField extends NumField<double> {
|
||||
DoubleField(String name,
|
||||
{String label,
|
||||
String type = 'number',
|
||||
bool isRequired = true,
|
||||
num step,
|
||||
double min,
|
||||
double max})
|
||||
: super(name,
|
||||
type: type,
|
||||
label: label,
|
||||
isRequired: isRequired,
|
||||
step: step,
|
||||
min: min,
|
||||
max: max);
|
||||
|
||||
@override
|
||||
Future<FieldReadResult<double>> read(RequestContext req,
|
||||
Map<String, dynamic> fields, Iterable<UploadedFile> files) async {
|
||||
var result = await super.read(req, fields, files);
|
||||
if (result == null) {
|
||||
return null;
|
||||
} else if (!result.isSuccess) {
|
||||
return FieldReadResult.failure(result.errors);
|
||||
} else {
|
||||
return FieldReadResult.success(result.value.toDouble());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A [NumField] that requires its value to be an [int].
|
||||
/// Passing a [double] will result in an error, so [step] defaults to 1.
|
||||
class IntField extends NumField<int> {
|
||||
IntField(String name,
|
||||
{String label,
|
||||
String type = 'number',
|
||||
bool isRequired = true,
|
||||
num step = 1,
|
||||
int min,
|
||||
int max})
|
||||
: super(name,
|
||||
label: label,
|
||||
type: type,
|
||||
isRequired: isRequired,
|
||||
step: step,
|
||||
min: min,
|
||||
max: max);
|
||||
|
||||
@override
|
||||
Future<FieldReadResult<int>> read(RequestContext req,
|
||||
Map<String, dynamic> fields, Iterable<UploadedFile> files) async {
|
||||
var result = await super.read(req, fields, files);
|
||||
if (result == null) {
|
||||
return null;
|
||||
} else if (!result.isSuccess) {
|
||||
return FieldReadResult.failure(result.errors);
|
||||
} else {
|
||||
var value = result.value;
|
||||
if (value is int) {
|
||||
return FieldReadResult.success(result.value);
|
||||
} else {
|
||||
return FieldReadResult.failure(['"$name" must be an integer.']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A [Field] that parses its value as an ISO6801 [DateTime].
|
||||
class DateTimeField extends Field<DateTime> {
|
||||
// Reuse text validation logic.
|
||||
TextField _textField;
|
||||
|
||||
/// The minimum/maximum value for the field.
|
||||
final DateTime min, max;
|
||||
|
||||
/// The amount for a form field to increment by.
|
||||
final num step;
|
||||
|
||||
DateTimeField(String name,
|
||||
{String label,
|
||||
bool isRequired = true,
|
||||
this.max,
|
||||
this.min,
|
||||
this.step,
|
||||
String type = 'datetime-local'})
|
||||
: super(name, type, label: label, isRequired: isRequired) {
|
||||
_textField = TextField(name, label: label, isRequired: isRequired);
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOr<U> accept<U>(FormRenderer<U> renderer) =>
|
||||
renderer.visitDateTimeField(this);
|
||||
|
||||
@override
|
||||
Future<FieldReadResult<DateTime>> read(RequestContext req,
|
||||
Map<String, dynamic> fields, Iterable<UploadedFile> files) async {
|
||||
var result = await _textField.read(req, fields, files);
|
||||
if (result == null) {
|
||||
return null;
|
||||
} else if (result.isSuccess != true) {
|
||||
return FieldReadResult.failure(result.errors);
|
||||
} else {
|
||||
var value = DateTime.tryParse(result.value);
|
||||
if (value != null) {
|
||||
return FieldReadResult.success(value);
|
||||
} else {
|
||||
return FieldReadResult.failure(
|
||||
['"$name" must be a properly-formatted date.']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A [Field] that validates an [UploadedFile].
|
||||
class FileField extends Field<UploadedFile> {
|
||||
/// If `true` (default), then the file must have a `content-type`.
|
||||
final bool requireContentType;
|
||||
|
||||
/// If `true` (default: `false`), then the file must have an associated
|
||||
/// filename.
|
||||
final bool requireFilename;
|
||||
|
||||
/// If provided, then the `content-type` must be present in this [Iterable].
|
||||
final Iterable<MediaType> allowedContentTypes;
|
||||
|
||||
FileField(String name,
|
||||
{String label,
|
||||
bool isRequired = true,
|
||||
this.requireContentType = true,
|
||||
this.requireFilename = false,
|
||||
this.allowedContentTypes})
|
||||
: super(name, 'file', label: label, isRequired: isRequired) {
|
||||
assert(allowedContentTypes == null || allowedContentTypes.isNotEmpty);
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOr<U> accept<U>(FormRenderer<U> renderer) =>
|
||||
renderer.visitFileField(this);
|
||||
|
||||
@override
|
||||
FutureOr<FieldReadResult<UploadedFile>> read(RequestContext req,
|
||||
Map<String, dynamic> fields, Iterable<UploadedFile> files) {
|
||||
var file = files.firstWhere((f) => f.name == name, orElse: () => null);
|
||||
if (file == null) {
|
||||
return null;
|
||||
} else if ((requireContentType || allowedContentTypes != null) &&
|
||||
file.contentType == null) {
|
||||
return FieldReadResult.failure(
|
||||
['A content type must be given for file "$name".']);
|
||||
} else if (requireFilename && file.filename == null) {
|
||||
return FieldReadResult.failure(
|
||||
['A filename must be given for file "$name".']);
|
||||
} else if (allowedContentTypes != null &&
|
||||
!allowedContentTypes.contains(file.contentType)) {
|
||||
return FieldReadResult.failure([
|
||||
'File "$name" cannot have content type '
|
||||
'"${file.contentType}". Allowed types: '
|
||||
'${allowedContentTypes.join(', ')}'
|
||||
]);
|
||||
} else {
|
||||
return FieldReadResult.success(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A wrapper around [FileField] that reads its input into an [Image].
|
||||
///
|
||||
/// **CAUTION**: The uploaded file will be read in memory.
|
||||
class ImageField extends Field<Image> {
|
||||
FileField _fileField;
|
||||
|
||||
/// The underlying [FileField].
|
||||
FileField get fileField => _fileField;
|
||||
|
||||
ImageField(String name,
|
||||
{String label,
|
||||
bool isRequired = true,
|
||||
bool requireContentType = true,
|
||||
bool requireFilename = false,
|
||||
Iterable<MediaType> allowedContentTypes})
|
||||
: super(name, 'file', label: label, isRequired: isRequired) {
|
||||
_fileField = FileField(name,
|
||||
label: label,
|
||||
isRequired: isRequired,
|
||||
requireContentType: requireContentType,
|
||||
requireFilename: requireFilename,
|
||||
allowedContentTypes: allowedContentTypes);
|
||||
}
|
||||
|
||||
@override
|
||||
FutureOr<U> accept<U>(FormRenderer<U> renderer) =>
|
||||
renderer.visitImageField(this);
|
||||
|
||||
@override
|
||||
FutureOr<FieldReadResult<Image>> read(RequestContext req,
|
||||
Map<String, dynamic> fields, Iterable<UploadedFile> files) async {
|
||||
var result = await fileField.read(req, fields, files);
|
||||
if (result == null) {
|
||||
return null;
|
||||
} else if (!result.isSuccess) {
|
||||
return FieldReadResult.failure(result.errors);
|
||||
} else {
|
||||
try {
|
||||
var image = decodeImage(await result.value.readAsBytes());
|
||||
if (image == null) {
|
||||
return FieldReadResult.failure(['"$name" must be an image file.']);
|
||||
} else {
|
||||
return FieldReadResult.success(image);
|
||||
}
|
||||
} on ImageException catch (e) {
|
||||
return FieldReadResult.failure(
|
||||
['Error in image file "$name": ${e.message}']);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
126
packages/validate/lib/src/field.dart
Normal file
126
packages/validate/lib/src/field.dart
Normal file
|
@ -0,0 +1,126 @@
|
|||
import 'dart:async';
|
||||
import 'package:angel_framework/angel_framework.dart';
|
||||
import 'package:matcher/matcher.dart';
|
||||
import 'form.dart';
|
||||
import 'form_renderer.dart';
|
||||
|
||||
/// Holds the result of validating a field.
|
||||
class FieldReadResult<T> {
|
||||
/// If `true`, then validation was successful.
|
||||
/// If `false`, [errors] must not be empty.
|
||||
final bool isSuccess;
|
||||
|
||||
/// The value provided by the user.
|
||||
final T value;
|
||||
|
||||
/// Any errors that arose during validation.
|
||||
final Iterable<String> errors;
|
||||
|
||||
FieldReadResult.success(this.value)
|
||||
: isSuccess = true,
|
||||
errors = [];
|
||||
|
||||
FieldReadResult.failure(this.errors)
|
||||
: isSuccess = false,
|
||||
value = null;
|
||||
}
|
||||
|
||||
/// An abstraction used to fetch values from request bodies, in a type-safe manner.
|
||||
abstract class Field<T> {
|
||||
/// The name of this field. This is the name that users should include in
|
||||
/// request bodies.
|
||||
final String name;
|
||||
|
||||
/// An optional label for the field.
|
||||
final String label;
|
||||
|
||||
/// Whether the field is required. If `true`, then if it is not
|
||||
/// present, an error will be generated.
|
||||
final bool isRequired;
|
||||
|
||||
/// The input `type` attribute, if applicable.
|
||||
final String type;
|
||||
|
||||
Field(this.name, this.type, {this.label, this.isRequired = true});
|
||||
|
||||
/// Reads the value from the request body.
|
||||
///
|
||||
/// If it returns `null` and [isRequired] is `true`, an error must
|
||||
/// be generated.
|
||||
FutureOr<FieldReadResult<T>> read(RequestContext req,
|
||||
Map<String, dynamic> fields, Iterable<UploadedFile> files);
|
||||
|
||||
/// Accepts a form renderer.
|
||||
FutureOr<U> accept<U>(FormRenderer<U> renderer);
|
||||
|
||||
/// Wraps this instance in one that throws an error if any of the
|
||||
/// [matchers] fails.
|
||||
Field<T> match(Iterable<Matcher> matchers) => _MatchedField(this, matchers);
|
||||
|
||||
/// Calls [read], and returns the retrieve value from the body.
|
||||
///
|
||||
/// If [query] is `true` (default: `false`), then the value will
|
||||
/// be read from the request `queryParameters` instead.
|
||||
///
|
||||
/// If there is an error, then an [AngelHttpException] is thrown.
|
||||
/// If a [defaultValue] is provided, it will be returned in case of an
|
||||
/// error or missing value.
|
||||
Future<T> getValue(RequestContext req,
|
||||
{String errorMessage, T defaultValue, bool query = false}) async {
|
||||
var uploadedFiles = <UploadedFile>[];
|
||||
if (req.hasParsedBody || !query) {
|
||||
await req.parseBody();
|
||||
uploadedFiles = req.uploadedFiles;
|
||||
}
|
||||
var result = await read(
|
||||
req, query ? req.queryParameters : req.bodyAsMap, uploadedFiles);
|
||||
if (result?.isSuccess != true && defaultValue != null) {
|
||||
return defaultValue;
|
||||
} else if (result == null) {
|
||||
errorMessage ??= Form.reportMissingField(name, query: query);
|
||||
throw AngelHttpException.badRequest(message: errorMessage);
|
||||
} else if (!result.isSuccess) {
|
||||
errorMessage ??= result.errors.first;
|
||||
throw AngelHttpException.badRequest(
|
||||
message: errorMessage, errors: result.errors.toList());
|
||||
} else {
|
||||
return result.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _MatchedField<T> extends Field<T> {
|
||||
final Field<T> inner;
|
||||
final Iterable<Matcher> matchers;
|
||||
|
||||
_MatchedField(this.inner, this.matchers)
|
||||
: super(inner.name, inner.type,
|
||||
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,
|
||||
Map<String, dynamic> fields, Iterable<UploadedFile> files) async {
|
||||
var result = await inner.read(req, fields, files);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
128
packages/validate/lib/src/form.dart
Normal file
128
packages/validate/lib/src/form.dart
Normal file
|
@ -0,0 +1,128 @@
|
|||
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_validate/angel_validate.dart';
|
||||
///
|
||||
/// var myForm = Form(fields: [
|
||||
/// TextField('username').match([minLength(8)]),
|
||||
/// TextField('password', confirmedAs: 'confirm_password'),
|
||||
/// ])
|
||||
///
|
||||
/// app.post('/login', (req, res) async {
|
||||
/// var loginBody =
|
||||
/// await myForm.decode(req, loginBodySerializer);
|
||||
/// // Do something with the decoded object...
|
||||
/// });
|
||||
/// ```
|
||||
class Form {
|
||||
/// A custom error message to provide the user if validation fails.
|
||||
final String errorMessage;
|
||||
|
||||
final List<Field> _fields = [];
|
||||
|
||||
static const String defaultErrorMessage =
|
||||
'There were errors in your submission. '
|
||||
'Please make sure all fields entered correctly, and submit it again.';
|
||||
|
||||
/// Computes an error message in the case of a missing required field.
|
||||
static String reportMissingField(String fieldName, {bool query = false}) {
|
||||
var type = query ? 'query parameter' : 'field';
|
||||
return 'The $type "$fieldName" is required.';
|
||||
}
|
||||
|
||||
Form({this.errorMessage = defaultErrorMessage, Iterable<Field> fields}) {
|
||||
fields?.forEach(addField);
|
||||
}
|
||||
|
||||
/// Returns the fields in this form.
|
||||
List<Field> get fields => _fields;
|
||||
|
||||
/// Helper for adding fields. Passing [matchers] will result in them
|
||||
/// being applied to the [field].
|
||||
Field<T> addField<T>(Field<T> field, {Iterable<Matcher> matchers}) {
|
||||
if (matchers != null) {
|
||||
field = field.match(matchers);
|
||||
}
|
||||
_fields.add(field);
|
||||
return field;
|
||||
}
|
||||
|
||||
/// Deserializes the result of calling [validate].
|
||||
///
|
||||
/// If [query] is `true` (default: `false`), then the value will
|
||||
/// be read from the request `queryParameters` instead.
|
||||
Future<T> deserialize<T>(
|
||||
RequestContext req, T Function(Map<String, dynamic>) f,
|
||||
{bool query = false}) {
|
||||
return validate(req, query: query).then(f);
|
||||
}
|
||||
|
||||
/// Uses the [codec] to [deserialize] the result of calling [validate].
|
||||
///
|
||||
/// If [query] is `true` (default: `false`), then the value will
|
||||
/// be read from the request `queryParameters` instead.
|
||||
Future<T> decode<T>(RequestContext req, Codec<T, Map> codec,
|
||||
{bool query = false}) {
|
||||
return deserialize(req, codec.decode, query: query);
|
||||
}
|
||||
|
||||
/// Calls [read], and returns the filtered request body.
|
||||
/// If there is even one error, then an [AngelHttpException] is thrown.
|
||||
///
|
||||
/// If [query] is `true` (default: `false`), then the value will
|
||||
/// be read from the request `queryParameters` instead.
|
||||
Future<Map<String, dynamic>> validate(RequestContext req,
|
||||
{bool query = false}) async {
|
||||
var result = await read(req, query: query);
|
||||
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.
|
||||
///
|
||||
/// If [query] is `true` (default: `false`), then the value will
|
||||
/// be read from the request `queryParameters` instead.
|
||||
Future<FieldReadResult<Map<String, dynamic>>> read(RequestContext req,
|
||||
{bool query = false}) async {
|
||||
var out = <String, dynamic>{};
|
||||
var errors = <String>[];
|
||||
var uploadedFiles = <UploadedFile>[];
|
||||
if (req.hasParsedBody || !query) {
|
||||
await req.parseBody();
|
||||
uploadedFiles = req.uploadedFiles;
|
||||
}
|
||||
|
||||
for (var field in fields) {
|
||||
var result = await field.read(
|
||||
req, query ? req.queryParameters : req.bodyAsMap, uploadedFiles);
|
||||
if (result == null && field.isRequired) {
|
||||
errors.add(reportMissingField(field.name, query: query));
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
}
|
21
packages/validate/lib/src/form_renderer.dart
Normal file
21
packages/validate/lib/src/form_renderer.dart
Normal file
|
@ -0,0 +1,21 @@
|
|||
import 'dart:async';
|
||||
import 'common_fields.dart';
|
||||
import 'field.dart';
|
||||
|
||||
abstract class FormRenderer<T> {
|
||||
const FormRenderer();
|
||||
|
||||
FutureOr<T> visit(Field<T> field) => field.accept(this);
|
||||
|
||||
FutureOr<T> visitBoolField(BoolField field);
|
||||
|
||||
FutureOr<T> visitDateTimeField(DateTimeField field);
|
||||
|
||||
FutureOr<T> visitFileField(FileField field);
|
||||
|
||||
FutureOr<T> visitImageField(ImageField field);
|
||||
|
||||
FutureOr<T> visitNumField(NumField field);
|
||||
|
||||
FutureOr<T> visitTextField(TextField field);
|
||||
}
|
76
packages/validate/lib/src/matchers.dart
Normal file
76
packages/validate/lib/src/matchers.dart
Normal file
|
@ -0,0 +1,76 @@
|
|||
import 'package:matcher/matcher.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://');
|
||||
|
||||
/// 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');
|
5
packages/validate/lib/without_matcher.dart
Normal file
5
packages/validate/lib/without_matcher.dart
Normal file
|
@ -0,0 +1,5 @@
|
|||
export 'src/common_fields.dart';
|
||||
export 'src/field.dart';
|
||||
export 'src/form.dart';
|
||||
export 'src/form_renderer.dart';
|
||||
export 'src/matchers.dart';
|
23
packages/validate/pubspec.yaml
Normal file
23
packages/validate/pubspec.yaml
Normal file
|
@ -0,0 +1,23 @@
|
|||
name: angel_validate
|
||||
version: 3.0.0-alpha
|
||||
description: Strongly-typed form handlers and validators for the Angel framework.
|
||||
author: Tobe O <thosakwe@gmail.com>
|
||||
homepage: https://github.com/angel-dart/validate
|
||||
environment:
|
||||
sdk: ">=2.0.0 <3.0.0"
|
||||
dependencies:
|
||||
angel_framework: ^2.0.0
|
||||
duration: ^2.0.0
|
||||
html_builder: ^1.0.0
|
||||
http_parser: ^3.0.0
|
||||
image: ^2.0.0
|
||||
matcher: ^0.12.5
|
||||
dev_dependencies:
|
||||
angel_orm: ^2.1.0-beta
|
||||
angel_orm_generator: ^2.1.0-beta
|
||||
angel_serialize: ^2.0.0
|
||||
angel_serialize_generator: ^2.0.0
|
||||
build_runner: ^1.0.0
|
||||
pedantic: ^1.0.0
|
||||
pretty_logging: ^1.0.0
|
||||
test: ^1.0.0
|
4
packages/validate/test/all_test.dart
Normal file
4
packages/validate/test/all_test.dart
Normal file
|
@ -0,0 +1,4 @@
|
|||
import 'package:angel_validate/angel_validate.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {}
|
Loading…
Reference in a new issue