Group
Extension

Dancer2-Plugin-FormValidator/lib/Dancer2/Plugin/FormValidator.pm

package Dancer2::Plugin::FormValidator;

use 5.24.0;
use strict;
use warnings;

use Dancer2::Plugin;
use Dancer2::Core::Hook;
use Dancer2::Plugin::FormValidator::Config;
use Dancer2::Plugin::FormValidator::Factory::Extensions;
use Dancer2::Plugin::FormValidator::Registry;
use Dancer2::Plugin::FormValidator::Input;
use Dancer2::Plugin::FormValidator::Processor;
use Types::Standard qw(InstanceOf);

our $VERSION = '1.04';

plugin_keywords qw(validate validated errors);

has validator_config => (
    is       => 'ro',
    isa      => InstanceOf['Dancer2::Plugin::FormValidator::Config'],
    lazy     => 1,
    builder  => sub {
        return Dancer2::Plugin::FormValidator::Config->new(
            config => $_[0]->config,
        );
    }
);

has registry => (
    is       => 'ro',
    isa      => InstanceOf['Dancer2::Plugin::FormValidator::Registry'],
    lazy     => 1,
    default  => sub {
        my $factory = Dancer2::Plugin::FormValidator::Factory::Extensions->new(
            plugin     => $_[0],
            extensions => $_[0]->config->{extensions} // {},
        );

        return Dancer2::Plugin::FormValidator::Registry->new(
            extensions => $factory->build,
        );
    }
);

# Var for saving last success validation valid input.
has valid => (
    is       => 'rwp',
    clearer  => 1,
);

sub BUILD {
    $_[0]->_register_hooks;
    return;
}

sub validate {
    my ($self, %args) = @_;

    # We need to delete old data in session if it wasn't collected.
    $self->_clear_session;

    # We need to unset value of this var (if there was something).
    $self->clear_valid;

    # Arguments.
    # Arguments.
    my $profile = $args{profile};
    my $input   = $args{input};
    my $lang    = $args{lang};

    if (not defined $input) {
        my $request = $self->app->request;

        if ($request->is_get) {
            $input = $request->query_parameters->as_hashref_mixed;
        }
        elsif($request->is_post) {
            $input = $request->body_parameters->as_hashref_mixed;
        }
    }

    if (defined $lang) {
        $self->_validator_language($lang);
    }

    my $processor = Dancer2::Plugin::FormValidator::Processor->new(
        input    => Dancer2::Plugin::FormValidator::Input->new(input => $input),
        profile  => $profile,
        config   => $self->validator_config,
        registry => $self->registry,
    );

    my $result = $processor->run;

    if ($result->success != 1) {
        $self->_set_session({
            messages => $result->messages,
            old      => $input,
        });
        return undef;
    }
    else {
        $self->_set_valid($result->valid);

        return $self->valid;
    }
}

sub validated {
    return $_[0]->valid;
}

sub errors {
    if (my $session = $_[0]->_get_session) {
        return $session->{messages}
    }
    return undef;
}

# Register Dancer2 hook to add custom template tokens: errors, old.
sub _register_hooks {
    my ($self) = @_;

    $self->app->add_hook(
        Dancer2::Core::Hook->new(
            name => 'before_template_render',
            code => sub {
                my ($tokens) = @_;

                my $errors   = {};
                my $old      = {};

                if (my $session = $self->_get_session) {
                    $errors = $session->{messages};
                    $old    = $session->{old};
                }

                $tokens->{errors} = $errors;
                $tokens->{old}    = $old;

                return;
            },
        )
    );

    return;
}

# Set validator to language to $lang.
sub _validator_language {
    my ($self, $lang) = @_;

    $self->validator_config->language($lang);
    return;
}

sub _set_session {
    my ($self, $value) = @_;

    $self->app->session->write(
        $self->validator_config->session_namespace,
        $value,
    );

    return;
}

sub _get_session {
    my ($self) = @_;

    my $session = $self->app->session->read(
        $self->validator_config->session_namespace,
    );

    $self->_clear_session;

    return $session;
}

sub _clear_session {
    my ($self) = @_;

    $self->app->session->delete(
        $self->validator_config->session_namespace,
    );

    return;
}

1;

__END__
# ABSTRACT: Dancer2 validation framework.

=pod

=encoding UTF-8

=head1 NAME

Dancer2::Plugin::FormValidator - neat and easy to start form validation plugin for Dancer2.

=head1 VERSION

version 1.04

