File Coverage

blib/lib/HTTP/Request/FromCurl.pm
Criterion Covered Total %
statement 180 250 72.0
branch 55 118 46.6
condition 20 39 51.2
subroutine 22 23 95.6
pod 2 2 100.0
total 279 432 64.5


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