File Coverage

blib/lib/Kwalify.pm
Criterion Covered Total %
statement 205 235 87.2
branch 108 130 83.0
condition 13 15 86.6
subroutine 29 34 85.2
pod 1 1 100.0
total 356 415 85.7


line stmt bran cond sub pod time code
1             # -*- mode: cperl -*-
2              
3             #
4             # Author: Slaven Rezic
5             #
6             # Copyright (C) 2006,2007,2008,2009,2010,2015,2020 Slaven Rezic. All rights reserved.
7             # This package is free software; you can redistribute it and/or
8             # modify it under the same terms as Perl itself.
9             #
10             # Mail: srezic@cpan.org
11             # WWW: http://www.rezic.de/eserte/
12             #
13              
14             package Kwalify;
15              
16 2     2   70051 use strict;
  2         11  
  2         57  
17 2     2   10 use warnings;
  2         4  
  2         56  
18              
19 2     2   9 use base qw(Exporter);
  2         3  
  2         307  
20 2     2   14 use vars qw(@EXPORT_OK $VERSION);
  2         3  
  2         470  
21             @EXPORT_OK = qw(validate);
22              
23             $VERSION = '1.23';
24              
25             sub validate ($$) {
26 60     60 1 36214 my($schema, $data) = @_;
27 60         150 my $self = Kwalify::Validator->new;
28 60         146 $self->validate($schema, $data, "/");
29 47 100       73 if (@{$self->{errors}}) {
  47         110  
30 22         31 die join("\n", map { " - $_" } @{$self->{errors}}) . "\n";
  24         182  
  22         41  
31             } else {
32 25         159 1;
33             }
34             }
35              
36             package Kwalify::Validator;
37              
38 2     2   2568 use overload ();
  2         2101  
  2         471  
