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.05';
4 9     9   392292 use strict;
  9         24  
  9         230  
5 9     9   42 use warnings;
  9         24  
  9         197  
6              
7 9     9   52 use Carp;
  9         20  
  9         451  
8 9     9   2184 use Devel::LexAlias qw(lexalias);
  9         22687  
  9         408  
9 9     9   53 use PadWalker qw(peek_sub);
  9         15  
  9         345  
10 9     9   47 use Scalar::Util qw/looks_like_number/;
  9         14  
  9         300  
11 9     9   3282 use Storable qw(dclone);
  9         19770  
  9         459  
12              
13 9     9   2102 use aliased qw/Data::DynamicValidator::Error/;
  9         4375  
  9         53  
14 9     9   685 use aliased qw/Data::DynamicValidator::Filter/;
  9         15  
  9         34  
15 9     9   748 use aliased qw/Data::DynamicValidator::Label/;
  9         15  
  9         35  
16 9     9   594 use aliased qw/Data::DynamicValidator::Path/;
  9         16  
  9         35  
17              
18             use overload
19             fallback => 1,
20             '&{}' => sub {
21 37     37   185 my $self = shift;
22 37     37   96 return sub { $self->validate(@_) }
23 9     9   893 };
  9         16  
  9         42  
  37         114  
24              
25 9     9   508 use parent qw/Exporter/;
  9         15  
  9         42  
26             our @EXPORT_OK = qw/validator/;
27              
28 9   50 9   533 use constant DEBUG => $ENV{DATA_DYNAMICVALIDATOR_DEBUG} || 0;
  9         14  
  9         12194  
