File Coverage

lib/Web/DataService/Ruleset.pm
Criterion Covered Total %
statement 64 180 35.5
branch 23 146 15.7
condition 2 23 8.7
subroutine 10 15 66.6
pod 0 8 0.0
total 99 372 26.6


line stmt bran cond sub pod time code
1             #
2             # Web::DataService::Ruleset
3             #
4             # This module provides a role that is used by 'Web::DataService'. It implements
5             # routines for handling rulesets.
6             #
7             # Author: Michael McClennen
8              
9 2     2   14 use strict;
  2         4  
  2         85  
10              
11             package Web::DataService::Ruleset;
12              
13 2     2   12 use Carp qw 'croak';
  2         4  
  2         92  
14 2     2   12 use Try::Tiny;
  2         15  
  2         114  
15 2     2   11 use Scalar::Util 'reftype';
  2         5  
  2         101  
16 2     2   13 use Data::Dumper;
  2         3  
  2         88  
17              
18 2     2   12 use HTTP::Validate qw(:validators);
  2         4  
  2         399  
19              
20 2     2   15 use Moo::Role;
  2         4  
  2         11  
21              
22              
23              
24             our @SPECIAL_ALL = qw(show limit offset count vocab datainfo linebreak header);
25             our @SPECIAL_SINGLE = qw(show vocab datainfo linebreak header);
26              
27              
28             # define_ruleset ( name, rule... )
29             #
30             # Define a ruleset under the given name. This is a wrapper around the
31             # subroutine HTTP::Validate::ruleset, that looks for certain literals and
32             # replaces them with custom ruleset definitions.
33              
34             sub define_ruleset {
35            
36 1     1 0 11 my $ds = shift;
37            
38             # The next argument must be the ruleset name. We restrict these to be
39             # valid names according to the Web::DataService pattern.
40            
41 1         2 my $ruleset_name = shift;
42            
43 1 50       9 croak "define_ruleset: invalid ruleset name '$ruleset_name'\n"
44             unless $ruleset_name =~ qr{ ^ [\w.:][\w.:-]* $ }xs;
45            
46             # If we are in 'diagnostic' mode, then we are generating diagnostics rather than
47             # output. So keep extra attributes and don't send them to HTTP::Validate.
48            
49 1         7 my $diag_mode = $ds->is_mode('diagnostic');
50            
51             # Go through the arguments one by one, to see which ones (if any) need to
52             # be edited before passing them on to HTTP::Validate.
53            
54 1         6 my @rules_and_doc;
55             my $pending_rule;
56 1         0 my @pending_doc;
57 1         0 my @final_doc;
58            
59 1         0 my %exclude_special;
60            
61             ARG:
62 1         3 foreach my $arg (@_)
63             {
64 4 50       11 next unless defined $arg;
65            
66             # Documentation strings that do not start with >> are just added to
67             # the documentation for the current rule (or are added to the
68             # beginning of the documentation if there is no current rule).
69            
70 4 50 66     18 if ( ! ref $arg && $arg !~ qr{^>>}s )
71             {
72 2         3 push @pending_doc, $arg;
73 2         7 next ARG;
74             }
75            
76             # Otherwise, we have a new top-level item (either a rule or a
77             # documentation paragraph. So if there is a pending rule then finish
78             # it now.
79            
80 2 100       17 if ( $pending_rule )
    50          
81             {
82 1 50       5 if ( $ds->check_rule($pending_rule, \@pending_doc) )
83             {
84 1         10 push @rules_and_doc, $pending_rule, @pending_doc, @final_doc;
85             }
86            
87 1         4 $pending_rule = undef;
88             }
89            
90             # If there is just pending documentation, then add it.
91            
92             elsif ( @pending_doc )
93             {
94 0         0 push @rules_and_doc, @pending_doc;
95             }
96            
97             # Then clear the "pending" lists.
98            
99 2         6 @pending_doc = ();
100 2         4 @final_doc = ();
101            
102             # Now, if we have a documentation string starting with ">>", add it.
103            
104 2 50       8 if ( ! ref $arg )
    50          
105             {
106 0         0 push @rules_and_doc, $arg;
107 0         0 next ARG;
108             }
109            
110             # Any reference that is not a hashref should be flagged.
111            
112             elsif ( ref $arg ne 'HASH' )
113             {
114 0         0 croak "define_ruleset: the arguments must be a list of hashrefs and strings\n";
115             }
116            
117             # If we get here, then $arg must be a new rule. If it is a substitution
118             # rule, then move its value to the RS_SUBST hash and otherwise ignore
119             # it. This is a feature implemented by Web::DataService and not by
120             # HTTP::Validate, so we cannot pass this rule to the latter module.
121            
122 2 50       6 if ( $arg->{substitute} )
123             {
124             croak "define_ruleset: the value of 'substitute' must be a hashref\n"
125 0 0       0 unless ref $arg->{substitute} eq 'HASH';
126            
127 0 0       0 croak "define_ruleset: unknown key in 'subtitute' rule\n"
128             if keys %$arg > 1;
129            
130 0         0 $ds->{RS_SUBST}{$ruleset_name} = $arg->{substitute};
131            
132 0         0 next ARG;
133             }
134            
135             # Otherwise, we make this the "pending rule" for subsequent documentation.
136            
137 2         4 $pending_rule = $arg;
138            
139             # We then check for an attribute that specifies the rule type. Rules that
140             # are not parameter rules are simply passed through to HTTP::Validate as-is.
141            
142             my $ruletype = $arg->{optional} ? 'optional'
143             : $arg->{param} ? 'param'
144 2 0       7 : $arg->{mandatory} ? 'mandatory'
    50          
    100          
145             : '';
146            
147 2 50       6 next ARG unless $ruletype;
148            
149             # The value of the rule-type attribute is the parameter name.
150            
151 2         3 my $param = $arg->{$ruletype};
152            
153             # Now look at the value(s) specified by the 'valid' attribute.
154            
155 0         0 my @valid = ref $arg->{valid} eq 'ARRAY' ? @{$arg->{valid}}
156             : defined $arg->{valid} ? $arg->{valid}
157 2 50       9 : ();
    50          
158            
159 2         5 foreach my $v ( @valid )
160             {
161             # If the value is 'FLAG_VALUE' or 'ANY_VALUE', or a code
162             # reference, then pass it through.
163            
164 0 0       0 next if ref $v eq 'CODE';
165 0 0 0     0 next if $v eq 'FLAG_VALUE' || $v eq 'ANY_VALUE';
166            
167             # If it is any other kind of reference, throw an exception.
168            
169 0 0       0 if ( ref $v )
    0          
170             {
171 0         0 croak "define_ruleset: invalid validator $v for parameter '$param'\n"
172             }
173            
174             # If it is the name of a set, then replace it with the
175             # corresponding set validator function. Also add the set's
176             # documentation string to the end of the documentation for this
177             # rule, unless the attribute 'no_set_doc' was also specified.
178            
179             # If we are in diagnostic mode, save the actual set name so it can
180             # be printed out later.
181            
182             elsif ( $ds->set_defined($v) )
183             {
184 0 0       0 $arg->{valid_save} = $arg->{valid} if $diag_mode;
185 0         0 $arg->{valid} = $ds->valid_set($v);
186            
187 0 0       0 unless ( $arg->{no_set_doc} )
188             {
189 0         0 push @final_doc, $ds->document_set($v);
190             }
191             }
192            
193             else
194             {
195 0         0 croak "define_ruleset: unknown set '$v' for parameter '$param'\n";
196             }
197             }
198            
199             # Delete the attribute 'no_set_doc' if it exists, because HTTP::Validate
200             # would reject it.
201            
202 2         4 delete $arg->{no_set_doc};
203            
204             # Now look for special values of the rule type parameter.
205            
206 2 50       29 if ( $arg->{$ruletype} =~ qr{ ^ \s* SPECIAL \( ( \s* \w+ \s* ) \) \s* $ }xs )
    50          
207             {
208 0         0 my $special_arg = $1;
209            
210             # If the special parameter is 'all' or 'single', add rules
211             # for all of the special parameters not yet defined in
212             # this ruleset.
213            
214 0 0 0     0 if ( $special_arg eq 'all' || $special_arg eq 'single' )
    0          
215             {
216             # Exclude the 'show' parameter, since it must be specified
217             # explicitly.
218            
219 0         0 $exclude_special{show} = 1;
220            
221             # Exclude the 'vocab' parameter unless more than one
222             # vocabulary has been defined.
223            
224 0 0       0 unless ( @{$ds->{vocab_list}} > 1 )
  0         0  
225             {
226 0         0 $exclude_special{vocab} = 1;
227             }
228            
229             # Exclude the 'header' parameter unless we have a format
230             # that uses it.
231            
232 0         0 foreach my $f ( @{$ds->{format_list}} )
  0         0  
233             {
234 0 0       0 $exclude_special{header} = 0 if $ds->{format}{$f}{uses_header};
235             }
236            
237 0   0     0 $exclude_special{header} //= 1;
238            
239 0 0       0 my @remaining_params = grep { $ds->{special}{$_} && ! $exclude_special{$_} }
  0 0       0  
240             ($special_arg eq 'all' ? @Web::DataService::SPECIAL_ALL
241             : @Web::DataService::SPECIAL_SINGLE);
242            
243 0         0 foreach my $p ( @remaining_params )
244             {
245 0         0 push @rules_and_doc, $ds->generate_special_rule($p);
246 0         0 push @rules_and_doc, $ds->generate_special_doc($p);
247             }
248            
249 0         0 $pending_rule = undef;
250             }
251            
252             # If the rule is 'show' and that parameter is enabled, then just
253             # replace the parameter name and otherwise leave the rule as it
254             # is. Otherwise, ignore this rule.
255            
256             elsif ( $special_arg eq 'show' )
257             {
258 0 0       0 if ( $ds->{special}{show} )
259             {
260 0         0 $arg->{$ruletype} = $ds->{special}{show};
261 0         0 $arg->{list} = ',';
262 0         0 $arg->{special} = 'show';
263             }
264            
265             else
266             {
267 0         0 $arg->{special} = 'IGNORE';
268             }
269             }
270            
271             # Otherwise, replace the rule with the specially generated one.
272             # Add the standard documentation if none was provided.
273            
274             else
275             {
276             # Make sure that the parameter name is valid.
277            
278             croak "define_ruleset: unknown special parameter '$special_arg'\n" unless
279 0 0       0 defined $Web::DataService::SPECIAL_PARAM{$special_arg};
280            
281             # Ignore this rule if the special parameter is not active.
282            
283 0 0       0 unless ( $ds->{special}{$special_arg} )
284             {
285 0         0 $pending_rule = { special => 'IGNORE' };
286 0         0 next ARG;
287             }
288            
289 0         0 $pending_rule = $ds->generate_special_rule($special_arg);
290 0         0 $pending_rule->{special} = $special_arg;
291            
292             # If the original rule specified any of 'errmsg', 'warn',
293             # 'alias', or 'clean', copy these over to the new rule.
294            
295 0 0       0 $pending_rule->{errmsg} = $arg->{errmsg} if defined $arg->{errmsg};
296 0 0       0 $pending_rule->{warn} = $arg->{warn} if defined $arg->{warn};
297 0 0       0 $pending_rule->{clean} = $arg->{clean} if defined $arg->{clean};
298             $pending_rule->{alias} = $arg->{alias} if defined $arg->{alias} &&
299 0 0 0     0 $arg->{alias} ne $pending_rule->{optional};
300            
301             # Mark that this parameter has already been dealt with, so
302             # that a later rule with 'all' will not include it a second
303             # time.
304            
305 0         0 $exclude_special{$special_arg} = 1;
306             }
307             }
308            
309             # Check for an obviously invalid parameter name.
310            
311             elsif ( $arg->{$ruletype} =~ qr{ SPECIAL | [()] }xs )
312             {
313 0         0 my $arg_value = $arg->{$ruletype};
314 0         0 croak "define_ruleset: syntax error with '$arg_value'\n";
315             }
316            
317             # Otherwise we can just let this rule go through.
318             }
319            
320             # Add the final rule and any pending documentation for it.
321            
322 1 50       3 if ( $pending_rule )
323             {
324 1 50       5 if ( $ds->check_rule($pending_rule, \@pending_doc) )
325             {
326 1         3 push @rules_and_doc, $pending_rule, @pending_doc, @final_doc;
327             }
328             }
329            
330             # If we are in 'diagnostic' mode, then stash a copy of the ruleset
331             # definition where it can be printed out later.
332            
333 1 50       4 if ( $diag_mode )
334             {
335 0         0 my @diag_copy;
336            
337             # Go through each of the entries in the ruleset definition
338            
339 0         0 foreach my $r ( @rules_and_doc )
340             {
341             # If this is a rule definition, then save a copy. Rename the key
342             # 'valid_save' to 'valid' in the copy, and delete 'valid_save'
343             # from the real definition if it exists so that it won't cause
344             # HTTP::Validate to throw an error. This is necessary because a
345             # code reference is not useful in the diagnostic data structure
346             # but the name of the set is.
347            
348 0 0       0 if ( ref $r eq 'HASH' )
349             {
350 0         0 my $copy = { %$r };
351 0 0       0 $copy->{valid} = $copy->{valid_save} if $copy->{valid_save};
352 0         0 delete $copy->{valid_save};
353 0         0 delete $r->{valid_save};
354            
355 0         0 push @diag_copy, $copy;
356             }
357            
358             # Documentation strings are just passed right through.
359            
360             else
361             {
362 0         0 push @diag_copy, $r;
363             }
364             }
365            
366 0         0 $ds->{ruleset_diag}{$ruleset_name} = \@diag_copy;
367             }
368            
369             # Then call HTTP::Validate::define_ruleset. Wrap it in a 'eval' block so
370             # that we can catch any errors and pass them to 'croak'.
371            
372 1         2 my $error_msg;
373            
374 1         2 eval {
375            
376 1         8 $ds->{validator}->define_ruleset($ruleset_name, @rules_and_doc);
377              
378             };
379            
380 1         314 $error_msg = $@;
381 1         3 $error_msg =~ s{ \s at \s (?: \S+ Ruleset.pm ) .* }{}xs;
382            
383 1 50       7 croak "define_ruleset: $error_msg" if $error_msg;
384             }
385              
386              
387             # check_rule ( rr, doc_ref )
388             #
389             # Check and adjust the special rule and its documentation. Return true if
390             # this rule should be included in the ruleset, false otherwise.
391              
392             sub check_rule {
393            
394 2     2 0 5 my ($ds, $rr, $doc_ref) = @_;
395            
396 2 50       8 return 1 unless $rr->{special};
397 0 0       0 return 0 if $rr->{special} eq 'IGNORE';
398            
399             @$doc_ref = $ds->generate_special_doc($rr->{special})
400 0 0       0 unless @$doc_ref;
401            
402 0         0 delete $rr->{special};
403 0         0 return 1;
404             }
405              
406              
407             # generate_special_rule ( param )
408             #
409             # Generate a rule for the given special parameter.
410              
411             sub generate_special_rule {
412            
413 0     0 0 0 my ($ds, $param) = @_;
414            
415             # Double check that this parameter is valid.
416            
417             croak "define_ruleset: the special parameter '$param' is not active\n"
418 0 0       0 unless $ds->{special}{$param};
419            
420             # Start with a basic 'optional' rule.
421            
422 0         0 my $rule = { optional => $ds->{special}{$param} };
423            
424             # If any aliases were defined for this special parameter, enable them as
425             # well.
426            
427             $rule->{alias} = $ds->{special_alias}{$param}
428 0 0       0 if ref $ds->{special_alias}{$param} eq 'ARRAY';
429            
430             # Add the necessary validator and other attributes.
431            
432 0 0 0     0 if ( $param eq 'limit' ) {
    0 0        
    0          
    0          
    0          
433 0         0 $rule->{valid} = [POS_ZERO_VALUE, ENUM_VALUE('all')];
434 0         0 $rule->{errmsg} = "acceptable values for 'limit' are a positive integer, 0, or 'all'",
435             }
436             elsif ( $param eq 'offset' ) {
437 0         0 $rule->{valid} = POS_ZERO_VALUE;
438             }
439             elsif ( $param eq 'count' || $param eq 'datainfo' || $param eq 'header' ) {
440 0         0 $rule->{valid} = FLAG_VALUE;
441             }
442             elsif ( $param eq 'linebreak' ) {
443 0         0 $rule->{valid} = ENUM_VALUE('cr', 'lf', 'crlf');
444             }
445             elsif ( $param eq 'vocab' ) {
446 0         0 $rule->{valid} = $ds->valid_vocab;
447             }
448            
449 0         0 return $rule;
450             }
451              
452              
453             # generate_special_doc ( param )
454             #
455             # Generate the documentation strings for the given special parameter.
456              
457             sub generate_special_doc {
458            
459 0     0 0 0 my ($ds, $param) = @_;
460            
461 0         0 my @doc;
462            
463 0 0       0 if ( $param eq 'selector' )
    0          
    0          
    0          
    0          
    0          
    0          
    0          
    0          
    0          
    0          
    0          
    0          
464             {
465 0         0 push @doc,
466             "Selects from among the available versions of this data service.",
467             "The value may be one of:";
468             }
469            
470             elsif ( $param eq 'format' )
471             {
472 0         0 push @doc,
473             "Specifies the output format. The value may be the name of",
474             "any of the formats available for the selected operation";
475             }
476            
477             elsif ( $param eq 'path' )
478             {
479 0         0 push @doc,
480             "Specifies a data service operation to perform.";
481             }
482            
483             elsif ( $param eq 'document' )
484             {
485 0         0 push @doc,
486             "If a request includes this parameter, then a documentation page is",
487             "returned instead of an operation being executed.";
488             }
489            
490             elsif ( $param eq 'show' )
491             {
492 0         0 push @doc,
493             "Selects additional information to be returned. The value",
494             "of this parameter must be one or more of the following, separated by commas.";
495             }
496            
497             elsif ( $param eq 'limit' )
498             {
499 0         0 push @doc,
500             "Limits the number of records returned.",
501             "The value may be a positive integer, zero, or C.";
502            
503 0         0 my $default = $ds->node_attr('', 'default_limit');
504            
505 0 0 0     0 if ( defined $default && $default > 0 )
506             {
507 0         0 push @doc,
508             "It defaults to $default, in order to prevent people",
509             "from accidentally sending requests that might generate",
510             "extremely large responses. If you really want the",
511             "entire result set, specify C";
512             }
513             }
514            
515             elsif ( $param eq 'offset' )
516             {
517 0         0 push @doc,
518             "Returned records start at this offset in the result set.",
519             "The value may be a positive integer or zero. You can use",
520             "this parameter along with C to return a large",
521             "result set in many smaller chunks.";
522             }
523            
524             elsif ( $param eq 'count' )
525             {
526 0         0 push @doc,
527             "If this parameter has a true value, then the response includes",
528             "a header stating the number of records that match the query",
529             "and the number of records actually returned. To learn how",
530             "this is encoded, see the documentation pages for the various",
531             "output formats.";
532             }
533            
534             elsif ( $param eq 'datainfo' )
535             {
536 0         0 my @extras;
537 0         0 my $info = $ds->data_info;
538            
539             push @extras, "=item *", "The name of the data provider"
540 0 0       0 if $info->{data_provider};
541             push @extras, "=item *", "The name of the data source"
542 0 0       0 if $info->{data_source};
543             push @extras, "=item *", "The license under which it is provided",
544 0 0       0 if $info->{data_license};
545            
546 0         0 push @doc,
547             "If this parameter is has a true value, then the response will",
548             "include header lines with a variety of information including:",
549             "=over",
550             @extras,
551             "=item *", "The date and time at which the data was accessed",
552             "=item *", "The URL and parameters used to generate this result set",
553             "=back",
554             "This is particularly useful for responses that will be saved to",
555             "disk for later analysis and use. This extra information will",
556             "serve to document the criteria by which data are included in the",
557             "result set and the time at which the result was generated, and",
558             "will contain a URL which can be used to re-run the query at a",
559             "later time. For more information about how this information is",
560             "encoded, see the documentation pages for the various output formats.";
561             }
562            
563             elsif ( $param eq 'vocab' )
564             {
565 0         0 push @doc,
566             "Selects the vocabulary used to name the fields in the response.",
567             "You only need to use this if you want to override the default",
568             "vocabulary for your selected format.",
569             "Possible values depend upon the particular URL path, and include:",
570             $ds->document_vocabs('/', { valid => 1 });
571             }
572            
573             elsif ( $param eq 'header' )
574             {
575 0         0 push @doc,
576             "This parameter is only relevant for text format responses. If",
577             "it has a true value, then the data records are preceded by a",
578             "header line giving the field names. If it has a false value,",
579             "this line will be omitted. See the documentation pages for",
580             "the various output formats regarding the default behavior if",
581             "this parameter is omitted.";
582             }
583            
584             elsif ( $param eq 'linebreak' )
585             {
586 0         0 push @doc,
587             "Specifies the character sequence used to terminate each line.",
588             "The value may be either 'cr' or 'crlf', and defaults to the",
589             "latter.";
590             }
591            
592             elsif ( $param eq 'save' )
593             {
594 0         0 push @doc,
595             "Specifies the name of a local file to which the output of this",
596             "request should be saved. Whether and how this happens",
597             "depends upon which web browser you are using. You can specify",
598             "C instead if you wish to display the result in the browser";
599 0 0       0 push @doc,
600             "If you include this parameter without any value, a default",
601             "filename will be provided."
602             if $ds->node_attr('/', 'default_save_filename');
603             }
604            
605 0         0 return @doc;
606             }
607              
608              
609             sub ruleset_defined {
610            
611 1     1 0 8 my ($ds, $rs_name) = @_;
612            
613 1         5 return $ds->{validator}->ruleset_defined($rs_name);
614             }
615              
616              
617             sub list_ruleset_params {
618            
619 0     0 0   my ($ds, $rs_name) = @_;
620            
621 0           return $ds->{validator}->list_params($rs_name);
622             }
623              
624              
625             sub document_ruleset {
626            
627 0     0 0   my ($ds, $rs_name) = @_;
628            
629 0           my $doc = $ds->validator->document_params($rs_name);
630            
631 0 0         if ( ref $ds->{RS_SUBST}{$rs_name} eq 'HASH' )
632             {
633 0           $ds->do_doc_substitution(\$doc, $rs_name);
634             }
635            
636 0           return $doc;
637             }
638              
639              
640             sub do_doc_substitution {
641            
642 0     0 0   my ($ds, $doc_ref, $rs_name) = @_;
643            
644 0           my $subst = $ds->{RS_SUBST}{$rs_name};
645            
646 0           my $pattern = join('|', keys %$subst);
647            
648 0           $$doc_ref =~ s{($pattern)}{$subst->{$1}}egm;
  0            
649             }
650              
651              
652             1;