File Coverage

blib/lib/HTTP/Request/Diff.pm
Criterion Covered Total %
statement 184 195 94.3
branch 72 104 69.2
condition 40 64 62.5
subroutine 16 16 100.0
pod 2 5 40.0
total 314 384 81.7


line stmt bran cond sub pod time code
1             package HTTP::Request::Diff;
2 5     5   1349634 use 5.020;
  5         28  
3 5     5   3434 use Moo 2;
  5         50678  
  5         45  
4              
5             our $VERSION = '0.07';
6              
7 5     5   10889 use experimental 'signatures'; # actually, they are stable but stable.pm doesn't know
  5         6910  
  5         52  
8 5     5   3174 use stable 'postderef';
  5         1634  
  5         41  
9 5     5   491 no warnings 'experimental::signatures';
  5         11  
  5         264  
10 5     5   3789 use Algorithm::Diff;
  5         35175  
  5         430  
11 5     5   41 use Carp 'croak', 'cluck';
  5         9  
  5         350  
12 5     5   33 use List::Util 'pairs', 'uniq', 'max';
  5         21  
  5         475  
13 5     5   3072 use CGI::Tiny::Multipart 'parse_multipart_form_data';
  5         11676  
  5         428  
14 5     5   677 use HTTP::Request;
  5         28922  
  5         171  
15 5     5   2749 use URI::QueryParam; # old versions of URI don't load the functionality automatically
  5         826  
  5         26382  
