File Coverage

blib/lib/Data/DynamicValidator.pm
Criterion Covered Total %
statement 205 205 100.0
branch 45 50 90.0
condition 29 41 70.7
subroutine 35 35 100.0
pod 7 8 87.5
total 321 339 94.6


line stmt bran cond sub pod time code
1             package Data::DynamicValidator;
2             #ABSTRACT: JPointer-like and Perl union for flexible perl data structures validation
3             $Data::DynamicValidator::VERSION = '0.04';
4 9     9   369797 use strict;
  9         24  
  9         240  
5 9     9   48 use warnings;
  9         26  
  9         227  
6              
7 9     9   67 use Carp;
  9         23  
  9         472  
8 9     9   2110 use Devel::LexAlias qw(lexalias);
  9         21884  
  9         418  
9 9     9   50 use PadWalker qw(peek_sub);
  9         18  
  9         318  
10 9     9   42 use Scalar::Util qw/looks_like_number/;
  9         16  
  9         287  
11 9     9   3349 use Storable qw(dclone);
  9         19453  
  9         456  
12              
13 9     9   2209 use aliased qw/Data::DynamicValidator::Error/;
  9         4123  
  9         52  
14 9     9   628 use aliased qw/Data::DynamicValidator::Filter/;
  9         16  
  9         32  
15 9     9   682 use aliased qw/Data::DynamicValidator::Label/;
  9         17  
  9         37  
16 9     9   591 use aliased qw/Data::DynamicValidator::Path/;
  9         18  
  9         30  
17              
18             use overload
19             fallback => 1,
20             '&{}' => sub {
21 37     37   209 my $self = shift;
22 37     37   99 return sub { $self->validate(@_) }
23 9     9   883 };
  9         15  
  9         43  
  37         126  
24              
25 9     9   494 use parent qw/Exporter/;
  9         16  
  9         35  
26             our @EXPORT_OK = qw/validator/;
27              
28 9   50 9   503 use constant DEBUG => $ENV{DATA_DYNAMICVALIDATOR_DEBUG} || 0;
  9         14  
  9         12106  
29              
30              
31              
32              
33              
34              
35              
36              
37             sub validator {
38 50     50 1 56337 return Data::DynamicValidator->new(@_);
39             }
40              
41             sub new {
42 50     50 0 107 my ($class, $data) = @_;
43 50         138 my $self = {
44             _data => $data,
45             _errors => [],
46             _bases => [],
47             };
48 50         259 return bless $self => $class;
49             }
50              
51              
52             sub validate {
53 41     41 1 139 my ($self, %args) = @_;
54              
55 41         75 my $on = $args{on };
56 41         58 my $should = $args{should };
57 41         57 my $because = $args{because};
58 41         62 my $each = $args{each };
59              
60 41 50 33     215 croak("Wrong arguments: 'on', 'should', 'because' should be specified")
      33        
61             if(!$on || !$should || !$because);
62              
63 41         49 warn "-- validating : $on \n" if DEBUG;
64              
65 41         75 my $errors = $self->{_errors};
66 41         58 my $selection_results;
67 41 50       81 if ( !@$errors ) {
68 41         52 my $success;
69 41         80 my $current_base = $self->current_base;
70 41         81 my $selector = $self->_rebase_selector($on);
71 41         82 ($success, $selection_results) = $self->_apply($selector, $should);
72 41 100       93 if (!$success) {
73             # if we met an error, and there is only 1 error path
74             # we report with expanded path, istead of unexpanded
75 6         8 my $error_routes = $selection_results->{routes};
76 6 100 66     22 my $reported_error_path = $error_routes && @$error_routes == 1
77             ? $error_routes->[0]
78             : $selector;
79 6         14 $self->report_error($because, $reported_error_path);
80             }
81             }
82             # OK, now going to child rules if there is no errors
83 41 100 100     129 if ( !@$errors && $each ) {
84 11         15 warn "-- no errors, will check children\n" if DEBUG;
85 11         29 $self->_validate_children($selection_results, $each);
86             }
87              
88 41         209 return $self;
89             }
90              
91              
92             sub report_error {
93 7     7 1 17 my ($self, $reason, $path) = @_;
94 7   66     19 $path //= $self->{_current_path};
95 7 50       14 croak "Can't report error unless path is undefined"
96             unless defined $path;
97 7         10 push @{ $self->{_errors} }, Error->new($reason, $path);
  7         29  
98             }
99              
100              
101 9     9 1 350 sub is_valid { @{ $_[0]->{_errors} } == 0; }
  9         47  
