Not just another Perl form framework

Introducing Form::Tiny


The following text is mostly promotion of my open source work that I'm proud of. Feel free to skip it if that does not interest you.

So many form frameworks!

There's plenty of them. Data::MuForm, Form::Toolkit, HTML::FormFu, Form::Sensible, Validate::Tiny, Mojolicious::Validator and many others. I'm sure they are all capable. From all of them Form::Toolkit is probably the one I like the most, but they all have one of the two flaws: they are either a bit too basic with no type checking or they are bloated with the entire set of field types and multiple heavy dependencies.

Moo(se) with Type::Tiny is a really good validator

Moose class fields are very much like form fields. You pass input in object construction and try/catch an exception thrown if the object cannot be constructed due to input error. You can't know if there are more errors if you've caught one, and the error messages are not suited for the end user to see (due to file names and line numbers), but it is pretty much what I want, maybe with just a tiny bit more customization.

Form framework should be more than just a data validator

What I mean by that is form validation may not end when each individual field is correct, or even when password1 matches password2. A login form can just check if both email and password are strings, but I wouldn't mind if it also loaded a model from a database to check if the user exists and checked if that password matches. A registration form should not create a new database record, but it can check if that username/email is not taken. It all depends on what you're trying to achieve, but I believe a form should only validate when the data is truly valid, not when it looks like it may be valid after we query the database.

All in all, my idea of a form module consists of:

  • not caring about the presentation layer too much
  • reusing the types from the amazing Type::Tiny library
  • making it dead simple to customize
  • making room for custom code that will not feel like a hack
  • being minimalistic with dependencies

Introducing Form::Tiny

Lets start with an easy example - a most basic registration form:

# this is done inside a package

use Form::Tiny -base;
use Form::Tiny::Error;
use Type::EmailAddress;
use Types::Common::String qw(StrongPassword);

form_field 'email' => (
    type => EmailAddress,
    required => 1,
);

form_field 'password' => (
    type => StrongPassword,
    required => 1,
);

form_field 'password2' => (
    type => StrongPassword,
    required => 1,
);

form_cleaner sub {
    my ($self, $data) = @_;

    $self->add_error(
        Form::Tiny::Error::DoesNotValidate->new(
            error => 'passwords mismatch'
        )
    ) if $data->{password} ne $data->{password2};
};

form_field is used to define a new field, and is much alike Moose's has syntax-wise. form_cleaner defines a cleaner, which is run after all fields are validated successfully and can both report new errors and change the "clean" form data, which will be available from outside the class.

Obviously the best thing about it is that the learning curve should be fairly flat at this point, if you already use Type::Tiny (which you should!). All the type checks come from that library, the rest of the syntax closely resembles Moose, and the actual validation portion fits in just a couple of lines:

my $form = MyForm->new;
$form->set_input($input_data);

if ($form->valid) {
    $form->fields; # contains all the data after transformations
}
else {
    $form->errors; # contains all the errors
}

Naturally, it can do a lot more

## 1
use Form::Tiny -strict, -filtered;
use Form::Tiny::Error;

use Types::Standard qw(InstanceOf Str Enum);
use Types::XSD::Lite qw(Base64Binary);

use Bitcoin::Crypto::Key::Public;
use Bitcoin::Crypto::Network;
use MIME::Base64;
use Syntax::Keyword::Try;

## 2
has 'network' => (
    is => 'ro',
    isa => InstanceOf [Bitcoin::Crypto::Network::],
    default => sub { Bitcoin::Crypto::Network->get },
);

## 3
form_field 'public_key' => sub {
    my ($self) = @_;

    return {
        type => (InstanceOf [Bitcoin::Crypto::Key::Public::])
            ->plus_coercions(Str, q{ Bitcoin::Crypto::Key::Public->from_hex($_) }),
        required => 1,
        coerce => 1,
        adjust => sub { shift->set_network($self->network) },
    };
};

## 4
form_field 'data.*.message' => (
    type => Str,
    required => 1,
);

form_field 'data.*.signature' => (
    type => Base64Binary,
    required => 1,
    adjust => sub { decode_base64(shift) },
);

form_field 'algorithm' => (
    type => Enum[qw(sha256 sha512)],
    default => sub { 'sha256' },
);

form_cleaner sub {
    my ($self, $data) = @_;

    ## 5
    for my $data_set (@{$data->{data}}) {
        my $valid;
        try {
            $valid = $data->{public_key}->verify_message(
                $data_set->{message},
                $data_set->{signature},
                $data->{algorithm}
            );
        }
        catch {
            $valid = 0;
        }

        $self->add_error(
            Form::Tiny::Error::DoesNotValidate->new(
                error => 'invalid signature for: ' . substr($data_set->{message}, 0, 10) . '...',
            )
        ) if !$valid;
    }
};

