diff --git a/lib/Mojolicious.pm b/lib/Mojolicious.pm index 827eea6941..862ab58b74 100644 --- a/lib/Mojolicious.pm +++ b/lib/Mojolicious.pm @@ -13,6 +13,7 @@ use Mojolicious::Routes; use Mojolicious::Sessions; use Mojolicious::Static; use Mojolicious::Types; +use Mojolicious::Validator; use Scalar::Util qw(blessed weaken); use Time::HiRes 'gettimeofday'; @@ -36,9 +37,10 @@ has secret => sub { # Default to moniker return $self->moniker; }; -has sessions => sub { Mojolicious::Sessions->new }; -has static => sub { Mojolicious::Static->new }; -has types => sub { Mojolicious::Types->new }; +has sessions => sub { Mojolicious::Sessions->new }; +has static => sub { Mojolicious::Static->new }; +has types => sub { Mojolicious::Types->new }; +has validator => sub { Mojolicious::Validator->new }; our $CODENAME = 'Top Hat'; our $VERSION = '4.42'; @@ -364,6 +366,14 @@ L object. # Add custom MIME type $app->types->type(twt => 'text/tweet'); +=head2 validator + + my $validator = $app->validator; + $app = $app->validator(Mojolicious::Validator->new); + +Validate form data, defaults to a L object. Note that +this attribute is EXPERIMENTAL and might change without warning! + =head1 METHODS L inherits all methods from L and implements the following diff --git a/lib/Mojolicious/Plugin/DefaultHelpers.pm b/lib/Mojolicious/Plugin/DefaultHelpers.pm index 9f932a74b8..564a084baa 100644 --- a/lib/Mojolicious/Plugin/DefaultHelpers.pm +++ b/lib/Mojolicious/Plugin/DefaultHelpers.pm @@ -33,6 +33,7 @@ sub register { $app->helper(include => \&_include); $app->helper(ua => sub { shift->app->ua }); $app->helper(url_with => \&_url_with); + $app->helper(validation => \&_validation); } sub _content { @@ -88,6 +89,12 @@ sub _url_with { return $self->url_for(@_)->query($self->req->url->query->clone); } +sub _validation { + my $self = shift; + return $self->stash->{'mojo.validation'} + ||= $self->app->validator->validation->input($self->req->params->to_hash); +} + 1; =encoding utf8 @@ -251,6 +258,13 @@ request. %= url_with->query([page => 2]) +=head2 validation + + %= validation + +Get L object for current request. Note +that this helper is EXPERIMENTAL and might change without warning! + =head1 METHODS L inherits all methods from diff --git a/lib/Mojolicious/Validator.pm b/lib/Mojolicious/Validator.pm new file mode 100644 index 0000000000..e050fa9a2b --- /dev/null +++ b/lib/Mojolicious/Validator.pm @@ -0,0 +1,98 @@ +package Mojolicious::Validator; +use Mojo::Base -base; + +use Mojolicious::Validator::Validation; + +has checks => sub { {range => \&_range} }; +has errors => sub { + { + range => sub {qq{Value needs to be $_[3]-$_[4] characters long.}}, + required => sub {qq{Value is required.}} + }; +}; + +sub add_check { shift->_add(checks => @_) } +sub add_error { shift->_add(errors => @_) } + +sub validation { + Mojolicious::Validator::Validation->new(validator => shift); +} + +sub _add { + my ($self, $attr, $name, $cb) = @_; + $self->$attr->{$name} = $cb; + return $self; +} + +sub _range { + my ($validation, $name, $value, $min, $max) = @_; + my $len = length $value; + return $len >= $min && $len <= $max; +} + +1; + +=encoding utf8 + +=head1 NAME + +Mojolicious::Validator - Validate form data + +=head1 SYNOPSIS + + use Mojolicious::Validator; + + my $validator = Mojolicious::Validator->new; + my $validation = $validator->validation; + +=head1 DESCRIPTION + +L validates form data. + +=head1 ATTRIBUTES + +L implements the following attributes. + +=head2 checks + + my $checks = $validator->checks; + $validator = $validator->checks({range => sub {...}}); + +Registered checks, by default only C is already defined. + +=head2 errors + + my $errors = $validator->errors; + $validator = $validator->errors({range => sub {...}}); + +Registered error generators, by default only C and C are +already defined. + +=head1 METHODS + +L inherits all methods from L and +implements the following new ones. + +=head2 add_check + + $validator = $validator->add_check(range => sub {...}); + +Register a new check. + +=head2 add_error + + $validator = $validator->add_error(range => sub {...}); + +Register a new error generator. + +=head2 validation + + my $validation = $validator->validation; + +Get a new L object to perform validations. + +=head1 SEE ALSO + +L, L, L. + +=cut diff --git a/lib/Mojolicious/Validator/Validation.pm b/lib/Mojolicious/Validator/Validation.pm new file mode 100644 index 0000000000..6b0fb32e97 --- /dev/null +++ b/lib/Mojolicious/Validator/Validation.pm @@ -0,0 +1,212 @@ +package Mojolicious::Validator::Validation; +use Mojo::Base -base; + +use Carp 'croak'; +use Scalar::Util 'blessed'; + +has [qw(input output)] => sub { {} }; +has [qw(topic validator)]; + +sub AUTOLOAD { + my $self = shift; + + my ($package, $method) = our $AUTOLOAD =~ /^([\w:]+)::(\w+)$/; + Carp::croak "Undefined subroutine &${package}::$method called" + unless Scalar::Util::blessed $self && $self->isa(__PACKAGE__); + + croak qq{Can't locate object method "$method" via package "$package"} + unless $self->validator->checks->{$method}; + return $self->check($method => @_); +} + +sub DESTROY { } + +sub check { + my ($self, $check) = (shift, shift); + + my $err = delete $self->{error}; + return $self unless $self->is_valid; + + my $cb = $self->validator->checks->{$check}; + my $name = $self->topic; + return $self if $self->_check($name, $self->input->{$name}, $cb, @_); + + delete $self->output->{$name}; + $self->_error($check, $err, $name, delete $self->input->{$name}, @_); + return $self; +} + +sub error { + my $self = shift; + $self->{error} = shift; + return $self; +} + +sub errors { @{shift->{errors}{shift()} // []} } + +sub has_errors { !!keys %{shift->{errors}} } + +sub is_valid { exists $_[0]->output->{$_[1] // $_[0]->topic} } + +sub optional { + my ($self, $name) = @_; + my $input = $self->input->{$name}; + $self->output->{$name} = $input + if $self->_check($name, $input, sub { defined $_[2] && length $_[2] }); + return $self->topic($name); +} + +sub param { + my ($self, $name) = @_; + + # Multiple names + return map { scalar $self->param($_) } @$name if ref $name eq 'ARRAY'; + + # List names + return sort keys %{$self->output} unless $name; + + my $value = $self->output->{$name}; + my @values = ref $value eq 'ARRAY' ? @$value : ($value); + return wantarray ? @values : $values[0]; +} + +sub required { + my ($self, $name) = @_; + $self->optional($name); + my $err = delete $self->{error}; + $self->_error('required', $err, $name, $self->input->{$name}) + unless $self->is_valid; + return $self; +} + +sub _check { + my ($self, $name, $input, $cb) = (shift, shift, shift, shift); + for my $value (ref $input eq 'ARRAY' ? @$input : $input) { + return undef unless $self->$cb($name, $value, @_); + } + return 1; +} + +sub _error { + my ($self, $check, $err, $name, $input) + = (shift, shift, shift, shift, shift); + my $cb = $self->validator->errors->{$check} // sub {'Value is invalid.'}; + push @{$self->{errors}{$name}}, $err // $self->$cb($name, $_, @_) + for ref $input eq 'ARRAY' ? @$input : $input; +} + +1; + +=encoding utf8 + +=head1 NAME + +Mojolicious::Validator::Validation - Perform validations + +=head1 SYNOPSIS + + use Mojolicious::Validator; + use Mojolicious::Validator::Validation; + + my $validator = Mojolicious::Validator->new; + my $validation + = Mojolicious::Validator::Validation->new(validator => $validator); + +=head1 DESCRIPTION + +L performs validations. + +=head1 ATTRIBUTES + +L implements the following attributes. + +=head2 input + + my $input = $validation->input; + $validation = $validation->input({}); + +Data to be validated. + +=head2 output + + my $output = $validation->output; + $validation = $validation->output({}); + +Validated data. + +=head2 topic + + my $topic = $validation->topic; + $validation = $validation->topic('foo'); + +Current validation topic. + +=head2 validator + + my $validator = $validation->validator; + $validation = $validation->validator(Mojolicious::Validator->new); + +L object this validation belongs to. + +=head1 METHODS + +L inherits all methods from L +and implements the following new ones. + +=head2 check + + $validation = $validation->check('range', 2, 7); + +Perform validation check. + +=head2 error + + $validation = $validation->error('This went wrong.'); + +Set custom error message for next validation check. + +=head2 errors + + my @messages = $validation->errors('foo'); + +Get error messages for failed validation checks. + +=head2 has_errors + + my $success = $validation->has_errors; + +Check if this validation has error messages. + +=head2 is_valid + + my $success = $validation->is_valid; + my $success = $validation->is_valid('foo'); + +Check if validation was successful, defaults to checking the current C. + +=head2 optional + + $validation = $validation->optional('foo'); + +Change validation C. + +=head2 param + + my @names = $c->param; + my $foo = $c->param('foo'); + my @foo = $c->param('foo'); + my ($foo, $bar) = $c->param(['foo', 'bar']); + +Access validated parameters. + +=head2 required + + $validation = $validation->required('foo'); + +Change validation C and make sure a value is present. + +=head1 SEE ALSO + +L, L, L. + +=cut diff --git a/t/mojolicious/validation_lite_app.t b/t/mojolicious/validation_lite_app.t new file mode 100644 index 0000000000..dac774dc8f --- /dev/null +++ b/t/mojolicious/validation_lite_app.t @@ -0,0 +1,94 @@ +use Mojo::Base -strict; + +BEGIN { + $ENV{MOJO_NO_IPV6} = 1; + $ENV{MOJO_REACTOR} = 'Mojo::Reactor::Poll'; +} + +use Test::More; +use Mojolicious::Lite; +use Test::Mojo; + +get '/' => sub { + my $self = shift; + $self->validation->required('name')->range(2, 5); +} => 'index'; + +my $t = Test::Mojo->new; + +# Required and optional values +my $validation = $t->app->validation; +$validation->input({foo => 'bar', baz => 'yada'}); +ok $validation->required('foo')->is_valid, 'valid'; +is_deeply $validation->output, {foo => 'bar'}, 'right result'; +is $validation->param('foo'), 'bar', 'right value'; +is_deeply [$validation->param], ['foo'], 'right names'; +ok !$validation->has_errors, 'no errors'; +ok $validation->optional('baz')->is_valid, 'valid'; +is_deeply $validation->output, {foo => 'bar', baz => 'yada'}, 'right result'; +is $validation->param('baz'), 'yada', 'right value'; +is_deeply [$validation->param], [qw(baz foo)], 'right names'; +is_deeply [$validation->param([qw(foo baz)])], [qw(bar yada)], 'right values'; +ok !$validation->has_errors, 'no errors'; +ok !$validation->optional('does_not_exist')->is_valid, 'not valid'; +is_deeply $validation->output, {foo => 'bar', baz => 'yada'}, 'right result'; +ok !$validation->has_errors, 'no errors'; +ok !$validation->required('does_not_exist')->is_valid, 'not valid'; +is_deeply $validation->output, {foo => 'bar', baz => 'yada'}, 'right result'; +ok $validation->has_errors, 'has errors'; +is_deeply [$validation->errors('does_not_exist')], ['Value is required.'], + 'right error'; + +# Range +$validation = $t->app->validation; +$validation->input({foo => 'bar', baz => 'yada', yada => 'yada'}); +ok $validation->required('foo')->range(1, 3)->is_valid, 'valid'; +is_deeply $validation->output, {foo => 'bar'}, 'right result'; +ok !$validation->has_errors, 'no errors'; +ok !$validation->required('baz')->range(1, 3)->is_valid, 'not valid'; +is_deeply $validation->output, {foo => 'bar'}, 'right result'; +ok $validation->has_errors, 'has errors'; +is_deeply [$validation->errors('baz')], + ['Value needs to be 1-3 characters long.'], 'right error'; +ok !$validation->required('yada')->range(5, 10)->is_valid, 'not valid'; +is_deeply $validation->output, {foo => 'bar'}, 'right result'; +ok $validation->has_errors, 'has errors'; +is_deeply [$validation->errors('yada')], + ['Value needs to be 5-10 characters long.'], 'right error'; + +# Custom errors +$validation = $t->app->validation; +$validation->input({foo => 'bar', yada => 'yada'}); +ok !$validation->error('Bar is required.')->required('bar')->is_valid, + 'not valid'; +is_deeply $validation->output, {}, 'right result'; +ok $validation->has_errors, 'has errors'; +is_deeply [$validation->errors('bar')], ['Bar is required.'], 'right error'; +ok !$validation->required('baz')->is_valid, 'not valid'; +ok $validation->has_errors, 'has errors'; +is_deeply [$validation->errors('baz')], ['Value is required.'], 'right error'; +ok !$validation->required('foo')->error('Foo is too small.')->range(25, 100) + ->is_valid, 'not valid'; +ok $validation->has_errors, 'has errors'; +is_deeply [$validation->errors('foo')], ['Foo is too small.'], 'right error'; +is $validation->topic, 'foo', 'right topic'; +ok !$validation->error('Failed!')->required('yada')->range(25, 100)->is_valid, + 'not valid'; +ok $validation->has_errors, 'has errors'; +is_deeply [$validation->errors('yada')], + ['Value needs to be 25-100 characters long.'], 'right error'; +is $validation->topic, 'yada', 'right topic'; + +# Successful validation +$t->get_ok('/?name=sri')->status_is(200)->content_is("\n"); + +# Failed validation +$t->get_ok('/?name=sebastian')->status_is(200) + ->content_is("Value needs to be 2-5 characters long.\n"); + +done_testing(); + +__DATA__ + +@@ index.html.ep +%= $_ for validation->errors('name')