File Coverage

blib/lib/Kwalify.pm
Criterion Covered Total %
statement 229 235 97.4
branch 124 130 95.3
condition 13 15 86.6
subroutine 32 34 94.1
pod 1 1 100.0
total 399 415 96.1


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,2024 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: https://github.com/eserte/p5-kwalify/
12             #
13              
14             package Kwalify;
15              
16 14     14   167344 use strict;
  14         26  
  14         541  
17 14     14   76 use warnings;
  14         40  
  14         916  
18              
19 14     14   78 use base qw(Exporter);
  14         21  
  14         2366  
20 14     14   108 use vars qw(@EXPORT_OK $VERSION);
  14         25  
  14         3886  
21             @EXPORT_OK = qw(validate);
22              
23             $VERSION = '1.24';
24              
25             sub validate ($$) {
26 86     86 1 293109 my($schema, $data) = @_;
27 86         398 my $self = Kwalify::Validator->new;
28 86         321 $self->validate($schema, $data, "/");
29 73 100       141 if (@{$self->{errors}}) {
  73         227  
30 33         60 die join("\n", map { " - $_" } @{$self->{errors}}) . "\n";
  86         732  
  33         88  
31             } else {
32 40         561 1;
33             }
34             }
35              
36             package Kwalify::Validator;
37              
38 14     14   10555 use overload ();
  14         29448  
  14         5409  