102              
103              
104              
105 11     11 1 34 sub errors { $_[0]->{_errors} }
106              
107              
108             sub rebase {
109 5     5 1 22 my ($self, $expandable_route, $rule) = @_;
110 5         10 my $current_base = $self->current_base;
111 5         12 my $selector = $self->_rebase_selector($expandable_route);
112 5         12 my $scenario = $self->_select($selector);
113 5         9 my $number_of_routes = @{ $scenario->{routes} };
  5         9  
114 5 50       14 carp "The route '$expandable_route' is ambigious for rebasing (should be unique)"
115             if $number_of_routes > 1;
116              
117 5 100       14 return $self if $number_of_routes == 0;
118              
119 4         5 push @{ $self->{_bases} }, $scenario->{routes}->[0];
  4         10  
120 4         12 $rule->($self);
121 4         19 pop @{ $self->{_bases} };
  4         10  
122 4         17 return $self;
123             }
124              
125              
126             sub current_base {
127 94     94 1 174 my $bases = $_[0]->{_bases};
128 94 100       181 return undef unless @$bases;
129 56         85 return $bases->[-1];
130             }
131              
132             ### private/implementation methods
133              
134             sub _rebase_selector {
135 46     46   80 my ($self, $selector) = @_;
136 46         77 my $current_base = $self->current_base;
137 46   100     108 my $add_base = $current_base && $selector !~ /^\/{2,}/;
138 46 100       109 my $rebased = $add_base ? $current_base . $selector : $selector;
139 46         70 warn "-- Rebasing selector $selector to $rebased \n" if DEBUG;
140 46         84 return $rebased;
141             }
142              
143             sub _validate_children {
144 11     11   23 my ($self, $selection_results, $each) = @_;
145 11         17 my ($routes, $values) = @{$selection_results}{qw/routes values/};
  11         27  
146 11         23 my $errors = $self->{_errors};
147 11         20 my $data = $self->{_data};
148 11         28 for my $i (0 .. @$routes-1) {
149 23         38 my $route = $routes->[$i];
150 23         32 push @{ $self->{_bases} }, $route;
  23         48  
151 23         37 my $value = $values->[$i];
152 23         63 my $label_for = { map { $_ => 1 } ($route->labels) };
  23         61  
153             # prepare context
154 23         119 my $pad = peek_sub($each);
155 23         84 while (my ($var, $ref) = each %$pad) {
156 38         202 my $var_name = substr($var, 1); # chomp sigil
157 38 100       117 next unless exists $label_for->{$var_name};
158 23         78 my $label_obj = Label->new($var_name, $route, $data);
159 23         68 lexalias($each, $var, \$label_obj);
160             }
161             # call
162 23         116 $self->{_current_path} = $route;
163 23         69 $each->($self, local $_ = Label->new('_', $route, $data));
164 23         564 pop @{ $self->{_bases} };
  23         52  
165 23 100       103 last if(@$errors);
166             }
167             }
168              
169              
170             # Takes path-like expandable expression and returns hashref of path with corresponding
171             # values from data, e.g.
172              
173             # validator({ a => [5,'z']})->_select('/a/*');
174             # # will return
175             # # {
176             # # routes => ['/a/0', '/a/1'],
177             # # values => [5, z],
178             # # }
179              
180             # Actualy routes are presented by Path objects.
181              
182             sub _select {
183 60     60   101 my ($self, $expession) = @_;
184 60         162 my $data = $self->{_data};
185 60         121 my $routes = $self->_expand_routes($expession);
186 60         113 my $values = [ map { $_->value($data) } @$routes ];
  78         184  
187             return {
188 60         177 routes => $routes,
189             values => $values,
190             };
191             }
192              
193              
194              
195             # Takes xpath-like expandable expression and sorted array of exapnded path e.g.
196             # validator({ a => [5,'z']})->_expand_routes('/a/*');
197             # # will return [ '/a/0', '/a/1' ]
198             # validator({ a => [5,'z']})->_expand_routes('/a');
199             # # will return [ '/a' ]
200             # validator({ a => { b => [5,'z'], c => ['y']} })->_expand_routes('/a/*/*');
201             # # will return [ '/a/b/0', '/a/b/1', '/a/c/0' ]
202              
203             sub _expand_routes {
204 80     80   129 my ($self, $expression) = @_;
205 80         101 warn "-- Expanding routes for $expression\n" if DEBUG;
206             # striping leading slashes
207 80         201 $expression =~ s/\/{2,}/\//;
208 80         237 my @routes = ( Path->new($expression) );
209 80         133 my $result = [];
210 80         171 while (@routes) {
211 187         287 my $route = shift(@routes);
212 187         384 my $current = $self->{_data};
213 187         400 my $elements = $route->components;
214 187         246 my $i;
215 187         281 my $can_be_accessed = 0;
216 187         396 for ($i = 0; $i < @$elements; $i++) {
217 597         748 $can_be_accessed = 0;
218 597         839 my $element = $elements->[$i];
219             # no futher examination if current value is undefined
220 597 100       951 last unless defined($current);
221 591 100       1129 next if($element eq '');
222 404         472 my $filter;
223 404         668 ($element, $filter) = _filter($element);
224 404         737 my $type = ref($current);
225 404         549 my $generator;
226             my $advancer;
227 404 100 66     1609 if ($element eq '*') {
    100 66        
    100 66        
      66        
228 75 100       183 if ($type eq 'HASH') {
    50          
229 44         146 my @keys = keys %$current;
230 44         80 my $idx = 0;
231             $generator = sub {
232 98     98   223 while($idx < @keys) {
233 86         152 my $key = $keys[$idx++];
234 86         342 my $match = $filter->($current->{$key}, {key => $key});
235 86 100       366 return $key if($match);
236             }
237 44         99 return undef;
238 44         146 };
239             } elsif ($type eq 'ARRAY') {
240 31         48 my $idx = 0;
241             $generator = sub {
242 84     84   187 while($idx < @$current) {
243 65         95 my $index = $idx++;
244 65         219 my $match = $filter->($current->[$index], {index => $index});
245 65 100       251 return $index if($match);
246             }
247 31         86 return undef;
248 31         101 };
249             }
250             }elsif ($type eq 'HASH' && exists $current->{$element}) {
251 228     228   531 $advancer = sub { $current->{$element} };
  228         353  
252             }elsif ($type eq 'ARRAY' && looks_like_number($element)
253             && (
254             ($element >= 0 && $element < @$current)
255             || ($element < 0 && abs($element) <= @$current)
256             )
257             ){
258 92     92   242 $advancer = sub { $current->[$element] };
  92         164  
259             }
260 404 100       754 if ($generator) {
261 75         129 while ( defined( my $new_element = $generator->()) ) {
262 107         3985 my $new_path = dclone($route);
263 107         345 $new_path->components->[$i] = $new_element;
264 107         271 push @routes, $new_path;
265             }
266 75         110 $current = undef;
267 75         350 last;
268             }
269 329 100       535 if ($advancer) {
270 320         487 $current = $advancer->();
271 320         411 $can_be_accessed = 1;
272 320         1021 next;
273             }
274             # the current element isn't hash nor array
275             # we can't traverse further, because there is more
276             # else current path
277 9         14 $current = undef;
278 9         27 $can_be_accessed = 0;
279             }
280 187   100     561 my $do_expansion = defined $current
281             || ($can_be_accessed && $i == @$elements);
282 187         233 warn "-- Expanded route : $route \n" if(DEBUG && $do_expansion);
283 187 100       602 push @$result, $route if($do_expansion);
284             }
285 80         285 return [ sort @$result ];
286             }
287              
288             sub _filter {
289 404     404   584 my $element = shift;
290 404         465 my $filter;
291 404         939 my $condition_re = qr/(.+?)(\[(.+)\])/;
292 404         1149 my @parts = $element =~ /$condition_re/;
293 404 100 66     955 if (@parts == 3 && defined($parts[2])) {
294 20         40 $element = $parts[0];
295 20         30 my $condition = $parts[2];
296 20         72 $filter = Filter->new($condition);
297             } else {
298 384     83   984 $filter = sub { 1 }; # always true
  83         121  
299             }
300 404         1074 return ($element, $filter);
301             }
302              
303              
304             # Takes the expandable expression and validation closure, then
305             # expands it, and applies the closure for every data piese,
306             # obtainted from expansion.
307              
308             # Returns the list of success validation mark and the hash
309             # of details (obtained via _select).
310              
311             sub _apply {
312 54     54   105 my ($self, $on, $should) = @_;
313 54         99 my $selection_results = $self->_select($on);
314 54         84 my $values = $selection_results->{values};
315 54   100     221 my $result = $values && @$values && $should->( @$values );
316 54         305 return ($result, $selection_results);
317             };
318              
319             1;
320              
321             __END__
322              
323             =pod
324              
325             =encoding UTF-8
326              
327             =head1 NAME
328              
329             Data::DynamicValidator - JPointer-like and Perl union for flexible perl data structures validation
330              
331             =head1 VERSION
332              
333             version 0.04
334              
335             =head1 SYNOPSIS
336              
337             my $my_complex_config = {
338             features => [
339             "a/f",
340             "application/feature1",
341             "application/feature2",
342             ],
343             service_points => {
344             localhost => {
345             "a/f" => { job_slots => 3, },
346             "application/feature1" => { job_slots => 5 },
347             "application/feature2" => { job_slots => 5 },
348             },
349             "127.0.0.1" => {
350             "application/feature2" => { job_slots => 5 },
351             },
352             },
353             mojolicious => {
354             hypnotoad => {
355             pid_file => '/tmp/hypnotoad-ng.pid',
356             listen => [ 'http://localhost:3000' ],
357             },
358             },
359             };
360              
361             use Data::DynamicValidator qw/validator/;
362             use Net::hostent;
363              
364             my $errors = validator($cfg)->(
365             on => '/features/*',
366             should => sub { @_ > 0 },
367             because => "at least one feature should be defined",
368             each => sub {
369             my $f = $_->();
370             shift->(
371             on => "//service_points/*/`$f`/job_slots",
372             should => sub { defined($_[0]) && $_[0] > 0 },
373             because => "at least 1 service point should be defined for feature '$f'",
374             )
375             }
376             )->(
377             on => '/service_points/sp:*',
378             should => sub { @_ > 0 },
379             because => "at least one service point should be defined",
380             each => sub {
381             my $sp;
382             shift->report_error("SP '$sp' isn't resolvable")
383             unless gethost($sp);
384             }
385             )->(
386             on => '/service_points/sp:*/f:*',
387             should => sub { @_ > 0 },
388             because => "at least one feature under service point should be defined",
389             each => sub {
390             my ($sp, $f);
391             shift->(
392             on => "//features/`*[value eq '$f']`",
393             should => sub { 1 },
394             because => "Feature '$f' of service point '$sp' should be decrlared in top-level features list",
395             )
396             },
397             })->rebase('/mojolicious/hypnotoad' => sub {
398             shift->(
399             on => '/pid_file',
400             should => sub { @_ == 1 },
401             because => "hypnotoad pid_file should be defined",
402             )->(
403             on => '/listen/*',
404             should => sub { @_ > 0 },
405             because => "hypnotoad listening interfaces defined",
406             );
407             })->errors;
408              
409             print "all OK\n"
410             unless(@$errors);
411              
412             =head2 RATIONALE
413              
414             There are complex data configurations, e.g. application configs. Not to
415             check them on applicaiton startup is B<wrong>, because of sudden
416             unexpected runtime errors can occur, which are not-so-pleasent to detect.
417             Write the code, that does full exhaustive checks, is B<boring>.
418              
419             This module tries to offer to use DLS, that makes data validation fun
420             for developer yet understandable for the person, which provides the data.
421              
422             =head1 DESCRIPTION
423              
424             First of all, you should create Validator instance:
425              
426             use Data::DynamicValidator qw/validator/;
427              
428             my $data = { ports => [2222] };
429             my $v = validator($data);
430              
431             Then, actually do validation:
432              
433             $v->(
434             on => '/ports/*',
435             should => sub { @_ > 0 },
436             because => 'At least one port should be defined at "ports" section',
437             );
438              
439             The C<on> parameter defines the data path, via JSON-pointer like expression;
440             the C<should> parameter provides the closure, which will check the values
441             gathered on via pointer. If the closure returns false, then the error will
442             be recorded, with description, provided by C<because> parameter.
443              
444             To get the results of validation, you can call:
445              
446             $v->is_valid; # returns true, if there is no validation errors
447             $v->errors; # returns array reference, consisting of the met Errors
448              
449             C<on>/C<should> parameters are convenient for validation of presense of
450             something, but they aren't so handy in checking of B<individual> values.
451             It should be mentioned, that C<should> closure, always takes an array of
452             the selected by C<on>, even if only one element has been selected.
453              
454             To handle B<individual> values in more convenient way the optional
455             C<each> parameter has been introduced.
456              
457             my $data = { ports => [2222, 3333] };
458             $v->(
459             on => '/ports/*',
460             should => sub { @_ > 0 },
461             because => 'At least one port should be defined at "ports" section',
462             each => sub {
463             my $port = $_->();
464             $v->report_error("All ports should be greater than 1000")
465             unless $port > 1000;
466             },
467             );
468              
469             So, C<report_error> could be used for custom errors reporting on current
470             path or current data value. The C<$_> is the an implicit alias or B<label>
471             to the last componenet of the current path, i.e. on our case the current
472             path in C<each> closure will be C</ports/0> and C</ports/1>, so the C<$_>
473             will be 0 and 1 respectively. To get the I<value> of the label, you should
474             "invoke" it, as showed previously. A label stringizes to the last data
475             path component, e.g. to "0" and "1" respectively.
476              
477             The C<each> closure single argrument is the validator instance itself. The
478             previous example could be rewriten with explicit label like:
479              
480             $v->(
481             on => '/ports/port:*',
482             should => sub { @_ > 0 },
483             because => 'At least one port should be defined at "ports" section',
484             each => sub {
485             my $port;
486             my $port_value = $port->();
487             shift->report_error("All ports should be greater than 1000")
488             unless $port_value > 1000;
489             },
490             );
491              
492             Providing aliases for array indices may be not so handy as for keys
493             of hashes. Please note, that the label C<port> was previously "declated"
494             in C<on> rule, and only then "injected" into C<$port> variable in
495             C<each> closure.
496              
497             Consider the following example:
498              
499             my $data = {
500             ports => [2000, 3000],
501             2000 => 'tcp',
502             3000 => 'udp',
503             };
504              
505             Let's validate it. The validation rule sounds as: there is 'ports' section,
506             where at least one port > 1000 should be declated, and then the same port
507             should appear at top-level, and it should be either 'tcp' or 'upd' type.
508              
509             use List::MoreUtils qw/any/;
510              
511             my $errors = validator($data)->(
512             on => '/ports/*[value > 1000 ]',
513             should => sub { @_ > 0 },
514             because => 'At least one port > 1000 should be defined in "ports" section',
515             each => sub {
516             my $port = $_->();
517             shift->(
518             on => "//*[key eq $port]",
519             should => sub { @_ == 1 && any { $_[0] eq $_ } (qw/tcp udp/) },
520             because => "The port $port should be declated at top-level as tcp or udp",
521             )
522             }
523             )->errors;
524              
525             As you probably noted, the the path expression contains two slashes at C<on> rule
526             inside C<each> rule. This is required to search data from the root, because
527             the current element is been set as B<base> before calling C<each>, so all expressions
528             inside C<each> are relative to the current element (aka base).
529              
530             You can change the base explicit way via C<rebase> method:
531              
532             my $data = {
533             mojolicious => {
534             hypnotoad => {
535             pid_file => '/tmp/hypnotoad-ng.pid',
536             listen => [ 'http://localhost:3000' ],
537             },
538             },
539             };
540              
541             $v->rebase('/mojolicious/hypnotoad' => sub {
542             shift->(
543             on => '/pid_file',
544             should => sub { @_ == 1 },
545             because => "hypnotoad pid_file should be defined",
546             )->(
547             on => '/listen/*',
548             should => sub { @_ > 0 },
549             because => "hypnotoad listening interfaces defined",
550             );
551             })->errors;
552              
553             =head2 DATA PATH EXPRESSIONS
554              
555             my $data = [qw/a b c d e/];
556             '/2' # selects the 'c' value in $data array
557             '/-1' # selects the 'e' value in $data array
558              
559             $data = { abc => 123 };
560             '/abc' # selects the '123' value in hashref under key 'abc'
561              
562             $data = {
563             mojolicious => {
564             hypnotoad => {
565             pid_file => '/tmp/hypnotoad-ng.pid',
566             }
567             }
568             };
569             '/mojolicious/hypnotoad/pid_file' # point to pid_file
570             '//mojolicious/hypnotoad/pid_file' # point to pid_file (independently of current base)
571              
572             # Escaping by back-quotes sample
573             $data => { "a/b" => { c => 5 } }
574             '/`a/b`/c' # selects 5
575              
576             $data = {abc => [qw/a b/]}; # 1
577             $data = {abc => { c => 'd'}}; # 2
578             $data = {abc => 7}; # 3
579             '/abc/*' # selects 'a' and 'b' in 1st case
580             # the 'd' in 2nd case
581             # the number 7 in 3rd case
582              
583             # Filtering capabilities samples:
584              
585             '/abc/*[size == 5]' # filter array/hash by size
586             '/abc/*[value eq "z"]' # filter array/hash by value equality
587             '/abc/*[index > 5]' # finter array by index
588             '/abc/*[key =~ /def/]' # finter hash by key
589              
590             =head2 DEBUGGING
591              
592             You can set the DATA_DYNAMICVALIDATOR_DEBUG environment variable
593             to get some advanced diagnostics information printed to "STDERR".
594              
595             DATA_DYNAMICVALIDATOR_DEBUG=1
596              
597             =head1 METHODS
598              
599             =head2 validate
600              
601             Performs validation based on C<on>, C<should>, C<because> and optional C<each>
602             parameters. Returns the validator itself (C<$self>), to allow further C<chain>
603             invocations. The validation will not be performed, if some errors already
604             have been detected.
605              
606             It is recommended to use overloaded function call, instead of this method
607             call. (e.g. C<$validator->(...)> instead of C<$validato->validate(...)> )
608              
609             =head2 report_error
610              
611             The method is used for custom errors reporing. It is mainly usable in C<each>
612             closure.
613              
614             validator({ ports => [1000, 2000, 3000] })->(
615             on => '/ports/port:*',
616             should => sub { @_ > 0 },
617             because => "At least one listening port should be defined",
618             each => sub {
619             my $port;
620             my $port_value = $port->();
621             shift->report_error("Port value $port_value isn't acceptable, because < 1000")
622             if($port_value < 1000);
623             }
624             );
625              
626             =head2 is_valid
627              
628             Checks, whether validator already has errors
629              
630             =head2 errors
631              
632             Returns internal array of errors
633              
634             =head2 rebase
635              
636             Temporaly sets the new base to the specified route, and invokes the closure
637             with the validator instance, i.e.
638              
639             $v->('/a' => $closure->($v))
640              
641             If the data can't be found at the specified route, the C<closure> is not
642             invoked.
643              
644             =head2 current_base
645              
646             Returns the current base, which is set only inside C<rebase> call or C<each> closure.
647             Returns undef is there is no current base.
648              
649             =head1 FUNCTIONS
650              
651             =head2 validator
652              
653             The enter point for DynamicValidator.
654              
655             my $errors = validator(...)->(
656             on => "...",
657             should => sub { ... },
658             because => "...",
659             )->errors;
660              
661             =head1 RESOURCES
662              
663             =over 4
664              
665             =item * Data::DPath
666              
667             L<https://metacpan.org/pod/Data::DPath>
668              
669             =back
670              
671             =head1 AUTHOR
672              
673             Ivan Baidakou <dmol@gmx.com>
674              
675             =head1 COPYRIGHT AND LICENSE
676              
677             This software is copyright (c) 2017 by Ivan Baidakou.
678              
679             This is free software; you can redistribute it and/or modify it under
680             the same terms as the Perl 5 programming language system itself.
681              
682             =cut