=head1 SYNOPSIS

    ### If you need a simple and easy validation in your project,
    ### This module is what you need.

    use Dancer2;
    use Dancer2::Plugin::FormValidator;

    ### First create form validation profile class.

    package RegisterForm {
        use Moo;
        with 'Dancer2::Plugin::FormValidator::Role::Profile';

        ### Here you need to declare fields => validators.

        sub profile {
            return {
                username     => [ qw(required alpha_num length_min:4 length_max:32) ],
                email        => [ qw(required email length_max:127) ],
                password     => [ qw(required length_max:40) ],
                password_cnf => [ qw(required same:password) ],
                confirm      => [ qw(required accepted) ],
            };
        }
    }

    ### Now you can use it in your Dancer2 project.

    post '/form' => sub {
        if (validate profile => RegisterForm->new) {
            my $valid_hash_ref = validated;

            save_user_input($valid_hash_ref);
            redirect '/success_page';
        }

        redirect '/form';
    };

The html result could be like:

=begin html

<p>
  <img alt="Screenshot register form" src="https://raw.githubusercontent.com/AlexP007/dancer2-plugin-formvalidator/main/assets/screenshot_register.png" width="500px">
</p>

=end html

=head1 DESCRIPTION

This is micro-framework that provides validation in your Dancer2 application.
It consists of dsl's keywords: validate, validated, errors.
It has a set of built-in validators that can be extended by compatible modules (extensions).
Also proved runtime switching between languages, so you can show proper error messages to users.

This module has a minimal set of dependencies and does not require the mandatory use of DBIc or Moose.

Uses simple and declarative approach to validate forms.

=head2 Validator

First, you need to create class which will implements
at least one main role: Dancer2::Plugin::FormValidator::Role::Profile.

This role requires profile method which should return a I<HashRef> Data::FormValidator accepts:

    package RegisterForm

    use Moo;
    with 'Dancer2::Plugin::FormValidator::Role::Profile';

    sub profile {
        return {
            username     => [ qw(required alpha_num_ascii length_min:4 length_max:32) ],
            email        => [ qw(required email length_max:127) ],
            password     => [ qw(required length_max:40) ],
            password_cnf => [ qw(required same:password) ],
            confirm      => [ qw(required accepted) ],
        };
    };

=head3 Profile method

Profile method should always return a I<HashRef[ArrayRef]> where keys are input fields names
and values are ArrayRef with list of validators.

=head2 Application

Then you need to set basic configuration:

    use Dancer2;

     set plugins => {
            FormValidator => {
                session => {
                    namespace => '_form_validator' # This is required field
                },
            },
        };

Now you can validate POST parameters in your controller:

    use Dancer2;
    use Dancer2::Plugin::FormValidator;
    use RegisterForm;

    post '/register' => sub {
        if (my $valid_hash_ref = validate profile => RegisterForm->new) {
            if (login($valid_hash_ref)) {
                redirect '/success_page';
            }
        }

        redirect '/register';
    };

    get '/register' => sub {
        template 'app/register' => {
            title  => 'Register page',
        };
    };

=head2 Template

In you template you have access to: $errors - this is I<HashRef[ArrayRef]> with fields names as keys
and error messages values and $old - contains old input values.

Template app/register:

    <div class="w-3/4 max-w-md bg-white shadow-lg py-4 px-6">
        <form method="post" action="/register">
            <div class="py-2">
                <label class="block font-normal text-gray-400" for="name">
                    Name
                </label>
                <input
                        type="text"
                        id="name"
                        name="name"
                        value="<: $old[name] :>"
                        class="border border-2 w-full h-5 px-4 py-5 mt-1 rounded-md
                        hover:outline-none focus:outline-none focus:ring-1 focus:ring-indigo-100"
                >
                <: for $errors[name] -> $error { :>
                    <small class="pl-1 text-red-400"><: $error :></small>
                <: } :>
            </div>
            <div class="py-2">
                <label class="block font-normal text-gray-400" for="email">
                    Email
                </label>
                <input
                        type="text"
                        id="email"
                        name="email"
                        value="<: $old[email] :>"
                        class="border border-2 w-full h-5 px-4 py-5 mt-1 rounded-md
                        hover:outline-none focus:outline-none focus:ring-1 focus:ring-indigo-100"
                >
                <: for $errors[email] -> $error { :>
                    <small class="pl-1 text-red-400"><: $error :></small>
                <: } :>

            <!-- Other fields -->
            ...
            ...
            ...
            <!-- Other fields end -->

            </div>
            <button
                    type="submit"
                    class="mt-4 bg-sky-600 text-white py-2 px-6 rounded-md hover:bg-sky-700"
            >
                Register
            </button>
        </form>
    </div>

=head1 CONFIGURATION

    ...
    plugins:
        FormValidator:
            session:
                namespace: '_form_validator'         # this is default
            messages:
                language: en                         # this is default
                ucfirst: 1                           # this is default
                validators:
                    required:
                        en: %s is needed from config # custom en message
                        de: %s ist erforderlich      # custom de message
                    ...
            extensions:
                dbic:
                    provider: Dancer2::Plugin::FormValidator::Extension::DBIC
                    ...
    ...

