File Coverage

lib/Mojolicious/Plugin/InputValidation.pm
Criterion Covered Total %
statement 156 173 90.1
branch 43 56 76.7
condition 55 149 36.9
subroutine 40 42 95.2
pod 1 1 100.0
total 295 421 70.0


line stmt bran cond sub pod time code
1             package IV_ANY;
2 6     6   11 sub new { my $class = shift; bless {@_}, $class }
  6         27  
3 4     4   17 sub optional { shift->{optional} }
4 70     70   263 sub nillable { shift->{nillable} }
5 30     30   263 sub empty { shift->{empty} }
6 62 100   62   76 sub error { my $self = shift; $self->{error} = shift if @_; $self->{error} }
  62         112  
  62         157  
7             sub pattern {
8 18     18   25 my $self = shift;
9 18 50       27 $self->{pattern} = shift if @_;
10             $self->{pattern}
11 18         99 }
12             sub accepts {
13 6     6   10 my ($self, $value, $path) = @_;
14 6 100 33     10 return 1 if ($self->nillable and not defined $value)
      33        
      33        
      0        
      33        
      33        
      33        
      66        
      66        
15             or ($self->empty and defined $value and !ref $value and $value eq '')
16             or (defined $value && !$self->pattern)
17             or ($self->pattern && $value =~ $self->pattern);
18              
19 3   50     16 $self->error("Value '$value' does not match at path " . ($path || '/'));
20 3         8 return 0;
21             }
22              
23             package IV_WORD;
24 2     2   631517 use base 'IV_ANY';
  2         13  
  2         1128  
25 6     6   12 sub new { my $class = shift; bless {@_}, $class }
  6         23  
26             sub accepts {
27 6     6   12 my ($self, $value, $path) = @_;
28 6 100 33     23 return 1 if ($self->nillable and not defined $value)
      33        
      33        
      0        
      33        
      66        
29             or ($self->empty and defined $value and !ref $value and $value eq '')
30             or ($value =~ /^\w+$/);
31              
32 2   50     25 $self->error("Value '$value' does not match word characters only at path " . ($path || '/'));
33 2         21 return 0;
34             }
35              
36             package IV_FLOAT;
37 2     2   15 use base 'IV_ANY';
  2         4  
  2         792  
38 6     6   12 sub new { my $class = shift; bless {@_}, $class }
  6         60  
39             sub accepts {
40 4     4   10 my ($self, $value, $path) = @_;
41 4 100 33     15 return 1 if ($self->nillable and not defined $value)
      33        
      33        
      0        
      33        
      66        
42             or ($self->empty and defined $value and !ref $value and $value eq '')
43             or ($value =~ /^-?\d+\.\d+$/);
44              
45 2   50     22 $self->error("Value '$value' is not a float at path " . ($path || '/'));
46 2         6 return 0;
47             }
48              
49             package IV_INT;
50 2     2   16 use base 'IV_ANY';
  2         4  
  2         790  
51 7     7   15 sub new { my $class = shift; bless {@_}, $class }
  7         29  
52             sub accepts {
53 11     11   32 my ($self, $value, $path) = @_;
54 11 100 33     24 return 1 if ($self->nillable and not defined $value)
      33        
      33        
      0        
      33        
      66        
55             or ($self->empty and defined $value and !ref $value and $value eq '')
56             or ($value =~ /^-?\d+$/);
57              
58 2   50     17 $self->error("Value '$value' is not an integer at path " . ($path || '/'));
59 2         5 return 0;
60             }
61              
62             package IV_ARRAY;
63 2     2   16 use base 'IV_ANY';
  2         5  
  2         1391  
