File Coverage

lib/Mojolicious/Plugin/InputValidation.pm
Criterion Covered Total %
statement 168 185 90.8
branch 45 58 77.5
condition 74 172 43.0
subroutine 44 46 95.6
pod 1 1 100.0
total 332 462 71.8


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