29              
30              
31              
32              
33              
34              
35              
36              
37             sub validator {
38 50     50 1 51437 return Data::DynamicValidator->new(@_);
39             }
40              
41             sub new {
42 50     50 0 103 my ($class, $data) = @_;
43 50         131 my $self = {
44             _data => $data,
45             _errors => [],
46             _bases => [],
47             };
48 50         274 return bless $self => $class;
49             }
50              
51              
52             sub validate {
53 41     41 1 128 my ($self, %args) = @_;
54              
55 41         74 my $on = $args{on };
56 41         54 my $should = $args{should };
57 41         52 my $because = $args{because};
58 41         65 my $each = $args{each };
59              
60 41 50 33     204 croak("Wrong arguments: 'on', 'should', 'because' should be specified")
      33        
61             if(!$on || !$should || !$because);
62              
63 41         51 warn "-- validating : $on \n" if DEBUG;
64              
65 41         66 my $errors = $self->{_errors};
66 41         46 my $selection_results;
67 41 50       83 if ( !@$errors ) {
68 41         52 my $success;
69 41         70 my $current_base = $self->current_base;
70 41         83 my $selector = $self->_rebase_selector($on);
71 41         80 ($success, $selection_results) = $self->_apply($selector, $should);
72 41 100       86 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         9 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     121 if ( !@$errors && $each ) {
84 11         14 warn "-- no errors, will check children\n" if DEBUG;
85 11         25 $self->_validate_children($selection_results, $each);
86             }
87              
88 41         176 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         9 push @{ $self->{_errors} }, Error->new($reason, $path);
  7         31  
98             }
99              
100              
101 9     9 1 334 sub is_valid { @{ $_[0]->{_errors} } == 0; }
  9         36  
102              
103              
104              
105 11     11 1 38 sub errors { $_[0]->{_errors} }
106              
107              
108             sub rebase {
109 5     5 1 20 my ($self, $expandable_route, $rule) = @_;
110 5         11 my $current_base = $self->current_base;
111 5         11 my $selector = $self->_rebase_selector($expandable_route);
112 5         16 my $scenario = $self->_select($selector);
113 5         10 my $number_of_routes = @{ $scenario->{routes} };
  5         8  
114 5 50       15 carp "The route '$expandable_route' is ambigious for rebasing (should be unique)"
115             if $number_of_routes > 1;
116              
117 5 100       12 return $self if $number_of_routes == 0;
118              
119 4         6 push @{ $self->{_bases} }, $scenario->{routes}->[0];
  4         9  
120 4         11 $rule->($self);
121 4         20 pop @{ $self->{_bases} };
  4         8  
122 4         17 return $self;
123             }
124              
125              
126             sub current_base {
127 94     94 1 171 my $bases = $_[0]->{_bases};
128 94 100       172 return undef unless @$bases;
129 56         82 return $bases->[-1];
130             }
131              
132             ### private/implementation methods
133              
134             sub _rebase_selector {
135 46     46   68 my ($self, $selector) = @_;
136 46         77 my $current_base = $self->current_base;
137 46   100     101 my $add_base = $current_base && $selector !~ /^\/{2,}/;
138 46 100       107 my $rebased = $add_base ? $current_base . $selector : $selector;
139 46         59 warn "-- Rebasing selector $selector to $rebased \n" if DEBUG;
140 46         86 return $rebased;
141             }
142              
143             sub _validate_children {
144 11     11   18 my ($self, $selection_results, $each) = @_;
145 11         20 my ($routes, $values) = @{$selection_results}{qw/routes values/};
  11         20  
146 11         19 my $errors = $self->{_errors};
147 11         17 my $data = $self->{_data};
148 11         25 for my $i (0 .. @$routes-1) {
149 23         36 my $route = $routes->[$i];
150 23         29 push @{ $self->{_bases} }, $route;
  23         39  
151 23         35 my $value = $values->[$i];
152 23         53 my $label_for = { map { $_ => 1 } ($route->labels) };
  23         50  
153             # prepare context
154 23         102 my $pad = peek_sub($each);
155 23         75 while (my ($var, $ref) = each %$pad) {
156 38         150 my $var_name = substr($var, 1); # chomp sigil
157 38 100       108 next unless exists $label_for->{$var_name};
158 23         59 my $label_obj = Label->new($var_name, $route, $data);
159 23         54 lexalias($each, $var, \$label_obj);
160             }
161             # call
162 23         131 $self->{_current_path} = $route;
163 23         64 $each->($self, local $_ = Label->new('_', $route, $data));
164 23         555 pop @{ $self->{_bases} };
  23         50  
165 23 100       85 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   118 my ($self, $expession) = @_;
184 60         175 my $data = $self->{_data};
185 60         109 my $routes = $self->_expand_routes($expession);
186 60         109 my $values = [ map { $_->value($data) } @$routes ];
  78         167  
187             return {
188 60         178 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         90 warn "-- Expanding routes for $expression\n" if DEBUG;
206             # striping leading slashes
207 80         203 $expression =~ s/\/{2,}/\//;
208 80         220 my @routes = ( Path->new($expression) );
209 80         128 my $result = [];
210 80         170 while (@routes) {
211 187         265 my $route = shift(@routes);
212 187         416 my $current = $self->{_data};
213 187         369 my $elements = $route->components;
214 187         241 my $i;
215 187         230 my $can_be_accessed = 0;
216 187         358 for ($i = 0; $i < @$elements; $i++) {
217 597         697 $can_be_accessed = 0;
218 597         821 my $element = $elements->[$i];
219             # no futher examination if current value is undefined
220 597 100       930 last unless defined($current);
221 591 100       1035 next if($element eq '');
222 404         439 my $filter;
223 404         611 ($element, $filter) = _filter($element);
224 404         648 my $type = ref($current);
225 404         525 my $generator;
226             my $advancer;
227 404 100 66     1516 if ($element eq '*') {
    100 66        
    100 66        
      66        
228 75 100       166 if ($type eq 'HASH') {
    50          
229 44         137 my @keys = keys %$current;
230 44         68 my $idx = 0;
231             $generator = sub {
232 98     98   202 while($idx < @keys) {
233 86         153 my $key = $keys[$idx++];
234 86         353 my $match = $filter->($current->{$key}, {key => $key});
235 86 100       340 return $key if($match);
236             }
237 44         97 return undef;
238 44         134 };
239             } elsif ($type eq 'ARRAY') {
240 31         45 my $idx = 0;
241             $generator = sub {
242 84     84   183 while($idx < @$current) {
243 65         94 my $index = $idx++;
244 65         226 my $match = $filter->($current->[$index], {index => $index});
245 65 100       246 return $index if($match);
246             }
247 31         76 return undef;
248 31         99 };
249             }
250             }elsif ($type eq 'HASH' && exists $current->{$element}) {
251 228     228   506 $advancer = sub { $current->{$element} };
  228         343  
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   228 $advancer = sub { $current->[$element] };
  92         152  
259             }
260 404 100       711 if ($generator) {
261 75         126 while ( defined( my $new_element = $generator->()) ) {
262 107         3783 my $new_path = dclone($route);
263 107         335 $new_path->components->[$i] = $new_element;
264 107         246 push @routes, $new_path;
265             }
266 75         104 $current = undef;
267 75         329 last;
268             }
269 329 100       521 if ($advancer) {
270 320         453 $current = $advancer->();
271 320         404 $can_be_accessed = 1;
272 320         945 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         13 $current = undef;
278 9         28 $can_be_accessed = 0;
279             }
280 187   100     514 my $do_expansion = defined $current
281             || ($can_be_accessed && $i == @$elements);
282 187         230 warn "-- Expanded route : $route \n" if(DEBUG && $do_expansion);
283 187 100       585 push @$result, $route if($do_expansion);
284             }
285 80         269 return [ sort @$result ];
286             }
287              
288             sub _filter {
289 404     404   520 my $element = shift;
290 404         457 my $filter;
291 404         859 my $condition_re = qr/(.+?)(\[(.+)\])/;
292 404         1110 my @parts = $element =~ /$condition_re/;
293 404 100 66     883 if (@parts == 3 && defined($parts[2])) {
294 20         39 $element = $parts[0];
295 20         36 my $condition = $parts[2];
296 20         80 $filter = Filter->new($condition);
297             } else {
298 384     83   921 $filter = sub { 1 }; # always true
  83         128  
299             }
300 404         996 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   106 my ($self, $on, $should) = @_;
313 54         103 my $selection_results = $self->_select($on);
314 54         79 my $values = $selection_results->{values};
315 54   100     215 my $result = $values && @$values && $should->( @$values );
316 54         264 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.05
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