16             require overload; # for checking whether inputs are overloaded
17              
18             =encoding utf-8
19              
20             =head1 NAME
21              
22             HTTP::Request::Diff - create diffs between HTTP requests
23              
24             =head1 SYNOPSIS
25              
26             use HTTP::Request::Common 'GET';
27              
28             my $diff = HTTP::Request::Diff->new(
29             reference => GET('https://example.com/?foo=bar' ),
30             #actual => $req2,
31             skip_headers => \@skip,
32             ignore_headers => \@skip2,
33             mode => 'exact', # default is 'semantic'
34             );
35              
36             my @differences = $diff->diff( GET('https://example.com/' ));
37             say Dumper $differences[0];
38             # {
39             # 'kind' => 'value',
40             # 'type' => 'query.foo',
41             # 'reference' => [
42             # 'bar'
43             # ],
44             # 'actual' => [
45             # undef
46             # ]
47             # }
48             #
49              
50             say $diff->as_table(@differences);
51             # +-----------+-----------+-----------+
52             # | Type | Reference | Actual |
53             # +-----------+-----------+-----------+
54             # | query.foo | bar | |
55             # +-----------+-----------+-----------+
56              
57              
58             =head1 METHODS
59              
60             =head2 C<< ->new >>
61              
62             my $diff = HTTP::Request::Diff->new(
63             mode => 'semantic',
64             );
65              
66             =head3 Options
67              
68             =over 4
69              
70             =item * C
71              
72             mode => 'strict',
73              
74             The comparison mode. The default is semantic comparison, which considers some
75             differences insignificant:
76              
77             =over 4
78              
79             =item * The order of HTTP headers
80              
81             =item * The boundary strings of multipart POST requests
82              
83             =item * The order of query parameters
84              
85             =item * The order of form parameters
86              
87             =item * A C header is equivalent to a missing C header
88              
89             =back
90              
91             C mode wants the requests to be as identical as possible.
92             C mode considers query parameters in the POST body as equivalent.
93              
94             =cut
95              
96             # lax -> parameters may be query or post-parameters
97             # semantic -> many things in requests are equivalent
98             # strict -> requests must be string-identical
99             has 'mode' => (
100             is => 'ro',
101             default => 'semantic',
102             );
103              
104             =item * C
105              
106             (optional) The reference request to compare against. Alternatively pass in
107             the request in the call to C<< ->diff >>.
108              
109             =cut
110              
111             has 'reference' => (
112             is => 'ro',
113             );
114              
115             =item * C
116              
117             skip_headers => ['X-Proxied-For'],
118              
119             List of headers to skip when comparing. Missing headers are not significant.
120              
121             =cut
122              
123             has 'skip_headers' => (
124             is => 'ro',
125             default => sub { [] },
126             );
127              
128             =item * C
129              
130             ignore_headers => ['Accept-Encoding'],
131              
132             List of headers to ignore when comparing. Missing headers are significant.
133              
134             =cut
135              
136             has 'ignore_headers' => (
137             is => 'ro',
138             default => sub { [] },
139             );
140              
141             =item * C
142              
143             Callback to canonicalize a request. The request will be passed in unmodified
144             either as a string or a L.
145              
146             =cut
147              
148             has 'canonicalize' => (
149             is => 'ro',
150             );
151              
152             =item * C
153              
154             Arrayref of things to compare.
155              
156             =cut
157              
158             has 'compare' => (
159             is => 'ro',
160             default => sub {
161             return [
162             request => 'method',
163             uri => 'host',
164             uri => 'port',
165             uri => 'path',
166             ];
167             },
168             );
169              
170             =item * C
171              
172             (optional) If we should output warnings when we receive C< \n > delimited input
173             instead of C< \r\n >. This mostly happens when input is read from text files
174             for regression test.
175              
176             Default is true.
177              
178             =back
179              
180             =cut
181              
182             has 'warn_on_newlines' => (
183             is => 'rw',
184             default => 1
185             );
186              
187 288     288 0 414 sub fetch_value($self, $req, $item, $req_params=undef) {
  288         408  
  288         391  
  288         474  
  288         435  
  288         417  
188 288         401 my $obj;
189 288 100       1522 if( $item->key eq 'request' ) {
    100          
    100          
    100          
    50          
190 92         242 my $v = $item->value;
191 92         366 return $req->$v;
192              
193             } elsif( $item->key eq 'headers' ) {
194 28         102 return $req->headers->header( $item->value );
195              
196             } elsif( $item->key eq 'query' ) {
197 16         53 return [ $req->uri->query_param( $item->value )];
198              
199             } elsif( $item->key eq 'uri' ) {
200 144         389 my $u = $req->uri;
201 144 100       1448 if( my $c = $u->can( $item->value )) {
202 76         275 return $c->($u)
203             } else {
204             return
205 68         172 }
206              
207             } elsif( $item->key eq 'form' ) {
208 8         35 return $req_params->{ $item->value };
209              
210             } else {
211 0         0 croak sprintf "Unknown key '%s'", $item->key;
212             }
213              
214             }
215              
216 4     4 0 9 sub get_form_parameters( $self, $req ) {
  4         9  
  4         7  
  4         7  
217 4         15 my(undef, $boundary) = $req->headers->content_type;
218 4         127 my $str = $req->content;
219 4         68 $boundary =~ s!^boundary=!!;
220              
221 4         11 my %res;
222 4         36 my $res = parse_multipart_form_data( \$str, length($str), $boundary);
223 4 50       980 if( ! $res ) {
224 0         0 croak "Malformed form data";
225             }
226 4         11 for my $p ($res->@*) {
227 8   50     50 $res{ $p->{name} } //= [];
228 8         23 push $res{ $p->{name}}->@*, $p->{content};
229             };
230 4         26 return \%res;
231             }
232              
233 48     48 0 82 sub get_request_header_names( $self, $req ) {
  48         77  
  48         83  
  48         72  
234 48 100       164 if( $req =~ /\n/ ) {
235 41 50       633 my( $header ) = $req =~ m/^(.*?)\r?\n\r?\n/ms
236             or croak "No header in request <$req>";
237 41         250 my @headers = ($header =~ /^([A-Za-z][A-Za-z\d-]+):/mg);
238 41         133 return @headers;
239             } else {
240             return
241 7         13 }
242             }
243              
244             =head2 C<< ->diff >>
245              
246             my @diff = $diff->diff( $reference, $actual, %options );
247             my @diff = $diff->diff( $actual, %options );
248              
249             Performs the diff and returns an array of hashrefs with differences.
250              
251             =cut
252              
253 24     24 1 245 sub diff( $self, $actual_or_reference, $actual=undef, %options ) {
  24         74  
  24         59  
  24         56  
  24         47  
  24         43  
254 24   33     265 $options{ warn_on_newlines } //= $self->warn_on_newlines;
255 24   33     229 $options{ mode } //= $self->mode;
256              
257             # Downconvert things to strings, unless we have strings already
258             # reparse into HTTP::Request for easy structural checks
259              
260 24         40 my $ref;
261 24 100       95 if( $actual ) {
    50          
262 7 50       22 $ref = $actual_or_reference
263             or croak "Need a reference request";
264             } elsif( $actual_or_reference ) {
265 17 50       81 $ref = $self->reference
266             or croak "Need a reference request";
267 17 50 33     76 $actual = $actual_or_reference // $self->actual
268             or croak "Need an actual request to diff";
269             } else {
270 0 0       0 $ref = $self->reference
271             or croak "Need a reference request";
272 0 0       0 $actual = $self->actual
273             or croak "Need an actual request to diff";
274             }
275              
276 24 50       111 if( my $c = $self->canonicalize ) {
277 0 0       0 $ref = $c->( $ref )
278             or croak "Request canonicalizer returned no request";
279 0 0       0 $actual = $c->( $actual )
280             or croak "Request canonicalizer returned no request";
281             };
282              
283             # maybe cache that in our builder?!
284 24         102 my %ignore_diff = map {; "headers.$_" => 1 } $self->ignore_headers->@*;
  3         21  
285              
286             # maybe cache that in our builder?!
287 24         86 my %skip_header = map { $_ => 1 } $self->skip_headers->@*;
  1         6  
288              
289 24         68 for ($ref, $actual) {
290 48 100       967 if( ref $_ ) {
291 14 50       55 if( $_->can( 'as_string' )) {
    0          
    0          
292 14         205 $_ = $_->as_string("\r\n");
293              
294             } elsif( $_->can('to_string' )) {
295 0         0 $_ = $_->to_string("\r\n");
296              
297             } elsif( overload::Method($_, '""')) {
298 0         0 $_ = "$_";
299             } else {
300 0         0 croak "Don't know how to convert $_ to a string";
301             }
302             }
303             };
304              
305 24 50       714 if( $options{ warn_on_newlines }) {
306 24 50 66     384 cluck 'Reference input has bare newlines in header, not crlf'
307             if $ref =~ /\A(.*?)[\r]?\n[\r]?\n/ and $1 =~ /[^\r]\n/;
308 24 50 66     331 cluck 'Actual input has bare newlines in header, not crlf'
309             if $actual =~ /\A(.*?)[\r]?\n[\r]?\n/ and $1 =~ /[^\r]\n/;
310             };
311              
312 24         171 my $r_ref = HTTP::Request->parse( $ref );
313 24         26785 my $r_actual = HTTP::Request->parse( $actual );
314              
315 24         5017 my @diff;
316              
317             # get query parameter separator, and check these (strict)
318 24 100 100     159 if( $options{ mode } eq 'strict' and my $q = $r_ref->uri->query ) {
319 1 50       37 if( $q =~ /([&;])/ ) {
320 1         4 my $query_separator = $1;
321 1 50       5 if( my $q2 = $r_actual->uri->query ) {
322 1 50       25 if( $q2 =~ /([&;])/ ) {
323 1 50       6 if( $1 ne $query_separator ) {
324 1         8 push @diff, {
325             reference => $q,
326             actual => $q2,
327             type => 'meta.query_separator',
328             kind => 'value',
329             };
330             }
331             }
332             }
333             }
334             };
335              
336 24         127 my @ref_header_order = $self->get_request_header_names( $ref );
337 24         71 my @actual_header_order = $self->get_request_header_names( $actual );
338              
339 14         59 my @headers = map {; ("headers", $_) }
340 24         160 grep { ! $skip_header{ $_ } }
  15         67  
341             uniq( @ref_header_order,
342             @actual_header_order
343             );
344              
345 24         133 my @query_params = map {; ("query", $_) }
  8         1456  
346             uniq( $r_ref->uri->query_param,
347             $r_actual->uri->query_param,
348             );
349 24         1309 my @form_params;
350 24         69 my ($ref_params, $actual_params);
351 24 100 66     199 if( $self->mode eq 'semantic'
352             or $self->mode eq 'lax' ) {
353              
354 22 100 66     106 if( $r_ref->headers->content_type eq 'multipart/form-data'
    100 66        
355             and $r_actual->headers->content_type eq 'multipart/form-data'
356             ) {
357             # We've checked the content type already, we can ignore the boundary
358             # value for semantic checks
359 2         151 $ignore_diff{ 'headers.Content-Type' } = 1;
360              
361             # The content length will likely also differ, as we use different
362             # sizes for the boundary
363 2         6 $ignore_diff{ 'headers.Content-Length' } = 1;
364              
365 2         11 $ref_params = $self->get_form_parameters( $r_ref );
366 2         8 $actual_params = $self->get_form_parameters( $r_actual );
367              
368 2         41 @form_params = map {; ("form", $_) }
  4         16  
369             uniq( keys( $ref_params->%* ),
370             keys( $actual_params->%*),
371             );
372              
373             } elsif( $r_actual->headers->content_type eq 'application/x-www-form-urlencoded'
374             and $r_actual->headers->content_type eq 'application/x-www-form-urlencoded'
375             ) {
376             # We've checked the content type already, we can ignore the boundary
377             # value for semantic checks
378 1         101 $ignore_diff{ 'headers.Content-Type' } = 1;
379              
380             # Handle %20 vs +
381 1         4 my $force_percent_encoding = ($r_ref->headers->content_length != $r_actual->headers->content_length);
382              
383 1 50       147 if( $force_percent_encoding ) {
384 1         3 for my $req ($r_ref, $r_actual) {
385 2         8 my $body = $req->content();
386 2 50       33 if( $body =~ s!\+!%20!g ) {
387 0         0 $ignore_diff{ 'header.Content-Length' } = 1;
388 0         0 $req->content( $body );
389             }
390             };
391             };
392              
393             }
394             };
395 24         813 my @check = ($self->compare->@*, @headers, @query_params, @form_params);
396              
397 24 100       69 if( !@form_params ) {
398 22         98 push @check, 'request' => 'content';
399             };
400              
401 24 100       102 if( $self->mode eq 'strict' ) {
402 2         9 push @check, 'request' => 'header_order';
403             }
404              
405             # also, we should check for cookies
406              
407 24         354 for my $p (pairs @check) {
408              
409 146         265 my $ref_v;
410             my $actual_v;
411              
412 146 100       604 if( $p->value eq 'header_order' ) {
413 2         6 $ref_v = \@ref_header_order;
414 2         7 $actual_v = \@actual_header_order;
415              
416             } else {
417 144         347 $ref_v = $self->fetch_value( $r_ref, $p, $ref_params );
418 144         3395 $actual_v = $self->fetch_value( $r_actual, $p, $actual_params );
419             }
420              
421 146         3037 my $type = sprintf( '%s.%s', @$p );
422              
423 146 100 100     1176 if( (defined $ref_v xor defined $actual_v)) {
    100 66        
    100          
    100          
424             # One is missing
425              
426             # semantic/lax: If Content-Length is missing, it is equivalent
427             # to Content-Length: 0
428              
429 4 50 33     75 if( ($self->mode eq 'lax' or $self->mode eq 'semantic')
      66        
      50        
      66        
      50        
      66        
430             and $type eq 'headers.Content-Length'
431             and ($ref_v // 0 )== 0 and ($actual_v // 0) == 0) {
432             # ignore
433             } else {
434              
435 4         46 push @diff, {
436             reference => $ref_v,
437             actual => $actual_v,
438             type => $type,
439             kind => 'missing',
440             };
441             };
442              
443             } elsif( ref $ref_v ) {
444             # Here we have a list of values, let's check if the lists
445             # of values match
446 14         147 my $diff = Algorithm::Diff->new( $ref_v, $actual_v );
447 14         3127 my $diff_type;
448             my @ref;
449 14         0 my @act;
450              
451 14         68 while( $diff->Next() ) {
452 18 100       556 if( $diff->Same() ) {
    100          
    100          
453 8         237 push @ref, $diff->Items(1);
454 8         254 push @act, $diff->Items(2);
455              
456             } elsif( !$diff->Items(2) ) {
457 4         152 push @ref, $diff->Items(1);
458 4         187 push @act, (undef) x scalar($diff->Items(1));
459 4   50     90 $diff_type //= 'missing';
460              
461             } elsif( !$diff->Items(1) ) {
462 3         147 push @ref, (undef) x scalar($diff->Items(2));
463 3         53 push @act, $diff->Items(2);
464 3   100     72 $diff_type //= 'missing';
465              
466             } else {
467 3         172 my $count = max( scalar $diff->Items(1), scalar $diff->Items(2));
468 3         128 push @ref, $diff->Items(1);
469 3         76 push @ref, (undef) x (scalar($diff->Items(2)) - $count);
470 3         55 push @act, $diff->Items(2);
471 3         62 push @act, (undef) x (scalar($diff->Items(1)) - $count);
472              
473 3         68 $diff_type = 'value';
474             }
475             };
476              
477 14 100       513 if( $diff_type ) {
478             # Do we really want to downconvert to strings?!
479             #my $ref_diff = join "\n", @ref;
480             #my $actual_diff = join "\n", @act;
481 8         22 my $ref_diff = \@ref;
482 8         18 my $actual_diff = \@act;
483 8         160 push @diff, {
484             reference => $ref_diff,
485             actual => $actual_diff,
486             type => sprintf( '%s.%s', @$p ),
487             kind => $diff_type,
488             };
489             };
490              
491             } elsif( !defined $ref_v and !defined $actual_v ) {
492             # neither value exists
493              
494             } elsif( $ref_v ne $actual_v ) {
495 12         35 my $type = sprintf( '%s.%s', @$p );
496 12 100       43 if( ! $ignore_diff{ $type }) {
497 9         71 push @diff, {
498             reference => $ref_v,
499             actual => $actual_v,
500             type => $type,
501             kind => 'value',
502             };
503             }
504             };
505             }
506             # parameters switching between body and query string
507             # if( $ref->headers->content_type eq '' ) {
508             # compare form values
509             # } else {
510             # compare request body
511             # }
512              
513 24         517 return @diff;
514             }
515              
516             =head2 C<< ->as_table( @diff ) >>
517              
518             my @diff = $diff->diff( $request1, $request2 );
519             print $diff->as_table( @diff );
520             # +-----------------+-----------+--------+
521             # | Type | Reference | Actual |
522             # +-----------------+-----------+--------+
523             # | request.content | Ümloud | Umloud |
524             # +-----------------+-----------+--------+
525              
526             Renders a diff as a table, using L.
527              
528             =cut
529              
530 4     4 1 12 sub as_table($self,@diff) {
  4         5  
  4         4  
  4         4  
531 4         20 require Term::Table;
532              
533 4 50       9 if( @diff ) {
534             my $t = Term::Table->new(
535             allow_overflow => 1,
536             header => ['Type', 'Reference', 'Actual'],
537             rows => [
538 4         10 map {[ $_->{type},
539 1   50     6 ref $_->{reference} ? join "\n", map { $_ // '' } $_->{reference}->@* : $_->{reference} // '',
540 4 100 50     35 ref $_->{actual} ? join "\n", map { $_ // '' } $_->{actual}->@* : $_->{actual} // '',
  1 100 50     7  
      100        
541             ]} @diff
542             ],
543             );
544 4         190 return join "\n", $t->render;
545             };
546             }
547              
548             1;
549              
550             __END__