64             sub new {
65 4     4   9 my $class = shift;
66 4         5 my $options = {};
67              
68 4         9 while (@_) {
69 5         11 my $elem = shift;
70 5 100       22 if (ref $elem eq 'ARRAY') {
71 1         4 $options->{pattern} = $elem;
72             }
73             else {
74 4         12 $options->{$elem} = shift;
75             }
76             }
77              
78 4         16 bless $options, $class
79             }
80             sub accepts {
81 4     4   6 my ($self, $value, $path) = @_;
82              
83 4 50 33     11 return 1 if $self->nillable and not defined $value;
84              
85 4 50       9 unless (ref $value eq 'ARRAY') {
86 0   0     0 $self->error("Array expected at path " . ($path || '/'));
87 0         0 return 0;
88             }
89              
90 4         7 my $elems = scalar @$value;
91              
92 4 50 66     14 if (defined $self->{max} && $elems > $self->{max}) {
93             $self->error(sprintf("Too many elements in array (%d vs %d) at path %s",
94 0   0     0 $elems, $self->{max}, $path || '/'));
95 0         0 return 0;
96             }
97              
98 4 50 33     11 if (defined $self->{min} && $elems < $self->{min}) {
99             $self->error(sprintf("Too few elements in array (%d vs %d) at path %s",
100 0   0     0 $elems, $self->{min}, $path || '/'));
101 0         0 return 0;
102             }
103              
104 4 100 33     15 if ($self->{of}) {
    50 33        
105 3   66     15 for (my $i = 0; $i < ($self->{max} // $elems); $i++) {
106 7         20 my $err = Mojolicious::Plugin::InputValidation::_validate_structure($value->[$i], $self->{of}, "$path/$i");
107              
108 7 100       20 if ($err) {
109 2         10 $self->error($err);
110 2         5 return 0;
111             }
112             }
113             }
114             elsif ($self->{pattern} && !$self->{min} && !$self->{min}) {
115 1         3 for (my $i = 0; $i < scalar @{$self->{pattern}}; $i++) {
  2         6  
116 1         4 my $err = Mojolicious::Plugin::InputValidation::_validate_structure($value->[$i], $self->{pattern}[$i], "$path/$i");
117              
118 1 50       8 if ($err) {
119 0         0 $self->error($err);
120 0         0 return 0;
121             }
122             }
123             }
124             else {
125 0   0     0 $self->error('Error: illegal pattern for array at path ' . ($path // '/'));
126 0         0 return 0;
127             }
128              
129 2         15 return 1;
130             }
131              
132             package IV_OBJECT;
133 2     2   16 use base 'IV_ANY';
  2         4  
  2         1348  
134             sub new {
135 36     36   68 my $class = shift;
136 36         51 my $options = {};
137              
138 36         90 while (@_) {
139 36         46 my $elem = shift;
140 36 50       73 if (ref $elem eq 'HASH') {
141 36         96 $options->{pattern} = $elem;
142             }
143             else {
144 0         0 $options->{$elem} = shift;
145             }
146             }
147              
148 36         72 bless $options, $class
149             }
150             sub accepts {
151 36     36   66 my ($self, $value, $path) = @_;
152              
153 36 50 33     76 return 1 if $self->nillable and not defined $value;
154              
155 36 50       74 unless (ref $value eq 'HASH') {
156 0   0     0 $self->error("Object expected at path " . ($path || '/'));
157 0         0 return 0;
158             }
159              
160 36         144 my @have_keys = sort keys %$value;
161 36         55 my @want_keys = sort keys %{$self->{pattern}};
  36         111  
162 36         65 my %want_keys = map { $_ => 1 } @want_keys;
  45         116  
163 36         67 my %have_keys = map { $_ => 1 } @have_keys;
  42         100  
164 36         55 my @unexpected = grep { !$want_keys{$_} } @have_keys;
  42         96  
165 36   100     52 my @missing = grep { !$have_keys{$_} && !$self->{pattern}{$_}->optional } @want_keys;
  45         157  
166              
167 36 100       77 if (@unexpected) {
168 1   50     13 $self->error(sprintf("Unexpected keys '%s' found at path %s", join(',', @unexpected), $path || '/'));
169 1         5 return 0;
170             }
171              
172 35 100       69 if (@missing) {
173 1   50     10 $self->error(sprintf("Missing keys '%s' at path %s", join(',', @missing), $path || '/'));
174 1         5 return 0;
175             }
176              
177 34         52 for my $key (grep { $have_keys{$_} } @want_keys) {
  42         72  
178 40         127 my $err = Mojolicious::Plugin::InputValidation::_validate_structure($value->{$key}, $self->{pattern}{$key}, "$path/$key");
179              
180 40 100       120 if ($err) {
181 17         45 $self->error($err);
182 17         60 return 0;
183             }
184             }
185              
186 17         53 return 1;
187             }
188              
189             package IV_DATETIME;
190 2     2   17 use base 'IV_ANY';
  2         11  
  2         949  
191 6     6   11 sub new { my $class = shift; bless {@_}, $class }
  6         36  
192             sub pattern {
193 3     3   4 my $self = shift;
194 3 50       7 $self->{pattern} = shift if @_;
195 3 50       39 $self->{pattern} || qr/^20\d\d-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d\d\d)?(Z|[+-]\d\d\d\d)$/
196             }
197             sub accepts {
198 3     3   7 my ($self, $value, $path) = @_;
199 3 100 33     7 return 1 if ($self->nillable and not defined $value)
      33        
      33        
      0        
      33        
      66        
200             or ($self->empty and defined $value and !ref $value and $value eq '')
201             or ($value =~ $self->pattern);
202              
203 1   50     12 $self->error("Value '$value' does not match datetime format at path " . ($path || '/'));
204 1         3 return 0;
205             }
206              
207             package Mojolicious::Plugin::InputValidation;
208 2     2   489 use Mojo::Base 'Mojolicious::Plugin';
  2         189464  
  2         24  
209 2     2   1199 no strict 'subs';
  2         7  
  2         132  
210              
211             our $VERSION = '0.08';
212              
213 2     2   14 use Mojo::Util 'monkey_patch';
  2         4  
  2         1456  
214              
215 6     6   98 sub iv_datetime { IV_DATETIME->new(@_) }
216 36     36   79 sub iv_object { IV_OBJECT->new(@_) }
217 4     4   12 sub iv_array { IV_ARRAY->new(@_) }
218 7     7   40628 sub iv_int { IV_INT->new(@_) }
219 6     6   43 sub iv_float { IV_FLOAT->new(@_) }
220 6     6   26 sub iv_word { IV_WORD->new(@_) }
221 6     6   20 sub iv_any { IV_ANY->new(@_) }
222              
223             sub import {
224 2     2   19 my $caller = caller;
225 2         10 monkey_patch $caller, 'iv_datetime', \&iv_datetime;
226 2         45 monkey_patch $caller, 'iv_object', \&iv_object;
227 2         60 monkey_patch $caller, 'iv_array', \&iv_array;
228 2         28 monkey_patch $caller, 'iv_int', \&iv_int;
229 2         25 monkey_patch $caller, 'iv_float', \&iv_float;
230 2         27 monkey_patch $caller, 'iv_word', \&iv_word;
231 2         35 monkey_patch $caller, 'iv_any', \&iv_any;
232             }
233              
234             sub register {
235 1     1 1 267 my ($self, $app, $conf) = @_;
236              
237             $app->helper(validate_json_request => sub {
238 4     4   69 my ($c, $pattern) = @_;
239 4         13 return _validate_structure($c->req->json, $pattern);
240 1         13 });
241             $app->helper(validate_params => sub {
242 0     0   0 my ($c, $pattern) = @_;
243 0         0 return _validate_structure($c->params, $pattern);
244 1         179 });
245             $app->helper(validate_structure => sub {
246 0     0   0 my ($c, $structure, $pattern) = @_;
247 0         0 return _validate_structure($structure, $pattern);
248 1         96 });
249             }
250              
251             sub _validate_structure {
252 70     70   1170 my ($input, $pattern, $path) = @_;
253              
254 70 100       202 if (ref $pattern eq 'HASH') {
    100          
255 36         64 $pattern = iv_object($pattern);
256             }
257             elsif (ref $pattern eq 'ARRAY') {
258 1         3 $pattern = iv_array($pattern);
259             }
260              
261 70 50       190 return sprintf("Error: pattern '%s' must be of kind iv_*", $pattern)
262             unless UNIVERSAL::isa($pattern, IV_ANY);
263              
264 70 100 100     221 return $pattern->error unless $pattern->accepts($input, $path // '');
265              
266 39         160 return '';
267             }
268              
269             =encoding utf8
270              
271             =head1 NAME
272              
273             Mojolicious::Plugin::InputValidation - Validate incoming requests
274              
275             =head1 SYNOPSIS
276              
277             use Mojolicious::Lite;
278             plugin 'InputValidation';
279              
280             # This needs to be done where one wants to use the iv_* routines.
281             use Mojolicious::Plugin::InputValidation;
282              
283             post '/books' => sub {
284             my $c = shift;
285              
286             # Validate incoming requests against our data model.
287             if (my $error = $c->validate_json_request({
288             title => iv_any,
289             abstract => iv_any(optional => 1, empty => 1),
290             author => {
291             firstname => iv_word,
292             lastname => iv_word,
293             },
294             published => iv_datetime,
295             price => iv_float,
296             revision => iv_int,
297             isbn => iv_any(pattern => qr/^[0-9\-]{10,13}$/),
298             })) {
299             return $c->render(status => 400, text => $error);
300             }
301              
302             # Now the payload is safe to use.
303             my $payload = $c->req->json;
304             ...
305             };
306              
307             =head1 DESCRIPTION
308              
309             L compares structures against a pattern.
310             The pattern is usually a nested structure, so the compare methods search
311             recursively for the first non-matching value. If such a value is found a
312             speaking error message is returned, otherwise a false value.
313              
314             =head1 METHODS
315              
316             L adds methods to the connection object
317             in a mojolicous controller. This way input validation becomes easy.
318              
319             =head2 validate_json_request
320              
321             my $error = $c->validate_json_request($pattern);
322              
323             This method try to match the json request payload ($c->req->json) against the
324             given pattern. If the payload matches, a false value is returned. If the payload
325             on the other hand does not match the pattern, the first non-matching value is
326             returned along with a speaking error message. The error message could look like:
327              
328             "Unexpected keys 'id,name' found at path /author"
329              
330             =head1 TYPES
331              
332             The pattern consists of one or more types the input is matched against.
333             The following types are available.
334              
335             =over 4
336              
337             =item iv_any
338              
339             This is the base type for all other types. By default it matches defined values
340             only. It supports beeing optional, means that it is okay if this element is
341             missing entirely in the payload.
342             When this type is marked as nillable, it also accepts a null/undef value.
343             To accept an empty string, mark it as empty.
344             This type supports a regex pattern to match against. All options can be combined.
345              
346             {
347             foo => iv_any,
348             bar => iv_any(optional => 1, empty => 1),
349             baz => iv_any(nillable => 1),
350             quux => iv_any(pattern => qr/^new|mint|used$/),
351             }
352              
353             =item iv_int
354              
355             This type matches integers, literally digits with an optional leading dash.
356              
357             {
358             foo => iv_int,
359             bar => iv_int(optional => 1),
360             baz => iv_int(nillable => 1),
361             }
362              
363             =item iv_float
364              
365             This type matches floats, so digits divided by a single dot, with an optional
366             leading dash.
367              
368             {
369             foo => iv_float,
370             bar => iv_float(optional => 1),
371             baz => iv_float(nillable => 1),
372             }
373              
374             =item iv_word
375              
376             This type is meant to match identifiers. It matches word character strings (\w+).
377             Using the iv_any type one can achieve the same with: iv_any(pattern => qr/^\w+$/)
378             To accept an empty string, mark it as empty.
379              
380             {
381             foo => iv_word,
382             bar => iv_word(optional => 1, empty => 1),
383             baz => iv_word(nillable => 1),
384             }
385              
386             =item iv_datetime
387              
388             This type matches datetime strings in the following format:
389              
390             YYYY-mm-DDTHH:mm:ssZ
391             YYYY-mm-DDTHH:mm:ss-0100
392             YYYY-mm-DDTHH:mm:ss+0000
393             YYYY-mm-DDTHH:mm:ss+0100
394             YYYY-mm-DDTHH:mm:ss.uuu+0100
395              
396             It also supports a regex pattern, but that kinda defeats the purpose of this type.
397              
398             {
399             foo => iv_datetime,
400             bar => iv_datetime(optional => 1),
401             baz => iv_datetime(nillable => 1),
402             quux => iv_datetime(pattern => qr/^\d\d\d\d-\d\d-\d\d$/,
403             }
404              
405             =item iv_object
406              
407             This types matches objects (hashes). It will recurse into the elements it contains.
408             A hash as a pattern is automatically turned into a iv_object. Using a hash is the
409             idiomatic way, unless you need to mark it as optional or nillable.
410              
411             {
412             foo => { ... },
413             bar => iv_object(optional => 1, { ... }),
414             baz => iv_object(nillable => 1, { ... }),
415             }
416              
417             =item iv_array - will match arrays
418              
419             This type will match arrays in two different ways. For one it can match a payload
420             against a fixed shape, and second it can match against an elemnt base type.
421             A literal array reference ([]) is turned into an iv_array of the first kind
422             automatically. The following is valid:
423              
424             {
425             foo => [iv_int, iv_word, ...],
426             bar => iv_array(optional => 1, [iv_int, iv_word, ...]),
427             baz => iv_array(nillable => 1, [iv_int, iv_word, ...]),
428             quux => iv_array(of => iv_int, min => 1, max => 7),
429             }
430              
431             =back
432              
433             =head1 ALERT
434              
435             This plugin is in alpha state, means it might not work at all or not as advertised.
436              
437             =head1 SEE ALSO
438              
439             L, L, L.
440              
441             =head1 LICENSE AND COPYRIGHT
442              
443             Copyright 2018 Tobias Leich.
444              
445             This program is free software; you can redistribute it and/or modify it
446             under the terms of the the Artistic License (2.0). You may obtain a
447             copy of the full license at:
448              
449             L
450              
451             Any use, modification, and distribution of the Standard or Modified
452             Versions is governed by this Artistic License. By using, modifying or
453             distributing the Package, you accept this license. Do not use, modify,
454             or distribute the Package, if you do not accept this license.
455              
456             If your Modified Version has been derived from a Modified Version made
457             by someone other than you, you are nevertheless required to ensure that
458             your Modified Version complies with the requirements of this license.
459              
460             This license does not grant you the right to use any trademark, service
461             mark, tradename, or logo of the Copyright Holder.
462              
463             This license includes the non-exclusive, worldwide, free-of-charge
464             patent license to make, have made, use, offer to sell, sell, import and
465             otherwise transfer the Package with respect to any patent claims
466             licensable by the Copyright Holder that are necessarily infringed by the
467             Package. If you institute patent litigation (including a cross-claim or
468             counterclaim) against any party alleging that the Package constitutes
469             direct or contributory patent infringement, then this Artistic License
470             to you shall terminate on the date that such litigation is filed.
471              
472             Disclaimer of Warranty: THE PACKAGE IS PROVIDED BY THE COPYRIGHT HOLDER
473             AND CONTRIBUTORS "AS IS' AND WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES.
474             THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
475             PURPOSE, OR NON-INFRINGEMENT ARE DISCLAIMED TO THE EXTENT PERMITTED BY
476             YOUR LOCAL LAW. UNLESS REQUIRED BY LAW, NO COPYRIGHT HOLDER OR
477             CONTRIBUTOR WILL BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, OR
478             CONSEQUENTIAL DAMAGES ARISING IN ANY WAY OUT OF THE USE OF THE PACKAGE,
479             EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
480              
481              
482             =cut
483              
484             1;