File Coverage

blib/lib/HTTP/Request/Generator.pm
Criterion Covered Total %
statement 190 291 65.2
branch 46 66 69.7
condition 17 20 85.0
subroutine 22 25 88.0
pod 5 8 62.5
total 280 410 68.2


line stmt bran cond sub pod time code
1             package HTTP::Request::Generator;
2 6     6   79211 use strict;
  6         46  
  6         244  
3 6     6   2971 use Filter::signatures;
  6         162563  
  6         48  
4 6     6   256 use feature 'signatures';
  6         50  
  6         194  
5 6     6   35 no warnings 'experimental::signatures';
  6         12  
  6         190  
6 6     6   3832 use Algorithm::Loops 'NestedLoops';
  6         14231  
  6         475  
7 6     6   5430 use List::MoreUtils 'zip';
  6         82919  
  6         41  
8 6     6   9855 use URI;
  6         28638  
  6         201  
9 6     6   42 use URI::Escape;
  6         14  
  6         539  
10 6     6   44 use Exporter 'import';
  6         13  
  6         165  
11 6     6   30 use Carp 'croak';
  6         26  
  6         22476  
12              
13             =head1 NAME
14              
15             HTTP::Request::Generator - generate HTTP requests
16              
17             =head1 SYNOPSIS
18              
19             use HTTP::Request::Generator 'generate_requests';
20              
21             @requests = generate_requests(
22             method => 'GET',
23             pattern => 'https://example.com/{bar,foo,gallery}/[00..99].html',
24             );
25              
26             # generates 300 requests from
27             # https://example.com/bar/00.html to
28             # https://example.com/gallery/99.html
29              
30             @requests = generate_requests(
31             method => 'POST',
32             host => ['example.com','www.example.com'],
33             path => '/profiles/:name',
34             url_params => {
35             name => ['Corion','Co-Rion'],
36             },
37             query_params => {
38             stars => [2,3],
39             },
40             body_params => {
41             comment => ['Some comment', 'Another comment, A++'],
42             },
43             headers => [
44             {
45             "Content-Type" => 'text/plain; encoding=UTF-8',
46             Cookie => 'my_session_id',
47             },
48             {
49             "Content-Type" => 'text/plain; encoding=Latin-1',
50             Cookie => 'my_session_id',
51             },
52             ],
53             );
54             # Generates 32 requests out of the combinations
55              
56             for my $req (@requests) {
57             $ua->request( $req );
58             };
59              
60             =cut
61              
62             our $VERSION = '0.09';
63             our @EXPORT_OK = qw( generate_requests as_dancer as_plack as_http_request
64             expand_curl_pattern
65             );
66              
67 150     150 0 208 sub unwrap($item,$default) {
  150         239  
  150         194  
  150         202  
68 150 100       398 defined $item
    100          
69             ? (ref $item ? $item : [$item])
70             : $default
71             }
72              
73 24     24 0 53 sub fetch_all( $iterator, $limit=0 ) {
  24         36  
  24         51  
  24         40  
74 24         32 my @res;
75 24         54 while( my @r = $iterator->()) {
76 186         417 push @res, @r;
77 186 100 100     531 if( $limit && (@res > $limit )) {
78 1         3 splice @res, $limit;
79             last
80 1         5 };
81             };
82             return @res
83 24         902 };
84              
85             our %defaults = (
86             method => ['GET'],
87             path => ['/'],
88             host => [''],
89             port => [0],
90             scheme => ['http'],
91              
92             # How can we specify various values for the headers?
93             headers => [{}],
94              
95             #query_params => [],
96             #body_params => [],
97             #url_params => [],
98             #values => [[]], # the list over which to iterate for *_params
99             );
100              
101             # We want to skip a set of values if they make a test fail
102             # if a value appears anywhere with a failing test, skip it elsewhere
103             # or look at the history to see whether that value has passing tests somewhere
104             # and then keep it?!
105              
106 70528     70528 0 92288 sub fill_url( $url, $values, $raw=undef ) {
  70528         100287  
  70528         89816  
  70528         94982  
  70528         88849  
107 70528 50       122673 if( $values ) {
108 70528 100       111760 if( $raw ) {
109 70480 100       158331 $url =~ s!:(\w+)!exists $values->{$1} ? $values->{$1} : ":$1"!ge;
  52793         193636  
110             } else {
111 48 50       122 $url =~ s!:(\w+)!exists $values->{$1} ? uri_escape($values->{$1}) : ":$1"!ge;
  24         271  
112             };
113             };
114 70528         183594 $url
115             };
116              
117             # Convert nonref arguments to arrayrefs
118             sub _makeref {
119             map {
120 100 100   100   178 ref $_ ne 'ARRAY' ? [$_] : $_
  183         436  
121             } @_
122             }
123              
124 15     15   26 sub _extract_enum( $name, $item ) {
  15         23  
  15         24  
  15         24  
125             # Explicitly enumerate all ranges
126 15 50       27 my @res = @{ $defaults{ $name } || []};
  15         83  
127              
128 15 100       47 if( $item ) {
129             # Expand all ranges into the enumerated lists
130 1         21 $item =~ s!\[([^.\]]+?)\.\.([^.\]]+?)\]!"{" . join(",", $1..$2 )."}"!ge;
  0         0  