39              
40             sub new {
41 86     86   189 my($class) = @_;
42 86         381 bless { errors => [] }, $class;
43             }
44              
45             sub validate {
46 86     86   225 my($self, $schema, $data, $path, $args) = @_;
47 86         270 $self->{done} = {};
48 86         260 $self->_validate($schema, $data, $path, $args);
49             }
50              
51             sub _validate {
52 445     445   1585 my($self, $schema, $data, $path, $args) = @_;
53 445         922 $self->{path} = $path;
54              
55 445 100       1387 if (!UNIVERSAL::isa($schema, "HASH")) {
56 1         5 $self->_die("Schema structure must be a hash reference");
57             }
58              
59 444         1222 my $type = $schema->{type};
60 444 100       902 if (!defined $type) {
61 3         5 $type = 'str'; # default type;
62             }
63 444         763 my $type_check_method = "validate_" . $type;
64 444 100       1688 if (!$self->can($type_check_method)) {
65 1         6 $self->_die("Invalid or unimplemented type '$type'");
66             }
67              
68 443         1290 $self->$type_check_method($schema, $data, $path, $args);
69             }
70              
71             sub _additional_rules {
72 296     296   668 my($self, $schema, $data, $path) = @_;
73 14     14   150 no warnings 'uninitialized'; # legal undef values may happen everywhere
  14         29  
  14         9027  
74 296         821 for my $schema_key (keys %$schema) {
75 559 50       1395 if (defined $schema->{$schema_key}) {
76 559 100       3543 if ($schema_key eq 'pattern') {
    100          
    100          
    100          
    50          
    100          
77 30         250 (my $pattern = $schema->{pattern}) =~ s{^/(.*)/$}{$1};
78 30 100       603 if ($data !~ qr{$pattern}) {
79 7         68 $self->_error("Non-valid data '$data' does not match /$pattern/");
80             }
81             } elsif ($schema_key eq 'length') {
82 20 100       93 if (!UNIVERSAL::isa($schema->{'length'}, "HASH")) {
83 1         5 $self->_die("'length' must be a hash with keys max and/or min");
84             }
85 19         39 my $length = length($data);
86 19         37 for my $sub_schema_key (keys %{ $schema->{'length'} }) {
  19         143  
87 37 100       173 if ($sub_schema_key eq 'min') {
    100          
    100          
    100          
88 12         44 my $min = $schema->{'length'}->{min};
89 12 100       97 if ($length < $min) {
90 5         38 $self->_error("'$data' is too short (length $length < min $min)");
91             }
92             } elsif ($sub_schema_key eq 'min-ex') {
93 6         14 my $min = $schema->{'length'}->{'min-ex'};
94 6 100       28 if ($length <= $min) {
95 1         7 $self->_error("'$data' is too short (length $length <= min $min)");
96             }
97             } elsif ($sub_schema_key eq 'max') {
98 14         40 my $max = $schema->{'length'}->{max};
99 14 100       99 if ($length > $max) {
100 1         7 $self->_error("'$data' is too long (length $length > max $max)");
101             }
102             } elsif ($sub_schema_key eq 'max-ex') {
103 4         9 my $max = $schema->{'length'}->{'max-ex'};
104 4 100       11 if ($length >= $max) {
105 1         6 $self->_error("'$data' is too long (length $length >= max $max)");
106             }
107             } else {
108 1         5 $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 37 100       139 if (!UNIVERSAL::isa($schema->{enum}, 'ARRAY')) {
113 1         4 $self->_die("'enum' must be an array");
114             }
115 36         62 my %valid = map { ($_,1) } @{ $schema->{enum} };
  122         352  
  36         154  
116 36 100       188 if (!exists $valid{$data}) {
117 5         23 $self->_error("'$data': invalid " . _base_path($path) . " value");
118             }
119             } elsif ($schema_key eq 'range') {
120 32 100       149 if (!UNIVERSAL::isa($schema->{range}, "HASH")) {
121 1         5 $self->_die("'range' must be a hash with keys max and/or min");
122             }
123 31         63 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 14     14   136 no warnings 'numeric';
  14         26  
  14         46434  
136 31     24   157 $lt = sub { $_[0] < $_[1] };
  24         129  
137 31     26   110 $gt = sub { $_[0] > $_[1] };
  26         191  
138 31     6   146 $le = sub { $_[0] <= $_[1] };
  6         40  
139 31     4   213 $ge = sub { $_[0] >= $_[1] };
  4         14  
140             # }
141              
142 31         55 for my $sub_schema_key (keys %{ $schema->{range} }) {
  31         120  
143 61 100       181 if ($sub_schema_key eq 'min') {
    100          
    100          
    100          
144 24         91 my $min = $schema->{range}->{min};
145 24 100       80 if ($lt->($data, $min)) {
146 10         52 $self->_error("'$data' is too small (< min $min)");
147             }
148             } elsif ($sub_schema_key eq 'min-ex') {
149 6         13 my $min = $schema->{range}->{'min-ex'};
150 6 100       13 if ($le->($data, $min)) {
151 1         6 $self->_error("'$data' is too small (<= min $min)");
152             }
153             } elsif ($sub_schema_key eq 'max') {
154 26         68 my $max = $schema->{range}->{max};
155 26 100       97 if ($gt->($data, $max)) {
156 1         6 $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       11 if ($ge->($data, $max)) {
161 1         7 $self->_error("'$data' is too large (>= max $max)");
162             }
163             } else {
164 1         6 $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         6 $self->_die("Unexpected key '$schema_key' in type specification");
171             }
172             }
173             }
174             }
175              
176             sub validate_text {
177 28     28   84 my($self, $schema, $data, $path) = @_;
178 28 100 100     187 if (!defined $data || ref $data) {
179 3 100       20 return $self->_error("Non-valid data '" . (defined $data ? $data : 'undef') . "', expected text");
180             }
181 25         75 $self->_additional_rules($schema, $data, $path);
182             }
183              
184             sub validate_str {
185 193     193   455 my($self, $schema, $data, $path) = @_;
186 193 100 100     1309 if (!defined $data || ref $data || $data =~ m{^\d+(\.\d+)?$}) {
      100        
187 4 100       34 return $self->_error("Non-valid data '" . (defined $data ? $data : 'undef') . "', expected a str");
188             }
189 189         579 $self->_additional_rules($schema, $data, $path);
190             }
191              
192             sub validate_int {
193 38     38   139 my($self, $schema, $data, $path) = @_;
194 38 100       196 if ($data !~ m{^[+-]?\d+$}) { # XXX what about scientific notation?
195 9         37 $self->_error("Non-valid data '" . $data . "', expected an int");
196             }
197 38         170 $self->_additional_rules($schema, $data, $path);
198             }
199              
200             sub validate_float {
201 2     2   8 my($self, $schema, $data, $path) = @_;
202 2 100       19 if ($data !~ m{^[+-]?\d+\.\d+$}) { # XXX other values?
203 1         6 $self->_error("Non-valid data '" . $data . "', expected a float");
204             }
205 2         8 $self->_additional_rules($schema, $data, $path);
206             }
207              
208             sub validate_number {
209 2     2   7 my($self, $schema, $data, $path) = @_;
210 2 100       23 if ($data !~ m{^[+-]?\d+(\.\d+)?$}) { # XXX combine int+float regexp!
211 1         14 $self->_error("Non-valid data '" . $data . "', expected a number");
212             }
213 2         8 $self->_additional_rules($schema, $data, $path);
214             }
215              
216             sub validate_bool {
217 7     7   17 my($self, $schema, $data, $path) = @_;
218 7 100       44 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         22 $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 26     26   90 my($self, $schema, $data, $path) = @_;
231 26 100       172 if ($data !~ m{^\d{4}-\d{2}-\d{2}$}) {
232 6         24 $self->_error("Non-valid data '" . $data . "', expected a date (YYYY-MM-DD)");
233             }
234 26         163 $self->_additional_rules($schema, $data, $path);
235             }
236              
237             sub validate_time {
238 1     1   18 my($self, $schema, $data, $path) = @_;
239 1 50       11 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         5 $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   15 my($self, $schema, $data, $path) = @_;
252 6         18 $self->_additional_rules($schema, $data, $path);
253             }
254              
255             sub validate_seq {
256 48     48   123 my($self, $schema, $data, $path) = @_;
257 48 100       244 if (!exists $schema->{sequence}) {
258 1         5 $self->_die("'sequence' missing with 'seq' type");
259             }
260 47         89 my $sequence = $schema->{sequence};
261 47 100       168 if (!UNIVERSAL::isa($sequence, 'ARRAY')) {
262 1         4 $self->_die("Expected array in 'sequence'");
263             }
264 46 100       140 if (@$sequence != 1) {
265 1         4 $self->_die("Expect exactly one element in sequence");
266             }
267 45 100       138 if (!UNIVERSAL::isa($data, 'ARRAY')) {
268 1         6 $self->_error("Non-valid data " . $data . ", expected sequence");
269 1         4 return;
270             }
271              
272 44 50       171 return if ($self->{done}{overload::StrVal($data)}{overload::StrVal($schema)});
273 44         549 $self->{done}{overload::StrVal($data)}{overload::StrVal($schema)} = 1;
274              
275 44         357 my $subschema = $sequence->[0];
276 44         211 my $unique = _get_boolean($subschema->{unique});
277 44         3901 my %unique_val;
278             my %unique_mapping_val;
279 44         1143 my $index = 0;
280 44         110 for my $elem (@$data) {
281 104         1434 my $subpath = _append_path($path, $index);
282 104         554 $self->_validate($subschema, $elem, $subpath, { unique_mapping_val => \%unique_mapping_val});
283 104 100       379 if ($unique) {
284 39 100       91 if (exists $unique_val{$elem}) {
285 3         18 $self->_error("'$elem' is already used at '$unique_val{$elem}'");
286             } else {
287 36         92 $unique_val{$elem} = $subpath;
288             }
289             }
290 104         339 $index++;
291             }
292             }
293              
294             sub validate_map {
295 92     92   241 my($self, $schema, $data, $path, $args) = @_;
296 92         199 my $unique_mapping_val;
297 92 50 66     410 if ($args && $args->{unique_mapping_val}) {
298 56         137 $unique_mapping_val = $args->{unique_mapping_val};
299             }
300 92 100       240 if (!exists $schema->{mapping}) {
301 1         4 $self->_die("'mapping' missing with 'map' type");
302             }
303 91         154 my $mapping = $schema->{mapping};
304 91 100       344 if (!UNIVERSAL::isa($mapping, 'HASH')) {
305 1         4 $self->_die("Expected hash in 'mapping'");
306             }
307 90 100       238 if (!defined $data) {
308 1         5 $self->_error("Undefined data, expected mapping");
309 1         3 return;
310             }
311 89 100       226 if (!UNIVERSAL::isa($data, 'HASH')) {
312 2         12 $self->_error("Non-valid data " . $data . ", expected mapping");
313 2         6 return;
314             }
315              
316 87 100       347 return if ($self->{done}{overload::StrVal($data)}{overload::StrVal($schema)});
317 86         921 $self->{done}{overload::StrVal($data)}{overload::StrVal($schema)} = 1;
318              
319 86         576 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 86         294 for my $key (keys %$mapping) {
327 320         591 my $subschema = $mapping->{$key};
328 320 100       684 if ($key eq '=') { # the "default" key
329 4         7 $default_key_schema = $subschema;
330 4         11 next;
331             }
332 316         1079 my $subpath = _append_path($path, $key);
333 316         665 $self->{path} = $subpath;
334 316 50       845 if (!UNIVERSAL::isa($subschema, 'HASH')) {
335 0         0 $self->_die("Expected subschema (a hash)");
336             }
337 316         931 my $required = _get_boolean($subschema->{required});
338 316 100       1028 if (!defined $data->{$key}) {
339 70 100       148 if ($required) {
340 6         14 $self->{path} = $path;
341 6         29 $self->_error("Expected required key '$key'");
342 6         18 next;
343             } else {
344 64         159 $seen_key{$key}++;
345 64         155 next;
346             }
347             }
348 246         622 my $unique = _get_boolean($subschema->{unique});
349 246 100       663 if ($unique) {
350 22 100 66     113 if (defined $unique_mapping_val->{$data->{$key}}->{val}
351             && $unique_mapping_val->{$data->{$key}}->{val} eq $data->{$key}) {
352 4         51 $self->_error("'$data->{$key}' is already used at '$unique_mapping_val->{$data->{$key}}->{path}'");
353             } else {
354 18         110 $unique_mapping_val->{$data->{$key}} = { val => $data->{$key},
355             path => $subpath,
356             };
357             }
358             }
359              
360 246         883 $self->_validate($subschema, $data->{$key}, $subpath);
361 246         935 $seen_key{$key}++;
362             }
363              
364             # while(my($key,$val) = each %$data) {
365 86         317 for my $key (keys %$data) {
366 268         479 my $val = $data->{$key};
367 268         618 my $subpath = _append_path($path, $key);
368 268         496 $self->{path} = $subpath;
369 268 100       809 if (!$seen_key{$key}) {
370 22 100       71 if ($default_key_schema) {
371 9         25 $self->_validate($default_key_schema, $val, $subpath);
372             } else {
373 13         43 $self->_error("Unexpected key '$key'");
374             }
375             }
376             }
377             }
378              
379             sub _die {
380 13     13   31 my($self, $msg) = @_;
381 13         30 $msg = "[$self->{path}] $msg";
382 13         149 die $msg."\n";
383             }
384              
385             sub _error {
386 88     88   223 my($self, $msg) = @_;
387 88         329 $msg = "[$self->{path}] $msg";
388 88         185 push @{$self->{errors}}, $msg;
  88         306  
389 88         331 0;
390             }
391              
392             # Functions:
393             sub _append_path {
394 688     688   1637 my($root, $leaf) = @_;
395 688 100       2884 $root . ($root !~ m{/$} ? "/" : "") . $leaf;
396             }
397              
398             sub _base_path {
399 5     5   20 my($path) = @_;
400 5         35 my($base) = $path =~ m{([^/]+)$};
401 5         39 $base;
402             }
403              
404             sub _get_boolean {
405 606     606   1437 my($val) = @_;
406 606 100       4139 defined $val && $val =~ m{^(yes|true|1)$}; # XXX check for all boolean trues
407             }
408              
409             1;
410             __END__