File Coverage

blib/lib/HTTP/Request/FromCurl.pm
Criterion Covered Total %
statement 185 261 70.8
branch 60 126 47.6
condition 21 42 50.0
subroutine 22 23 95.6
pod 2 2 100.0
total 290 454 63.8


line stmt bran cond sub pod time code
1             package HTTP::Request::FromCurl;
2 13     13   326491 use strict;
  13         114  
  13         409  
3 13     13   70 use warnings;
  13         32  
  13         457  
4 13     13   96 use File::Basename 'basename';
  13         35  
  13         1527  
5 13     13   5998 use HTTP::Request;
  13         315553  
  13         480  
6 13     13   6852 use HTTP::Request::Common;
  13         32239  
  13         958  
7 13     13   104 use URI;
  13         26  
  13         421  
8 13     13   77 use URI::Escape;
  13         26  
  13         653  
9 13     13   10473 use Getopt::Long;
  13         144875  
  13         69  
10 13     13   2071 use File::Spec::Unix;
  13         45  
  13         604  
11 13     13   7476 use HTTP::Request::CurlParameters;
  13         69  
  13         648  
12 13     13   7513 use HTTP::Request::Generator 'generate_requests';
  13         308076  
  13         1048  
13 13     13   124 use PerlX::Maybe;
  13         46  
  13         140  
14 13     13   7193 use MIME::Base64 'encode_base64';
  13         8820  
  13         881  
15 13     13   119 use File::Basename 'basename';
  13         29  
  13         628  
16              
17 13     13   108 use Filter::signatures;
  13         36  
  13         100  
18 13     13   363 use feature 'signatures';
  13         45  
  13         1054  
19 13     13   102 no warnings 'experimental::signatures';
  13         28  
  13         44368  