131              
132             # Explode all enumerated items into their list
133             # We should punt this into a(nother) iterator, maybe?!
134 1 50       14 if( $item =~ /\{.*\}/ ) {
135 0         0 my $changed = 1;
136 0         0 @res = $item;
137 0         0 while ($changed) {
138 0         0 undef $changed;
139             @res = map {
140 0         0 my $i = $_;
  0         0  
141 0         0 my @r;
142 0 0       0 if( $i =~ /^([^{]*)\{([^}]+)\}([^{]*)/ ) {
143 0         0 my($pre, $m, $post) = ($1,$2,$3);
144 0         0 $changed = 1;
145 0         0 @r = map { "$pre$_$post" } split /,/, $m, -1;
  0         0  
146             } else {
147 0         0 @r = $i
148             };
149             @r
150 0         0 } @res;
151             }
152             } else {
153 1         4 @res = $item;
154             };
155             };
156             return \@res
157 15         37 }
158              
159 15     15   26 sub _extract_enum_query( $query ) {
  15         27  
  15         24  
160 15         39 my $items = _extract_enum( 'query', $query );
161 15         26 my %parameters;
162 15         41 for my $q (@$items) {
163 1         5 my $u = URI->new('example.com', 'http');
164 1         61 $u->query($q);
165 1         23 my %f = $u->query_form;
166 1         73 for my $k (keys %f) {
167 2         10 $parameters{ $k }->{ $f{ $k }} = 1;
168             };
169             };
170              
171 15         49 for my $v (values %parameters) {
172 2         8 $v = [ sort keys %$v ];
173             };
174              
175 15         92 \%parameters
176             }
177              
178              
179             =head2 C<< expand_curl_pattern >>
180              
181             my %res = expand_curl_pattern( 'https://' );
182             #
183              
184             Expands a curl-style pattern to a pattern using positional placeholders.
185             See the C documentation on the patterns.
186              
187             =cut
188              
189 15     15 1 29 sub expand_curl_pattern( $pattern ) {
  15         31  
  15         23  
190 15         26 my %ranges;
191              
192             # Split up the URL pattern into a scheme, host(pattern), port number and
193             # path (pattern)
194             #use Regexp::Debugger;
195             #use re 'debug';
196 15         195 my( $scheme, $host, $port, $path, $query )
197             = $pattern =~ m!^(?:([^:]+):)? # scheme
198             /?/? # optional? slashes
199             ( # hostname
200             \[(?:[:\da-fA-F]+)\] # ipv6
201             |[^/:\[]+
202             (?:\[[^/\]]+\][^/:\[]*)*
203             (?=[:/]|$) # plain, or expansion
204             |\[[^:\]]+\][^/:]* # expansion
205             )
206             (?::(\d+))? # optional port
207             ([^?]*) # path
208             (?:\?(.*))? # optional query part
209             $!x;
210             #my( $scheme, $host, $port, $path, $query )
211             # = $pattern =~ m!^(?:([^:]+):)?/?/?(\[(?:[:\da-fA-F]+)\]|[^/:\[]+(?:\[[^/\]]+\][^/:\[]*)*(?=[:/]|$)|\[[^:\]]+\][^/:]*)(?::(\d+))?([^?]*)(?:\?(.*))?$!;
212              
213             #no Regexp::Debugger;
214              
215             # Explicitly enumerate all ranges
216 15         40 my $idx = 0;
217              
218 15 100       40 if( $scheme ) {
219 14         32 $scheme =~ s!\[([^.\]]+?)\.\.([^.\[]+?)\]!$ranges{$idx} = [$1..$2]; ":".$idx++!ge;
  0         0  
  0         0  
220 14         41 $scheme =~ s!\{([^\}]*)\}!$ranges{$idx} = [split /,/, $1, -1]; ":".$idx++!ge;
  2         15  
  2         11  
221             };
222              
223 15 50       42 if( $host ) {
224 15         51 $host =~ s!\[([^.\]]+?)\.\.([^.\[]+?)\]!$ranges{$idx} = [$1..$2]; ":".$idx++!ge;
  6         65  
  6         35  
225 15         40 $host =~ s!\{([^\}]*)\}!$ranges{$idx} = [split /,/, $1, -1]; ":".$idx++!ge;
  1         44  
  1         6  
