File Coverage

blib/lib/HTTP/Request/FromCurl.pm
Criterion Covered Total %
statement 243 254 95.6
branch 106 126 84.1
condition 33 42 78.5
subroutine 21 21 100.0
pod 2 2 100.0
total 405 445 91.0


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