20              
21             our $VERSION = '0.52';
22              
23             =head1 NAME
24              
25             HTTP::Request::FromCurl - create a HTTP::Request from a curl command line
26              
27             =head1 SYNOPSIS
28              
29             my $req = HTTP::Request::FromCurl->new(
30             # Note - curl itself may not appear
31             argv => ['https://example.com'],
32             );
33              
34             my $req = HTTP::Request::FromCurl->new(
35             command => 'https://example.com',
36             );
37              
38             my $req = HTTP::Request::FromCurl->new(
39             command_curl => 'curl -A mycurl/1.0 https://example.com',
40             );
41              
42             my @requests = HTTP::Request::FromCurl->new(
43             command_curl => 'curl -A mycurl/1.0 https://example.com https://www.example.com',
44             );
45             # Send the requests
46             for my $r (@requests) {
47             $ua->request( $r->as_request )
48             }
49              
50             =head1 RATIONALE
51              
52             C command lines are found everywhere in documentation. The Firefox
53             developer tools can also copy network requests as C command lines from
54             the network panel. This module enables converting these to Perl code.
55              
56             =head1 METHODS
57              
58             =head2 C<< ->new >>
59              
60             my $req = HTTP::Request::FromCurl->new(
61             # Note - curl itself may not appear
62             argv => ['--user-agent', 'myscript/1.0', 'https://example.com'],
63             );
64              
65             my $req = HTTP::Request::FromCurl->new(
66             # Note - curl itself may not appear
67             command => '--user-agent myscript/1.0 https://example.com',
68             );
69              
70             The constructor returns one or more L objects
71             that encapsulate the parameters. If the command generates multiple requests,
72             they will be returned in list context. In scalar context, only the first request
73             will be returned. Note that the order of URLs between C<--url> and unadorned URLs will be changed in the sense that all unadorned URLs will be handled first.
74              
75             my $req = HTTP::Request::FromCurl->new(
76             command => '--data-binary @/etc/passwd https://example.com',
77             read_files => 1,
78             );
79              
80             =head3 Options
81              
82             =over 4
83              
84             =item B
85              
86             An arrayref of commands as could be given in C< @ARGV >.
87              
88             =item B
89              
90             A scalar in a command line, excluding the C command
91              
92             =item B
93              
94             A scalar in a command line, including the C command
95              
96             =item B
97              
98             Do read in the content of files specified with (for example)
99             C<< --data=@/etc/passwd >>. The default is to not read the contents of files
100             specified this way.
101              
102             =back
103              
104             =head1 GLOBAL VARIABLES
105              
106             =head2 C<< %default_headers >>
107              
108             Contains the default headers added to every request
109              
110             =cut
111              
112             our %default_headers = (
113             'Accept' => '*/*',
114             'User-Agent' => 'curl/7.55.1',
115             );
116              
117             =head2 C<< @option_spec >>
118              
119             Contains the L specification of the recognized command line
120             parameters.
121              
122             The following C options are recognized but largely ignored:
123              
124             =over 4
125              
126             =item C< --disable >
127              
128             =item C< --dump-header >
129              
130             =item C< --include >
131              
132             =item C< --location >
133              
134             =item C< --progress-bar >
135              
136             =item C< --show-error >
137              
138             =item C< --fail >
139              
140             =item C< --silent >
141              
142             =item C< --verbose >
143              
144             =item C< --junk-session-cookies >
145              
146             If you want to keep session cookies between subsequent requests, you need to
147             provide a cookie jar in your user agent.
148              
149             =item C<--next>
150              
151             Resetting the UA between requests is something you need to handle yourself
152              
153             =item C<--parallel>
154              
155             =item C<--parallel-immediate>
156              
157             =item C<--parallel-max>
158              
159             Parallel requests is something you need to handle in the UA
160              
161             =back
162              
163             =cut
164              
165             our @option_spec = (
166             'user-agent|A=s',
167             'verbose|v', # ignored
168             'show-error|S', # ignored
169             'fail|f', # ignored
170             'silent|s', # ignored
171             'anyauth', # ignored
172             'basic',
173             'buffer!',
174             'capath=s',
175             'cert|E=s',
176             'compressed',
177             'cookie|b=s',
178             'cookie-jar|c=s',
179             'data|d=s@',
180             'data-ascii=s@',
181             'data-binary=s@',
182             'data-raw=s@',
183             'data-urlencode=s@',
184             'digest',
185             'disable|q!', # ignored
186             'dump-header|D=s', # ignored
187             'referrer|e=s',
188             'form|F=s@',
189             'form-string=s@',
190             'get|G',
191             'globoff|g',
192             'head|I',
193             'header|H=s@',
194             'include|i', # ignored
195             'interface=s',
196             'insecure|k',
197             'json=s@',
198             'location|L', # ignored, we always follow redirects
199             'max-filesize=s',
200             'max-time|m=s',
201             'ntlm',
202             'keepalive!',
203             'range=s',
204             'request|X=s',
205             'oauth2-bearer=s',
206             'output|o=s',
207             'progress-bar|#', # ignored
208             'user|u=s',
209             'next', # ignored
210             'parallel|Z', # ignored
211             'parallel-immediate', # ignored
212             'parallel-max', # ignored
213             'junk-session-cookies|j', # ignored, must be set in code using the HTTP request
214             'unix-socket=s',
215             'url=s@',
216             );
217              
218 5     5 1 4963 sub new( $class, %options ) {
  5         12  
  5         12  
  5         9  
219 5         8 my $cmd = $options{ argv };
220              
221 5 100       20 if( $options{ command }) {
    50          
222 1         460 require Text::ParseWords;
223 1         1393 $cmd = [ Text::ParseWords::shellwords($options{ command }) ];
224              
225             } elsif( $options{ command_curl }) {
226 0         0 require Text::ParseWords;
227 0         0 $cmd = [ Text::ParseWords::shellwords($options{ command_curl }) ];
228              
229             # remove the implicit curl command:
230 0         0 shift @$cmd;
231             };
232              
233 5         417 for (@$cmd) {
234 28 50       79 $_ = '--next'
235             if $_ eq '-:'; # GetOptions does not like "next|:" as specification
236             };
237              
238 5         34 my $p = Getopt::Long::Parser->new(
239             config => [ 'bundling', 'no_auto_abbrev', 'no_ignore_case_always' ],
240             );
241 5 50       528 $p->getoptionsfromarray( $cmd,
242             \my %curl_options,
243             @option_spec,
244             ) or return;
245 5 50       16086 my @urls = (@$cmd, @{ $curl_options{ url } || [] });
  5         32  
246              
247             return
248 5 100       25 wantarray ? map { $class->_build_request( $_, \%curl_options, %options ) } @urls
  7         31  
249             : ($class->_build_request( $urls[0], \%curl_options, %options ))[0]
250             ;
251             }
252              
253             =head1 METHODS
254              
255             =head2 C<< ->squash_uri( $uri ) >>
256              
257             my $uri = HTTP::Request::FromCurl->squash_uri(
258             URI->new( 'https://example.com/foo/bar/..' )
259             );
260             # https://example.com/foo/
261              
262             Helper method to clean up relative path elements from the URI the same way
263             that curl does.
264              
265             =cut
266              
267 19     19 1 20105 sub squash_uri( $class, $uri ) {
  19         36  
  19         27  
  19         29  
268 19         63 my $u = $uri->clone;
269 19         279 my @segments = $u->path_segments;
270              
271 19 100 100     833 if( $segments[-1] and ($segments[-1] eq '..' or $segments[-1] eq '.' ) ) {
      100        
272 6         16 push @segments, '';
273             };
274              
275 19         39 @segments = grep { $_ ne '.' } @segments;
  57         117  
276              
277             # While we find a pair ( "foo", ".." ) remove that pair
278 19         42 while( grep { $_ eq '..' } @segments ) {
  75         143  
279 10         18 my $i = 0;
280 10         23 while( $i < $#segments ) {
281 28 100 100     94 if( $segments[$i] ne '..' and $segments[$i+1] eq '..') {
282 12         37 splice @segments, $i, 2;
283             } else {
284 16         31 $i++
285             };
286             };
287             };
288              
289 19 100       52 if( @segments < 2 ) {
290 11         60 @segments = ('','');
291             };
292              
293 19         63 $u->path_segments( @segments );
294 19         1246 return $u
295             }
296              
297 30     30   40 sub _add_header( $self, $headers, $h, $value ) {
  30         42  
  30         35  
  30         46  
  30         38  
  30         40  
298 30 50       85 if( exists $headers->{ $h }) {
299 0 0       0 if (!ref( $headers->{ $h })) {
300 0         0 $headers->{ $h } = [ $headers->{ $h }];
301             }
302 0         0 push @{ $headers->{ $h } }, $value;
  0         0  
303             } else {
304 30         75 $headers->{ $h } = $value;
305             }
306             }
307              
308 1     1   2 sub _maybe_read_data_file( $self, $read_files, $data ) {
  1         35  
  1         4  
  1         2  
  1         2  
309 1         2 my $res;
310 1 50       4 if( $read_files ) {
311 0 0       0 if( $data =~ /^\@(.*)/ ) {
312 0 0       0 open my $fh, '<', $1
313             or die "$1: $!";
314 0         0 local $/; # / for Filter::Simple
315 0         0 binmode $fh;
316 0         0 $res = <$fh>
317             } else {
318 0         0 $res = $data
319             }
320             } else {
321 1 50       13 $res = ($data =~ /^\@(.*)/)
322             ? "... contents of $1 ..."
323             : $data
324             }
325 1         3 return $res
326             }
327              
328 0     0   0 sub _maybe_read_upload_file( $self, $read_files, $data ) {
  0         0  
  0         0  
  0         0  
  0         0  
329 0         0 my $res;
330 0 0       0 if( $read_files ) {
331 0 0       0 if( $data =~ /^<(.*)/ ) {
    0          
332 0 0       0 open my $fh, '<', $1
333             or die "$1: $!";
334 0         0 local $/; # / for Filter::Simple
335 0         0 binmode $fh;
336 0         0 $res = <$fh>
337             } elsif( $data =~ /^\@(.*)/ ) {
338             # Upload the file
339 0         0 $res = [ $1 => basename($1), Content_Type => 'application/octet-stream' ];
340             } else {
341 0         0 $res = $data
342             }
343             } else {
344 0 0       0 if( $data =~ /^[<@](.*)/ ) {
345 0         0 $res = [ undef, basename($1), Content_Type => 'application/octet-stream', Content => "... contents of $1 ..." ],
346             } else {
347 0         0 $res = $data
348             }
349             }
350 0         0 return $res
351             }
352              
353 8     8   14 sub _build_request( $self, $uri, $options, %build_options ) {
  8         14  
  8         13  
  8         12  
  8         17  
  8         12  
354 8         11 my $body;
355 8 100       13 my @headers = @{ $options->{header} || []};
  8         35  
356 8         22 my $method = $options->{request};
357             # Ideally, we shouldn't sort the data but process it in-order
358 8 100       29 my @post_read_data = (@{ $options->{'data'} || []},
359 8 50       13 @{ $options->{'data-ascii'} || [] }
  8         32  
360             );
361             ;
362 8 50       16 my @post_raw_data = @{ $options->{'data-raw'} || [] },
  8         30  
363             ;
364 8 50       13 my @post_urlencode_data = @{ $options->{'data-urlencode'} || [] };
  8         25  
365 8 50       13 my @post_binary_data = @{ $options->{'data-binary'} || [] };
  8         25  
366 8 50       15 my @post_json_data = @{ $options->{'json'} || [] };
  8         24  
367              
368 8         13 my @form_args;
369 8 50       20 if( $options->{form}) {
370             # support --form uploaded_file=@myfile
371             # and --form "uploaded_text=<~/texts/content.txt"
372             push @form_args, map { /^([^=]+)=(.*)$/
373 0 0       0 ? ($1 => $self->_maybe_read_upload_file( $build_options{ read_files }, $2 ))
374 0         0 : () } @{$options->{form}
375 0         0 };
376             };
377 8 50       16 if( $options->{'form-string'}) {
378 0 0       0 push @form_args, map {; /^([^=]+)=(.*)$/ ? ($1 => $2) : (); } @{ $options->{'form-string'}};
  0         0  
  0         0  
379             };
380              
381             # expand the URI here if wanted
382 8         17 my @uris = ($uri);
383 8 100       18 if( ! $options->{ globoff }) {
384 4         57 @uris = map { $_->{url} } generate_requests( pattern => shift @uris, limit => $build_options{ limit } );
  5         20942  
385             }
386              
387 8 50 33     43 if( $options->{'max-filesize'}
388             and $options->{'max-filesize'} =~ m/^(\d+)([kmg])$/i ) {
389             my $mult = {
390             'k' => 1024,
391             'g' => 1024*1024*1024,
392             'm' => 1024*1024,
393 0         0 }->{ $2 };
394 0         0 $options->{'max-filesize'} = $1 * $mult;
395             }
396              
397 8         12 my @res;
398 8         22 for my $uri (@uris) {
399 9         41 $uri = URI->new( $uri );
400 9         643 $uri = $self->squash_uri( $uri );
401              
402 9 100       84 my $host = $uri->can( 'host_port' ) ? $uri->host_port : "$uri";
403              
404             # Stuff we use unless nothing else hits
405 9         281 my %request_default_headers = %default_headers;
406              
407             # Sluuuurp
408             # Thous should be hoisted out of the loop
409             @post_binary_data = map {
410 9         22 $self->_maybe_read_data_file( $build_options{ read_files }, $_ );
  0         0  
411             } @post_binary_data;
412              
413             @post_json_data = map {
414 9         18 $self->_maybe_read_data_file( $build_options{ read_files }, $_ );
  0         0  
415             } @post_json_data;
416              
417             @post_read_data = map {
418 9         15 my $v = $self->_maybe_read_data_file( $build_options{ read_files }, $_ );
  1         6  
419 1         5 $v =~ s![\r\n]!!g;
420 1         4 $v
421             } @post_read_data;
422              
423             @post_urlencode_data = map {
424 9 0       16 m/\A([^@=]*)([=@])?(.*)\z/sm
  0         0  
425             or die "This should never happen";
426 0         0 my ($name, $op, $content) = ($1,$2,$3);
427 0 0       0 if(! $op) {
    0          
428 0         0 $content = $name;
429             } elsif( $op eq '@' ) {
430 0         0 $content = "$op$content";
431             };
432 0 0 0     0 if( defined $name and length $name ) {
433 0         0 $name .= '=';
434             } else {
435 0         0 $name = '';
436             };
437 0         0 my $v = $self->_maybe_read_data_file( $build_options{ read_files }, $content );
438 0         0 $name . uri_escape( $v )
439             } @post_urlencode_data;
440              
441 9         15 my $data;
442 9 100 66     73 if( @post_read_data
    50 66        
      33        
443             or @post_binary_data
444             or @post_raw_data
445             or @post_urlencode_data
446             ) {
447 1         4 $data = join "&",
448             @post_read_data,
449             @post_binary_data,
450             @post_raw_data,
451             @post_urlencode_data
452             ;
453             } elsif( @post_json_data ) {
454 0         0 $data = join '', @post_json_data;
455             }
456              
457 9 50       53 if( @form_args) {
    50          
    50          
    100          
458 0   0     0 $method //= 'POST';
459              
460             #my $req = HTTP::Request::Common::POST(
461             # 'https://example.com',
462             # Content_Type => 'form-data',
463             # Content => \@form_args,
464             #);
465             #$body = $req->content;
466             #$request_default_headers{ 'Content-Type' } = join "; ", $req->headers->content_type;
467              
468             } elsif( $options->{ get }) {
469 0         0 $method = 'GET';
470             # Also, append the POST data to the URL
471 0 0       0 if( $data ) {
472 0         0 my $q = $uri->query;
473 0 0 0     0 if( defined $q and length $q ) {
474 0         0 $q .= "&";
475             } else {
476 0         0 $q = "";
477             };
478 0         0 $q .= $data;
479 0         0 $uri->query( $q );
480             };
481              
482             } elsif( $options->{ head }) {
483 0         0 $method = 'HEAD';
484              
485             } elsif( defined $data ) {
486 1   50     4 $method //= 'POST';
487 1         2 $body = $data;
488              
489 1 50       3 if( @post_json_data ) {
490 0         0 $request_default_headers{ 'Content-Type' } = "application/json";
491 0         0 $request_default_headers{ 'Accept' } = "application/json";
492              
493             } else {
494 1         3 $request_default_headers{ 'Content-Type' } = 'application/x-www-form-urlencoded';
495             };
496              
497             } else {
498 8   100     21 $method ||= 'GET';
499             };
500              
501 9 100       23 if( defined $body ) {
502 1         2 $request_default_headers{ 'Content-Length' } = length $body;
503             };
504              
505 9 50       23 if( $options->{ 'oauth2-bearer' } ) {
506 0         0 push @headers, sprintf 'Authorization: Bearer %s', $options->{'oauth2-bearer'};
507             };
508              
509 9 100       27 if( $options->{ 'user' } ) {
510 1 50 33     14 if( $options->{anyauth}
      33        
      33        
511             || $options->{digest}
512             || $options->{ntlm}
513             || $options->{negotiate}
514             ) {
515             # Nothing to do here, just let LWP::UserAgent do its thing
516             # This means one additional request to fetch the appropriate
517             # 401 response asking for credentials, but ...
518             } else {
519             # $options->{basic} or none at all
520 1         4 my $info = delete $options->{'user'};
521             # We need to bake this into the header here?!
522 1         11 push @headers, sprintf 'Authorization: Basic %s', encode_base64( $info );
523             }
524             };
525              
526 9         18 my %headers;
527 9         23 for my $kv (
528 2 50       17 (map { /^\s*([^:\s]+)\s*:\s*(.*)$/ ? [$1 => $2] : () } @headers),) {
529 2         7 $self->_add_header( \%headers, @$kv );
530             };
531              
532 9 50       23 if( defined $options->{ 'user-agent' }) {
533 0         0 $self->_add_header( \%headers, "User-Agent", $options->{ 'user-agent' } );
534             };
535              
536 9 50       40 if( defined $options->{ referrer }) {
537 0         0 $self->_add_header( \%headers, "Referer" => $options->{ 'referrer' } );
538             };
539              
540 9 50       23 if( defined $options->{ range }) {
541 0         0 $self->_add_header( \%headers, "Range" => $options->{ 'range' } );
542             };
543              
544             # We want to compare the headers case-insensitively
545 9         27 my %headers_lc = map { lc $_ => 1 } keys %headers;
  2         9  
546              
547 9         21 for my $k (keys %request_default_headers) {
548 20 100       52 if( ! $headers_lc{ lc $k }) {
549 19         43 $self->_add_header( \%headers, $k, $request_default_headers{ $k });
550             };
551             };
552 9 50       34 if( ! $headers{ 'Host' }) {
553 9         25 $self->_add_header( \%headers, 'Host' => $host );
554             };
555              
556 9 50       22 if( defined $options->{ 'cookie-jar' }) {
557 0         0 $options->{'cookie-jar-options'}->{ 'write' } = 1;
558             };
559              
560 9 50       31 if( defined( my $c = $options->{ cookie })) {
561 0 0       0 if( $c =~ /=/ ) {
562 0         0 $headers{ Cookie } = $options->{ 'cookie' };
563             } else {
564 0         0 $options->{'cookie-jar'} = $c;
565 0         0 $options->{'cookie-jar-options'}->{ 'read' } = 1;
566             };
567             };
568              
569             # Curl 7.61.0 ignores these:
570             #if( $options->{ keepalive }) {
571             # $headers{ 'Keep-Alive' } = 1;
572             #} elsif( exists $options->{ keepalive }) {
573             # $headers{ 'Keep-Alive' } = 0;
574             #};
575              
576 9 50       20 if( $options->{ compressed }) {
577 0         0 my $compressions = HTTP::Message::decodable();
578 0         0 $self->_add_header( \%headers, 'Accept-Encoding' => $compressions );
579             };
580              
581 9         14 my $auth;
582 9         20 for my $kind (qw(basic ntlm negotiate)) {
583 27 50       57 if( $options->{$kind}) {
584 0         0 $auth = $kind;
585             }
586             };
587              
588             push @res, HTTP::Request::CurlParameters->new({
589             method => $method,
590             uri => $uri,
591             headers => \%headers,
592             body => $body,
593             maybe auth => $auth,
594             maybe cert => $options->{cert},
595             maybe capath => $options->{capath},
596             maybe credentials => $options->{ user },
597             maybe output => $options->{ output },
598             maybe timeout => $options->{ 'max-time' },
599             maybe cookie_jar => $options->{'cookie-jar'},
600             maybe cookie_jar_options => $options->{'cookie-jar-options'},
601             maybe insecure => $options->{'insecure'},
602             maybe max_filesize => $options->{'max-filesize'},
603             maybe show_error => $options->{'show-error'},
604             maybe fail => $options->{'fail'},
605             maybe unix_socket => $options->{'unix-socket'},
606 9 50       328 maybe local_address => $options->{'interface'},
607             maybe form_args => scalar @form_args ? \@form_args : undef,
608             });
609             }
610              
611             return @res
612 8         151 };
613              
614             1;
615              
616             =head1 LIVE DEMO
617              
618             L
619              
620             =head1 KNOWN DIFFERENCES
621              
622             =head2 Incompatible cookie jar formats
623              
624             Until somebody writes a robust Netscape cookie file parser and proper loading
625             and storage for L, this module will not be able to load and
626             save files in the format that Curl uses.
627              
628             =head2 Loading/saving cookie jars is the job of the UA
629              
630             You're expected to instruct your UA to load/save cookie jars:
631              
632             use Path::Tiny;
633             use HTTP::CookieJar::LWP;
634              
635             if( my $cookies = $r->cookie_jar ) {
636             $ua->cookie_jar( HTTP::CookieJar::LWP->new()->load_cookies(
637             path($cookies)->lines
638             ));
639             };
640              
641             =head2 Different Content-Length for POST requests
642              
643             =head2 Different delimiter for form data
644              
645             The delimiter is built by L, and C uses a different
646             mechanism to come up with a unique data delimiter. This results in differences
647             in the raw body content and the C header.
648              
649             =head1 MISSING FUNCTIONALITY
650              
651             =over 4
652              
653             =item *
654              
655             File uploads / content from files
656              
657             While file uploads and reading POST data from files are supported, the content
658             is slurped into memory completely. This can be problematic for large files
659             and little available memory.
660              
661             =item *
662              
663             Mixed data instances
664              
665             Multiple mixed instances of C<--data>, C<--data-ascii>, C<--data-raw>,
666             C<--data-binary> or C<--data-raw> are sorted by type first instead of getting
667             concatenated in the order they appear on the command line.
668             If the order is important to you, use one type only.
669              
670             =item *
671              
672             Multiple sets of parameters from the command line
673              
674             Curl supports the C<< --next >> command line switch which resets
675             parameters for the next URL.
676              
677             This is not (yet) supported.
678              
679             =back
680              
681             =head1 SEE ALSO
682              
683             L
684              
685             L
686              
687             L
688              
689             L - for the inverse function
690              
691             The module HTTP::Request::AsCurl likely also implements a much better version
692             of C<< ->as_curl >> than this module.
693              
694             L - a converter for multiple
695             target languages
696              
697             L
698              
699             =head1 REPOSITORY
700              
701             The public repository of this module is
702             L.
703              
704             =head1 SUPPORT
705              
706             The public support forum of this module is
707             L.
708              
709             =head1 BUG TRACKER
710              
711             Please report bugs in this module via the Github bug queue at
712             L
713              
714             =head1 AUTHOR
715              
716             Max Maischein C
717              
718             =head1 COPYRIGHT (c)
719              
720             Copyright 2018-2023 by Max Maischein C.
721              
722             =head1 LICENSE
723              
724             This module is released under the same terms as Perl itself.
725              
726             =cut