=head2 session

=head3 namespace

Session storage key where this module stores data, like: errors or old vars.

=head2 messages

=head3 language

Default language for error messages.

=head3 ucfirst

Apply ucfirst function to messages or not.

=head3 validators

Key => values, where key is validator name and value is messages
dictionary for different languages.

=head2 extensions

Key => values, where key is extension short name and values is its configuration.

=head1 DSL KEYWORDS

=head3 validate

    validate(Hash %args): HashRef|undef

Accept arguments as hash:

    (
        profile => Object implementing Dancer2::Plugin::FormValidator::Role::Profile # required
        input   => HashRef of values to validate, default is body_parameters->as_hashref_mixed
        lang    => Accepts two-lettered language id, default is 'en'
    )

Profile is required, input and lang is optional.

Returns valid input I<HashRef> if validation succeed, otherwise returns undef.

    ### You can use HashRef returned from validate.

    if (my $valid_hash_ref = validate profile => RegisterForm->new) {
        # Success, data is valid.
    }


    ### Or more declarative approach with validated keyword.

    if (validate profile => RegisterForm->new) {
        # Success, data is valid.
        my $valid_hash_ref = validated;

        # Do some operations...
    }
    else {
        # Error, data is invalid.
        my $errors = errors; # errors keyword returns error messages.

        # Redirect or show errors...
    }

=head3 validated

    validated(): HashRef|undef

No arguments.
Returns valid input I<HashRef> if validate succeed.
I<Undef> value will be returned after first call within one validation process.

    my $valid_hash_ref = validated;

=head3 errors

    errors(): HashRef

No arguments.
Returns I<HashRef[ArrayRef]> if validation failed.

    my $errors_hash_multi = errors;

=head1 Validators

=head3 accepted

    accepted(): Bool

Validates that field B<exists> and one of the listed: (yes on 1).

    field => [ qw(accepted) ]

=head3 alpha

    alpha(Str $encoding = 'a'): Bool

Validate that string only contain of alphabetic symbols.
By default encoding is ascii, i.e B</^[[:alpha:]]+$/a>.

    field => [ qw(alpha) ]

To set encoding to unicode you need to pass 'u' argument:

    field => [ qw(alpha:u) ]

Then the validation rule will be B</^[[:alpha:]]+$/>.

=head3 alpha_num

    alpha_num(Str $encoding = 'a'): Bool

Validate that string only contain of alphabetic symbols, underscore and numbers 0-9.
By default encoding is ascii, i.e. B</^\w+$/a>.

    field => [ qw(alpha_num) ]

To set encoding to unicode you need to pass 'u' argument:

    field => [ qw(alpha_num:u) ]

Rule will be B</^\w+$/>.

=head3 boolean

    boolean(): Bool

Validate that field is 0 or 1 scalar value.

    field => [ qw(boolean) ]

=head3 email

    email(): Bool

Validate that field is valid email(B<rfc822>).

    field => [ qw(email) ]

=head3 email_dns

    email_dns(): Bool

Validate that field is valid email(B<rfc822>) and dns exists.

    field => [ qw(email_dns) ]

=head3 enum

    enum(Array @values): Bool

Validate that field is one of listed values.

    field => [ qw(enum:value1,value2) ]

=head3 integer

    integer(): Bool

Validate that field is integer.

    field => [ qw(integer) ]

=head3 length_max

    length_max(Int $num): Bool

Validate that string length <= num.

    field => [ qw(length_max:32) ]

=head3 length_min

    length_min(Int $num): Bool

Validate that string length >= num.

    field => [ qw(length_max:4) ]

=head3 max

    max(Int $num): Bool

Validate that field is number <= num.

    field => [ qw(max:32) ]

=head3 min

    min(Int $num): Bool

Validate that field is number >= num.

    field => [ qw(min:4) ]

=head3 numeric

    numeric(): Bool

Validate that field is number.

    field => [ qw(numeric) ]

=head3 required

    required(): Bool

Validate that field exists and not empty string.

    field => [ qw(required) ]

=head3 required_with

    required_with(Str $field_name): Bool

Validate that field exists and not empty string if another field is exists and not empty.

    field_1 => [ qw(required) ]
    field_2 => [ qw(required_with:field_1) ]

=head3 same

    same(Str $field_name): Bool

Validate that field is exact value as another.

    field_1 => [ qw(required) ]
    field_2 => [ qw(required same:field_1) ]

=head1 CUSTOM MESSAGES