226             };
227              
228 15 100       42 if( $port ) {
229 4         9 $port =~ s!\[([^.\]]+?)\.\.([^.\[]+?)\]!$ranges{$idx} = [$1..$2]; ":".$idx++!ge;
  0         0  
  0         0  
230 4         8 $port =~ s!\{([^\}]*)\}!$ranges{$idx} = [split /,/, $1, -1]; ":".$idx++!ge;
  0         0  
  0         0  
231             };
232              
233 15 100       39 if( $path ) {
234 11         52 $path =~ s!\[([^.\]]+?)\.\.([^.\[]+?)\]!$ranges{$idx} = [$1..$2]; ":".$idx++!ge;
  7         117  
  7         36  
235             # Move all explicitly enumerated parts into lists:
236 11         48 $path =~ s!\{([^\}]*)\}!$ranges{$idx} = [split /,/, $1, -1]; ":".$idx++!ge;
  4         27  
  4         21  
237             };
238              
239 15         62 my %res = (
240             url_params => \%ranges,
241             host => $host,
242             scheme => $scheme,
243             port => $port,
244             path => $path,
245             query_params => _extract_enum_query( $query ),
246             raw_params => 1,
247             );
248 15         123 %res
249             }
250              
251 25     25   51 sub _generate_requests_iter(%options) {
  25         56  
  25         35  
252 25 100 50 17762   200 my $wrapper = delete $options{ wrap } || sub { wantarray ? @_ : $_[0]};
  17762         60336  
253 25         190 my @keys = sort keys %defaults;
254              
255 25 100       94 if( my $pattern = delete $options{ pattern }) {
256 15         64 %options = (%options, expand_curl_pattern( $pattern ));
257             };
258              
259 25   100     102 my $query_params = $options{ query_params } || {};
260 25   100     94 my $body_params = $options{ body_params } || {};
261 25   100     76 my $url_params = $options{ url_params } || {};
262              
263             $options{ "fixed_$_" } ||= {}
264 25   50     387 for @keys;
265              
266             # Now only iterate over the non-empty lists
267 25         68 my %args = map { my @v = unwrap($options{ $_ }, [@{$defaults{ $_ }}]);
  150         283  
  150         389  
268 150 50       468 @v ? ($_ => @v) : () }
269             @keys;
270 25         148 @keys = sort keys %args; # somewhat predictable
271             $args{ $_ } ||= {}
272 25   50     226 for qw(query_params body_params url_params);
273 25         99 my @loops = _makeref @args{ @keys };
274             #use Data::Dumper; warn Dumper \@loops, \@keys, \%options;
275              
276             # Turn all query_params into additional loops for each entry in keys %$query_params
277             # Turn all body_params into additional loops over keys %$body_params
278 25         65 my @query_params = keys %$query_params;
279 25         67 push @loops, _makeref values %$query_params;
280 25         53 my @body_params = keys %$body_params;
281 25         69 push @loops, _makeref values %$body_params;
282 25         60 my @url_params = keys %$url_params;
283 25         53 push @loops, _makeref values %$url_params;
284              
285             #use Data::Dumper; warn "Looping over " . Dumper \@loops;
286              
287 25         116 my $iter = NestedLoops(\@loops,{});
288              
289             # Set up the fixed parts
290 25         1726 my %template;
291              
292 25         62 for(qw(query_params body_params headers)) {
293 75   100     300 $template{ $_ } = $options{ "fixed_$_" } || {};
294             };
295              
296             return sub {
297 17786     17786   94140 my @v = $iter->();
298 17786 100       375898 return unless @v;
299             #use Data::Dumper; warn Dumper \@v;
300              
301             # Patch in the new values
302 17762         53419 my %values = %template;
303 17762         41009 my @vv = splice @v, 0, 0+@keys;
304 17762         68748 @values{ @keys } = @vv;
305              
306             # Now add the query_params, if any
307 17762 100       39061 if(@query_params) {
308 122         251 my @get_values = splice @v, 0, 0+@query_params;
309 122         181 $values{ query_params } = { (%{ $values{ query_params } }, zip( @query_params, @get_values )) };
  122         818  
310             };
311             # Now add the body_params, if any
312 17762 100       33990 if(@body_params) {
313 8         16 my @values = splice @v, 0, 0+@body_params;
314 8         13 $values{ body_params } = { %{ $values{ body_params } }, zip @body_params, @values };
  8         36  
315             };
316              
317             # Recreate the URL with the substituted values
318 17762 100       33020 if( @url_params ) {
319 17632         23734 my %v;
320 17632         44243 @v{ @url_params } = splice @v, 0, 0+@url_params;
321             #use Data::Dumper; warn Dumper \%values;
322 17632         36863 for my $key (qw(scheme host port path )) {
323 70528         144447 $values{ $key } = fill_url($values{ $key }, \%v, $options{ raw_params });
324             };
325             };
326              
327 17762         35175 $values{ url } = _build_uri( \%values );
328              
329             # Merge the headers as well
330             #warn "Merging headers: " . Dumper($values{headers}). " + " . (Dumper $template{headers});
331 17762 50       28872 %{$values{headers}} = (%{$template{headers}}, %{$values{headers} || {}});
  17762         30292  
  17762         30851  
  17762         39425  
332 17762         39518 return $wrapper->(\%values);
333 25         194 };
334             }
335              
336 17762     17762   22979 sub _build_uri( $req ) {
  17762         24037  
  17762         23419  
337 17762         50207 my $uri = URI->new( '', $req->{scheme} );
338 17762 100       902999 if( $req->{host}) {
339 17630         46468 $uri->host( $req->{host});
340 17630         1091088 $uri->scheme( $req->{scheme});
341 17630 100 100     1332235 $uri->port( $req->{port}) if( $req->{port} and $req->{port} != $uri->default_port );
342             };
343 17762         49851 $uri->path( $req->{path});
344             # We want predictable URIs, so we sort the keys here instead of
345             # just passing the hash reference
346 17762         455136 $uri->query_form( map { $_ => $req->{query_params}->{$_} } sort keys %{ $req->{query_params} });
  456         1039  
  17762         69015  
347             #$uri->query_form( $req->{query_params});
348 17762         266805 $uri
349             }
350              
351             =head2 C<< generate_requests( %options ) >>
352              
353             my $g = generate_requests(
354             url => '/profiles/:name',
355             url_params => ['Mark','John'],
356             wrap => sub {
357             my( $req ) = @_;
358             # Fix up some values
359             $req->{headers}->{'Content-Length'} = 666;
360             },
361             );
362             while( my $r = $g->()) {
363             send_request( $r );
364             };
365              
366             This function creates data structures that are suitable for sending off
367             a mass of similar but different HTTP requests. All array references are expanded
368             into the cartesian product of their contents. The above example would create
369             two requests:
370              
371             url => '/profiles/Mark,
372             url => '/profiles/John',
373              
374             C returns an iterator in scalar context. In list context, it
375             returns the complete list of requests:
376              
377             my @requests = generate_requests(
378             url => '/profiles/:name',
379             url_params => ['Mark','John'],
380             wrap => sub {
381             my( $req ) = @_;
382             # Fix up some values
383             $req->{headers}->{'Content-Length'} = 666;
384             },
385             );
386             for my $r (@requests) {
387             send_request( $r );
388             };
389              
390             Note that returning a list instead of the iterator will use up quite some memory
391             quickly, as the list will be the cartesian product of the input parameters.
392              
393             There are helper functions
394             that will turn that data into a data structure suitable for your HTTP framework
395             of choice.
396              
397             {
398             method => 'GET',
399             url => '/profiles/Mark',
400             scheme => 'http',
401             port => 80,
402             headers => {},
403             body_params => {},
404             query_params => {},
405             }
406              
407             As a shorthand for creating lists, you can use the C option, which
408             will expand a string into a set of requests. C<{}> will expand into alternatives
409             while C<[xx..yy]> will expand into the range C to C. Note that these
410             lists will be expanded in memory.
411              
412             =head3 Options
413              
414             =over 4
415              
416             =item B
417              
418             pattern => 'https://example.{com,org,net}/page_[00..99].html',
419              
420             Generate URLs from this pattern instead of C, C
421             and C.
422              
423             =item B
424              
425             URL template to use.
426              
427             =item B
428              
429             Parameters to replace in the C template.
430              
431             =item B
432              
433             Parameters to replace in the POST body.
434              
435             =item B
436              
437             Parameters to replace in the GET request.
438              
439             =item B
440              
441             Hostname(s) to use.
442              
443             =item B
444              
445             Port(s) to use.
446              
447             =item B
448              
449             Headers to use. Currently, no templates are generated for the headers. You have
450             to specify complete sets of headers for each alternative.
451              
452             =item B
453              
454             Limit the number of requests generated.
455              
456             =back
457              
458             =cut
459              
460 25     25 1 31883 sub generate_requests(%options) {
  25         83  
  25         40  
461             croak "Option 'protocol' is now named 'scheme'."
462 25 50       112 if $options{ protocol };
463              
464 25         109 my $i = _generate_requests_iter(%options);
465 25 100       73 if( wantarray ) {
466 24         82 return fetch_all($i, $options{ limit });
467             } else {
468 1         4 return $i
469             }
470             }
471              
472             =head2 C<< as_http_request >>
473              
474             generate_requests(
475             method => 'POST',
476             url => '/feedback/:item',
477             wrap => \&HTTP::Request::Generator::as_http_request,
478             )
479              
480             Converts the request data to a L object.
481              
482             =cut
483              
484 0     0 1   sub as_http_request($req) {
  0            
  0            
485 0           require HTTP::Request;
486 0           HTTP::Request->VERSION(6); # ->flatten()
487 0           require URI;
488 0           require URI::QueryParam;
489              
490 0           my $body = '';
491 0           my $headers;
492             my $form_ct;
493 0 0         if( keys %{$req->{body_params}}) {
  0            
494 0           require HTTP::Request::Common;
495             my $r = HTTP::Request::Common::POST( $req->{url},
496 0           [ %{ $req->{body_params} }],
  0            
497             );
498 0           $headers = HTTP::Headers->new( %{ $req->{headers} }, $r->headers->flatten );
  0            
499 0           $body = $r->content;
500 0           $form_ct = $r->content_type;
501             } else {
502 0           $headers = HTTP::Headers->new( %$headers );
503             };
504              
505             # Store metadata / generate "signature" for later inspection/isolation?
506 0           my $uri = _build_uri( $req );
507 0 0         $uri->query_param( %{ $req->{query_params} || {} });
  0            
508             my $res = HTTP::Request->new(
509 0           $req->{method} => $uri,
510             $headers,
511             $body,
512             );
513 0           $res
514             }
515              
516             =head2 C<< as_dancer >>
517              
518             generate_requests(
519             method => 'POST',
520             url => '/feedback/:item',
521             wrap => \&HTTP::Request::Generator::as_dancer,
522             )
523              
524             Converts the request data to a L object.
525              
526             During the creation of Dancer::Request objects, C<< %ENV >> will be empty except
527             for C<< $ENV{TMP} >> and C<< $ENV{TEMP} >>.
528              
529              
530             This function needs and dynamically loads the following modules:
531              
532             L
533              
534             L
535              
536             =cut
537              
538 0     0 1   sub as_dancer($req) {
  0            
  0            
539 0           require Dancer::Request;
540 0           require HTTP::Request;
541 0           HTTP::Request->VERSION(6); # ->flatten()
542              
543 0           my $body = '';
544 0           my $headers;
545             my $form_ct;
546 0 0         if( keys %{$req->{body_params}}) {
  0            
547 0           require HTTP::Request::Common;
548             my $r = HTTP::Request::Common::POST( $req->{url},
549 0           [ %{ $req->{body_params} }],
  0            
550             );
551 0           $headers = HTTP::Headers->new( %{ $req->{headers} }, $r->headers->flatten );
  0            
552 0           $body = $r->content;
553 0           $form_ct = $r->content_type;
554             } else {
555 0           $headers = HTTP::Headers->new( %$headers );
556             };
557              
558 0           my $uri = _build_uri( $req );
559              
560             # Store metadata / generate "signature" for later inspection/isolation?
561 0           my %old_ENV = %ENV;
562 0           local %ENV; # wipe out non-overridable default variables of Dancer::Request
563 0           my @keep = (qw(TMP TEMP));
564 0           @ENV{ @keep } = @old_ENV{ @keep };
565             my $res = Dancer::Request->new_for_request(
566             $req->{method} => $uri->path,
567             $req->{query_params},
568             $body,
569             $headers,
570             { CONTENT_LENGTH => length($body),
571             CONTENT_TYPE => $form_ct,
572             HTTP_HOST => (join ":", $req->{host}, $req->{port}),
573             SERVER_NAME => $req->{host},
574             SERVER_PORT => $req->{port},
575             REQUEST_METHOD => $req->{ method },
576 0           REQUEST_URI => $uri,
577             SCRIPT_NAME => $uri->path,
578              
579             },
580             );
581 0           $res->{_http_body}->add($body);
582             #use Data::Dumper; warn Dumper $res;
583 0           $res
584             }
585              
586             =head2 C<< as_plack >>
587              
588             generate_requests(
589             method => 'POST',
590             url => '/feedback/:item',
591             wrap => \&HTTP::Request::Generator::as_plack,
592             )
593              
594             Converts the request data to a L object.
595              
596             During the creation of Plack::Request objects, C<< %ENV >> will be empty except
597             for C<< $ENV{TMP} >> and C<< $ENV{TEMP} >>.
598              
599             This function needs and dynamically loads the following modules:
600              
601             L
602              
603             L
604              
605             L
606              
607             =cut
608              
609 0     0 1   sub as_plack($req) {
  0            
  0            
610 0           require Plack::Request;
611 0           Plack::Request->VERSION(1.0047);
612 0           require HTTP::Headers;
613 0           require Hash::MultiValue;
614              
615 0           my %env = %$req;
616 0           $env{ 'psgi.version' } = '1.0';
617 0           $env{ 'psgi.url_scheme' } = delete $env{ scheme };
618 0 0         $env{ 'plack.request.query_parameters' } = [%{delete $env{ query_params }||{}} ];
  0            
619 0 0         $env{ 'plack.request.body_parameters' } = [%{delete $env{ body_params }||{}} ];
  0            
620 0           $env{ 'plack.request.headers' } = HTTP::Headers->new( %{ delete $req->{headers} });
  0            
621 0           $env{ REQUEST_METHOD } = delete $env{ method };
622 0           $env{ REQUEST_URI } = _build_uri( $req );
623 0           $env{ SCRIPT_NAME } = $env{ REQUEST_URI }->path;
624 0           delete $env{ url };
625 0           $env{ QUERY_STRING } = ''; # not correct, but...
626 0           $env{ SERVER_NAME } = delete $env{ host };
627 0           $env{ SERVER_PORT } = delete $env{ port };
628             # need to convert the headers into %env HTTP_ keys here
629 0           $env{ CONTENT_TYPE } = undef;
630              
631             # Store metadata / generate "signature" for later inspection/isolation?
632 0           my %old_ENV = %ENV;
633 0           local %ENV; # wipe out non-overridable default variables of Dancer::Request
634 0           my @keep = (qw(TMP TEMP));
635 0           @ENV{ @keep } = @old_ENV{ @keep };
636 0           my $res = Plack::Request->new(\%env);
637 0           $res
638             }
639              
640             1;
641              
642             =head1 SEE ALSO
643              
644             L for the pattern syntax
645              
646             =head1 REPOSITORY
647              
648             The public repository of this module is
649             L.
650              
651             =head1 SUPPORT
652              
653             The public support forum of this module is L.
654              
655             =head1 BUG TRACKER
656              
657             Please report bugs in this module via the RT CPAN bug queue at
658             L
659             or via mail to L.
660              
661             =head1 AUTHOR
662              
663             Max Maischein C
664              
665             =head1 COPYRIGHT (c)
666              
667             Copyright 2017-2019 by Max Maischein C.
668              
669             =head1 LICENSE
670              
671             This module is released under the same terms as Perl itself.
672              
673             =cut