That is certainly much more advanced example. This form can be used to import public keys and verify signatures for a public key. I am using my own Bitcoin::Crypto library here, so that could be a part of a signed transactions verification mechanism. Lets break it down (see marks in the comments):

#1

This time the Form::Tiny import does not have the -base flag, but -strict and -filtered.

The first one enables strict mode for the form, which causes any data that was not specified as a form field to produce an error.

The second one enables form field filtering, which will happen just before the field validation. By default, it will trim every string on input.

#2

This is just a regular Moo class field declaration. There's no magic here - using Form::Tiny with flags will load Moo into the namespace along with all its keywords. It can be initialized just like any other Moo property when constructing an object.

For this example, it will hold an instance of a cryptocurrency network definition.

#3

Under the hood all fields are built using the build_fields subroutine. The form_field is just a helper that wraps and replaces its definition inside the package with some black magic. However, that helper in its normal form has no access to the form class instance, which the regular builder can access. We could always resort to the bare metal style, but the form_field helper can also gain access to that instance with its a bit longer, function based form.

A bit more exciting part of that snippet is the type for public_key field. It is actually an class instance, but the form also gets informed that it should coerce any string value into that object. This way we immediately get rid of data that has little meaning to us - in this example it is a hex-encoded cryptocurrency public key. It only becomes useful after being promoted to a full class instance.

The last part of that snippet showcases the data adjustment, performed only after a successful validation. In this case it sets the cryptocurrency network declared in #2, since hex-encoded public keys does not carry that information (note: it is has no impact on signature verification done in this example, but it is important for address generation).

#4

These two field definitions showcase field nesting. The input hashref is expected to have a data key, which should be an arrayref of hashrefs. Each of these hashrefs should contain signature and message keys. If any of that is not true, the form validation fails.

As a bonus, a signature is base64 decoded after it passes the validation. After the adjustment, values no longer need to meet the type check requirements, which allows for more flexibility.

#5

Form cleaning verifies every message and signature, and adds errors if necessary. The entire form has effectively become a standalone signature checker with not that much code involved, and some extra capabilities beyond that.

Truly tiny, but pretty comprehensive

Thanks to not shipping a single data type, it only has a few non-core dependencies. With Moo and Type::Tiny already installed, it will take under ten seconds to install, even with its fairly sizeable test suite. I'm yet to benchmark it in terms of speed against other data validators from CPAN, and that could be a fun excercise.

I've never seen a data validator that cares about what you don't want. I really missed this some time ago writing validation code in Laravel - couldn't just feed the validated data to some logic, had to tidy it up first. I don't want to have to deal will any additional data that does not have validators tied to it. Form::Tiny deals with it pretty effectively - unwanted data doesn't ever end up in post-validation output, and with -strict flag (or when composing with a ::Strict role) unwanted data is considered a form error.

Type::Tiny offers many type libraries, but the neat thing is that the module can be used with any type definition compatible, and even with any class that can check and validate (with named methods). Since Type::Tiny is Moose-compatible, it means that almost any type library from CPAN could be used in its place.

There are some pain points, of course...

There were two things missing at the time of writing, which are now fixed. The first one is coercions - they were not wrapped inside a try/catch, so if a coercion produced an error then the entire form would blow up.

The second one was the lack of default value on form fields. Adjustments do not cut it, because they are not called if the value does not exist. Both of these fixes have been published to CPAN prior to publishing this article.

What will not be fixed is error messages. Type::Tiny was not meant to be validating data for end users, so the error messages are not very user friendly. Thankfully they do not expose file names and line numbers as normal exception catching does. To combat this, there is an option to specify your own error message for a field.

All in all, writing a form validation module is not as easy as it might seem. Numerous edge cases are possible that need to be taken care of, not to mention the inevitable hard decisions which have to be made. Like for example, I had to disallow default values for fields nested inside arrays, because the scope of that goes far beyond checking for the field existence - which would make the code much more complicated while not adding that much value to the module.

Now available on CPAN!

This module, as well as a couple of others, are available on my CPAN page. If you like my work, give my modules a try and ++ them / star them on github if they meet your expectations, or open bug reports if they don't. I hope you enjoy!


Comments? Suggestions? Send to bbrtj.pro@gmail.com
Published on 2021-01-06