To define custom error messages for fields/validators your Validator should implement
Role: Dancer2::Plugin::FormValidator::Role::ProfileHasMessages.

    package Validator {
        use Moo;
        with 'Dancer2::Plugin::FormValidator::Role::ProfileHasMessages';

        sub profile {
            return {
                name  => [qw(required)],
                email => [qw(required email)],
            };
        }

        sub messages {
            return {
                name => {
                    required => {
                        en => 'Specify your %s',
                    },
                },
                email => {
                    required => {
                        en => '%s is needed',
                    },
                    email => {
                        en => '%s please use valid email',
                    }
                }
            };
        }
    }

=head1 HOOKS

There is hook_before method available, which allows your Profile object to make
decisions depending on the input data. You could use it with Moo around modifier:

    around hook_before => sub {
        my ($orig, $self, $profile, $input) = @_;

        # If there is specific input value.
        if ($input->{name} eq 'Secret') {
            # Delete all validators for field 'surname'.
            delete $profile->{surname};
        }

        return $orig->($self, $profile, $input);
    };

=head1 EXTENSIONS

=head2 Writing custom extensions

You can extend the set of validators by writing extensions:

    package Extension {
        use Moo;
        with 'Dancer2::Plugin::FormValidator::Role::Extension';

        sub validators {
            return {
                is_true  => 'IsTrue',   # Full class name
                email    => 'Email',    # Full class name
                restrict => 'Restrict', # Full class name
            }
        }
    }

Extension should implement Role: Dancer2::Plugin::FormValidator::Role::Extension.

B<Hint:> you could reassign built-in validator with your custom one.

Custom validators:

    package IsTrue {
        use Moo;
        with 'Dancer2::Plugin::FormValidator::Role::Validator';

        sub message {
            return {
                en => '%s is not a true value',
            };
        }

        sub validate {
            my ($self, $field, $input) = @_;

            if (exists $input->{$field}) {
                if ($input->{$field} == 1) {
                    return 1;
                }
                else {
                    return 0;
                }
            }

            return 1;
        }
    }

Validator should implement Role: Dancer2::Plugin::FormValidator::Role::Validator.

Config:

    set plugins => {
        FormValidator => {
            session    => {
                namespace => '_form_validator'
            },
            extensions => {
                extension => {
                    provider => 'Extension',
                }
            }
        },
    };

=head2 Extensions modules

There is a set of ready-made extensions available on cpan:

=over 4

=item *
L<Dancer2::Plugin::FormValidator::Extension::Password|https://metacpan.org/pod/Dancer2::Plugin::FormValidator::Extension::Password>
- for validating passwords.

=item *
L<Dancer2::Plugin::FormValidator::Extension::DBIC|https://metacpan.org/pod/Dancer2::Plugin::FormValidator::Extension::DBIC>
- for checking fields existence in table rows.

=back

=head1 ROLES

=over 4

=item *
Dancer2::Plugin::FormValidator::Role::Profile - for profile classes.

=item *
Dancer2::Plugin::FormValidator::Role::HasMessages - for classes, that implements custom error messages.

=item *
Dancer2::Plugin::FormValidator::Role::ProfileHasMessages - brings together Profile and HasMassages.

=item *
Dancer2::Plugin::FormValidator::Role::Extension - for extension classes.

=item *
Dancer2::Plugin::FormValidator::Role::Validator - for custom validators.

=back

=head1 HINTS

If you don't want to create separated classes for your validation logic,
you could create one base class and reuse it in your project.

    ### Validator class

    package Validator {
        use Moo;
        with 'Dancer2::Plugin::FormValidator::Role::Profile';

        has profile_hash => (
            is       => 'ro',
            required => 1,
        );

        sub profile {
            return $_[0]->profile_hash;
        }
    }

    ### Application

    use Dancer2

    my $validator = Validator->new(profile_hash =>
        {
            email => [qw(required email)],
        }
    );

    post '/subscribe' => sub {
        if (not validate profile => $validator) {
            to_json errors;
        }
    };

=head1 BUGS AND LIMITATIONS

If you find one, please let me know.

=head1 SOURCE CODE REPOSITORY

L<https://github.com/AlexP007/dancer2-plugin-formvalidator|https://github.com/AlexP007/dancer2-plugin-formvalidator>.

=head1 AUTHOR

Alexander Panteleev <alexpan at cpan dot org>.

=head1 LICENSE AND COPYRIGHT

This software is copyright (c) 2022 by Alexander Panteleev.
This is free software; you can redistribute it and/or modify it under
the same terms as the Perl 5 programming language system itself.

=cut


Powered by Groonga
Maintained by Kenichi Ishigaki <ishigaki@cpan.org>. If you find anything, submit it on GitHub.