39              
40             sub new {
41 60     60   95 my($class) = @_;
42 60         155 bless { errors => [] }, $class;
43             }
44              
45             sub validate {
46 60     60   108 my($self, $schema, $data, $path, $args) = @_;
47 60         124 $self->{done} = {};
48 60         123 $self->_validate($schema, $data, $path, $args);
49             }
50              
51             sub _validate {
52 146     146   297 my($self, $schema, $data, $path, $args) = @_;
53 146         222 $self->{path} = $path;
54              
55 146 100       355 if (!UNIVERSAL::isa($schema, "HASH")) {
56 1         3 $self->_die("Schema structure must be a hash reference");
57             }
58              
59 145         217 my $type = $schema->{type};
60 145 50       229 if (!defined $type) {
61 0         0 $type = 'str'; # default type;
62             }
63 145         236 my $type_check_method = "validate_" . $type;
64 145 100       415 if (!$self->can($type_check_method)) {
65 1         5 $self->_die("Invalid or unimplemented type '$type'");
66             }
67              
68 144         320 $self->$type_check_method($schema, $data, $path, $args);
69             }
70              
71             sub _additional_rules {
72 89     89   155 my($self, $schema, $data, $path) = @_;
73 2     2   15 no warnings 'uninitialized'; # legal undef values may happen everywhere
  2         4  
  2         951  
74 89         234 for my $schema_key (keys %$schema) {
75 167 50       314 if (defined $schema->{$schema_key}) {
76 167 100       846 if ($schema_key eq 'pattern') {
    100          
    100          
    100          
    50          
    100          
77 4         23 (my $pattern = $schema->{pattern}) =~ s{^/(.*)/$}{$1};
78 4 100       58 if ($data !~ qr{$pattern}) {
79 1         24 $self->_error("Non-valid data '$data' does not match /$pattern/");
80             }
81             } elsif ($schema_key eq 'length') {
82 8 100       23 if (!UNIVERSAL::isa($schema->{'length'}, "HASH")) {
83 1         4 $self->_die("'length' must be a hash with keys max and/or min");
84             }
85 7         13 my $length = length($data);
86 7         9 for my $sub_schema_key (keys %{ $schema->{'length'} }) {
  7         20  
87 13 50       36 if ($sub_schema_key eq 'min') {
    100          
    100          
    100          
88 0         0 my $min = $schema->{'length'}->{min};
89 0 0       0 if ($length < $min) {
90 0         0 $self->_error("'$data' is too short (length $length < min $min)");
91             }
92             } elsif ($sub_schema_key eq 'min-ex') {
93 6         19 my $min = $schema->{'length'}->{'min-ex'};
94 6 100       19 if ($length <= $min) {
95 1         5 $self->_error("'$data' is too short (length $length <= min $min)");
96             }
97             } elsif ($sub_schema_key eq 'max') {
98 2         4 my $max = $schema->{'length'}->{max};
99 2 100       7 if ($length > $max) {
100 1         8 $self->_error("'$data' is too long (length $length > max $max)");
101             }
102             } elsif ($sub_schema_key eq 'max-ex') {
103 4         6 my $max = $schema->{'length'}->{'max-ex'};
104 4 100       9 if ($length >= $max) {
105 1         8 $self->_error("'$data' is too long (length $length >= max $max)");
106             }
107             } else {
108 1         4 $self->_die("Unexpected key '$sub_schema_key' in length specification, expected min, max, min-ex and/or max-ex");
109             }
110             }
111             } elsif ($schema_key eq 'enum') {
112 3 100       13 if (!UNIVERSAL::isa($schema->{enum}, 'ARRAY')) {
113 1         3 $self->_die("'enum' must be an array");
114             }
115 2         4 my %valid = map { ($_,1) } @{ $schema->{enum} };
  6         17  
  2         5  
116 2 50       11 if (!exists $valid{$data}) {
117 0         0 $self->_error("'$data': invalid " . _base_path($path) . " value");
118             }
119             } elsif ($schema_key eq 'range') {
120 8 100       24 if (!UNIVERSAL::isa($schema->{range}, "HASH")) {
121 1         3 $self->_die("'range' must be a hash with keys max and/or min");
122             }
123 7         11 my($lt, $le, $gt, $ge);
124             ## yes? no?
125             # if (eval { require Scalar::Util; defined &Scalar::Util::looks_like_number }) {
126             # if (Scalar::Util::looks_like_number($data)) {
127             # $lt = sub { $_[0] < $_[1] };
128             # $gt = sub { $_[0] > $_[1] };
129             # } else {
130             # $lt = sub { $_[0] lt $_[1] };
131             # $gt = sub { $_[0] gt $_[1] };
132             # }
133             # } else {
134             # warn "Cannot determine whether $data is a number, assume so..."; # XXX show only once
135 2     2   15 no warnings 'numeric';
  2         4  
  2         5314  
136 7     0   24 $lt = sub { $_[0] < $_[1] };
  0         0  
137 7     2   18 $gt = sub { $_[0] > $_[1] };
  2         7  
138 7     6   14 $le = sub { $_[0] <= $_[1] };
  6         24  
139 7     4   16 $ge = sub { $_[0] >= $_[1] };
  4         24  
140             # }
141              
142 7         9 for my $sub_schema_key (keys %{ $schema->{range} }) {
  7         20  
143 13 50       43 if ($sub_schema_key eq 'min') {
    100          
    100          
    100          
144 0         0 my $min = $schema->{range}->{min};
145 0 0       0 if ($lt->($data, $min)) {
146 0         0 $self->_error("'$data' is too small (< min $min)");
147             }
148             } elsif ($sub_schema_key eq 'min-ex') {
149 6         44 my $min = $schema->{range}->{'min-ex'};
150 6 100       19 if ($le->($data, $min)) {
151 1         5 $self->_error("'$data' is too small (<= min $min)");
152             }
153             } elsif ($sub_schema_key eq 'max') {
154 2         4 my $max = $schema->{range}->{max};
155 2 100       4 if ($gt->($data, $max)) {
156 1         4 $self->_error("'$data' is too large (> max $max)");
157             }
158             } elsif ($sub_schema_key eq 'max-ex') {
159 4         8 my $max = $schema->{range}->{'max-ex'};
160 4 100       6 if ($ge->($data, $max)) {
161 1         13 $self->_error("'$data' is too large (>= max $max)");
162             }
163             } else {
164 1         5 $self->_die("Unexpected key '$sub_schema_key' in range specification, expected min, max, min-ex and/or max-ex");
165             }
166             }
167             } elsif ($schema_key eq 'assert') {
168 0         0 $self->_die("'assert' is not yet implemented");
169             } elsif ($schema_key !~ m{^(type|required|unique|name|classname|class|desc)$}) {
170 1         5 $self->_die("Unexpected key '$schema_key' in type specification");
171             }
172             }
173             }
174             }
175              
176             sub validate_text {
177 16     16   32 my($self, $schema, $data, $path) = @_;
178 16 100 100     62 if (!defined $data || ref $data) {
179 3 100       15 return $self->_error("Non-valid data '" . (defined $data ? $data : 'undef') . "', expected text");
180             }
181 13         28 $self->_additional_rules($schema, $data, $path);
182             }
183              
184             sub validate_str {
185 53     53   88 my($self, $schema, $data, $path) = @_;
186 53 100 100     261 if (!defined $data || ref $data || $data =~ m{^\d+(\.\d+)?$}) {
      100        
187 3 100       16 return $self->_error("Non-valid data '" . (defined $data ? $data : 'undef') . "', expected a str");
188             }
189 50         88 $self->_additional_rules($schema, $data, $path);
190             }
191              
192             sub validate_int {
193 8     8   18 my($self, $schema, $data, $path) = @_;
194 8 100       39 if ($data !~ m{^[+-]?\d+$}) { # XXX what about scientific notation?
195 2         8 $self->_error("Non-valid data '" . $data . "', expected an int");
196             }
197 8         20 $self->_additional_rules($schema, $data, $path);
198             }
199              
200             sub validate_float {
201 2     2   13 my($self, $schema, $data, $path) = @_;
202 2 100       15 if ($data !~ m{^[+-]?\d+\.\d+$}) { # XXX other values?
203 1         5 $self->_error("Non-valid data '" . $data . "', expected a float");
204             }
205 2         6 $self->_additional_rules($schema, $data, $path);
206             }
207              
208             sub validate_number {
209 2     2   7 my($self, $schema, $data, $path) = @_;
210 2 100       17 if ($data !~ m{^[+-]?\d+(\.\d+)?$}) { # XXX combine int+float regexp!
211 1         5 $self->_error("Non-valid data '" . $data . "', expected a number");
212             }
213 2         7 $self->_additional_rules($schema, $data, $path);
214             }
215              
216             sub validate_bool {
217 7     7   14 my($self, $schema, $data, $path) = @_;
218 7 100       39 if ($data !~ m{^(yes|true|1|no|false|0)$}) { # XXX correct?
219 1         5 $self->_error("Non-valid data '" . $data . "', expected a boolean");
220             }
221 7         18 $self->_additional_rules($schema, $data, $path);
222             }
223              
224             # XXX is this correct?
225             sub validate_scalar {
226 0     0   0 shift->validate_text(@_);
227             }
228              
229             sub validate_date {
230 0     0   0 my($self, $schema, $data, $path) = @_;
231 0 0       0 if ($data !~ m{^\d{4}-\d{2}-\d{2}$}) {
232 0         0 $self->_error("Non-valid data '" . $data . "', expected a date (YYYY-MM-DD)");
233             }
234 0         0 $self->_additional_rules($schema, $data, $path);
235             }
236              
237             sub validate_time {
238 1     1   4 my($self, $schema, $data, $path) = @_;
239 1 50       9 if ($data !~ m{^\d{2}:\d{2}:\d{2}$}) {
240 0         0 $self->_error("Non-valid data '" . $data . "', expected a time (HH:MM:SS)");
241             }
242 1         3 $self->_additional_rules($schema, $data, $path);
243             }
244              
245             sub validate_timestamp {
246 0     0   0 my($self) = @_;
247 0         0 $self->_error("timestamp validation NYI"); # XXX
248             }
249              
250             sub validate_any {
251 6     6   13 my($self, $schema, $data, $path) = @_;
252 6         13 $self->_additional_rules($schema, $data, $path);
253             }
254              
255             sub validate_seq {
256 20     20   37 my($self, $schema, $data, $path) = @_;
257 20 100       39 if (!exists $schema->{sequence}) {
258 1         3 $self->_die("'sequence' missing with 'seq' type");
259             }
260 19         28 my $sequence = $schema->{sequence};
261 19 100       53 if (!UNIVERSAL::isa($sequence, 'ARRAY')) {
262 1         4 $self->_die("Expected array in 'sequence'");
263             }
264 18 100       38 if (@$sequence != 1) {
265 1         3 $self->_die("Expect exactly one element in sequence");
266             }
267 17 100       40 if (!UNIVERSAL::isa($data, 'ARRAY')) {
268 1         6 $self->_error("Non-valid data " . $data . ", expected sequence");
269 1         3 return;
270             }
271              
272 16 50       48 return if ($self->{done}{overload::StrVal($data)}{overload::StrVal($schema)});
273 16         149 $self->{done}{overload::StrVal($data)}{overload::StrVal($schema)} = 1;
274              
275 16         109 my $subschema = $sequence->[0];
276 16         33 my $unique = _get_boolean($subschema->{unique});
277 16         27 my %unique_val;
278             my %unique_mapping_val;
279 16         23 my $index = 0;
280 16         29 for my $elem (@$data) {
281 38         60 my $subpath = _append_path($path, $index);
282 38         169 $self->_validate($subschema, $elem, $subpath, { unique_mapping_val => \%unique_mapping_val});
283 38 100       102 if ($unique) {
284 26 100       50 if (exists $unique_val{$elem}) {
285 2         13 $self->_error("'$elem' is already used at '$unique_val{$elem}'");
286             } else {
287 24         47 $unique_val{$elem} = $subpath;
288             }
289             }
290 38         84 $index++;
291             }
292             }
293              
294             sub validate_map {
295 29     29   56 my($self, $schema, $data, $path, $args) = @_;
296 29         32 my $unique_mapping_val;
297 29 50 66     84 if ($args && $args->{unique_mapping_val}) {
298 12         18 $unique_mapping_val = $args->{unique_mapping_val};
299             }
300 29 100       52 if (!exists $schema->{mapping}) {
301 1         3 $self->_die("'mapping' missing with 'map' type");
302             }
303 28         35 my $mapping = $schema->{mapping};
304 28 100       60 if (!UNIVERSAL::isa($mapping, 'HASH')) {
305 1         3 $self->_die("Expected hash in 'mapping'");
306             }
307 27 100       50 if (!defined $data) {
308 1         4 $self->_error("Undefined data, expected mapping");
309 1         2 return;
310             }
311 26 100       55 if (!UNIVERSAL::isa($data, 'HASH')) {
312 2         8 $self->_error("Non-valid data " . $data . ", expected mapping");
313 2         5 return;
314             }
315              
316 24 50       54 return if ($self->{done}{overload::StrVal($data)}{overload::StrVal($schema)});
317 24         187 $self->{done}{overload::StrVal($data)}{overload::StrVal($schema)} = 1;
318              
319 24         178 my %seen_key;
320             my $default_key_schema;
321              
322             ## Originally this was an each-loop, but this could lead into
323             ## endless recursions, because mapping may be reused in Kwalify,
324             ## thus the each iterator was shared between recursion levels.
325             # while(my($key,$subschema) = each %$mapping) {
326 24         62 for my $key (keys %$mapping) {
327 60         85 my $subschema = $mapping->{$key};
328 60 50       107 if ($key eq '=') { # the "default" key
329 0         0 $default_key_schema = $subschema;
330 0         0 next;
331             }
332 60         110 my $subpath = _append_path($path, $key);
333 60         110 $self->{path} = $subpath;
334 60 50       134 if (!UNIVERSAL::isa($subschema, 'HASH')) {
335 0         0 $self->_die("Expected subschema (a hash)");
336             }
337 60         139 my $required = _get_boolean($subschema->{required});
338 60 100       137 if (!defined $data->{$key}) {
339 12 50       19 if ($required) {
340 0         0 $self->{path} = $path;
341 0         0 $self->_error("Expected required key '$key'");
342 0         0 next;
343             } else {
344 12         20 $seen_key{$key}++;
345 12         23 next;
346             }
347             }
348 48         91 my $unique = _get_boolean($subschema->{unique});
349 48 100       88 if ($unique) {
350 12 100 66     49 if (defined $unique_mapping_val->{$data->{$key}}->{val}
351             && $unique_mapping_val->{$data->{$key}}->{val} eq $data->{$key}) {
352 2         20 $self->_error("'$data->{$key}' is already used at '$unique_mapping_val->{$data->{$key}}->{path}'");
353             } else {
354 10         48 $unique_mapping_val->{$data->{$key}} = { val => $data->{$key},
355             path => $subpath,
356             };
357             }
358             }
359              
360 48         117 $self->_validate($subschema, $data->{$key}, $subpath);
361 48         111 $seen_key{$key}++;
362             }
363              
364             # while(my($key,$val) = each %$data) {
365 24         87 for my $key (keys %$data) {
366 48         75 my $val = $data->{$key};
367 48         74 my $subpath = _append_path($path, $key);
368 48         77 $self->{path} = $subpath;
369 48 50       125 if (!$seen_key{$key}) {
370 0 0       0 if ($default_key_schema) {
371 0         0 $self->_validate($default_key_schema, $val, $subpath);
372             } else {
373 0         0 $self->_error("Unexpected key '$key'");
374             }
375             }
376             }
377             }
378              
379             sub _die {
380 13     13   40 my($self, $msg) = @_;
381 13         31 $msg = "[$self->{path}] $msg";
382 13         88 die $msg."\n";
383             }
384              
385             sub _error {
386 26     26   61 my($self, $msg) = @_;
387 26         65 $msg = "[$self->{path}] $msg";
388 26         33 push @{$self->{errors}}, $msg;
  26         60  
389 26         59 0;
390             }
391              
392             # Functions:
393             sub _append_path {
394 146     146   276 my($root, $leaf) = @_;
395 146 100       516 $root . ($root !~ m{/$} ? "/" : "") . $leaf;
396             }
397              
398             sub _base_path {
399 0     0   0 my($path) = @_;
400 0         0 my($base) = $path =~ m{([^/]+)$};
401 0         0 $base;
402             }
403              
404             sub _get_boolean {
405 124     124   219 my($val) = @_;
406 124 100       369 defined $val && $val =~ m{^(yes|true|1)$}; # XXX check for all boolean trues
407             }
408              
409             1;
410             __END__