use
5.020;
our
$VERSION
=
'0.07'
;
no
warnings
'experimental::signatures'
;
use
Carp
'croak'
,
'cluck'
;
has
'mode'
=> (
is
=>
'ro'
,
default
=>
'semantic'
,
);
has
'reference'
=> (
is
=>
'ro'
,
);
has
'skip_headers'
=> (
is
=>
'ro'
,
default
=>
sub
{ [] },
);
has
'ignore_headers'
=> (
is
=>
'ro'
,
default
=>
sub
{ [] },
);
has
'canonicalize'
=> (
is
=>
'ro'
,
);
has
'compare'
=> (
is
=>
'ro'
,
default
=>
sub
{
return
[
request
=>
'method'
,
uri
=>
'host'
,
uri
=>
'port'
,
uri
=>
'path'
,
];
},
);
has
'warn_on_newlines'
=> (
is
=>
'rw'
,
default
=> 1
);
sub
fetch_value(
$self
,
$req
,
$item
,
$req_params
=
undef
) {
my
$obj
;
if
(
$item
->key eq
'request'
) {
my
$v
=
$item
->value;
return
$req
->
$v
;
}
elsif
(
$item
->key eq
'headers'
) {
return
$req
->headers->header(
$item
->value );
}
elsif
(
$item
->key eq
'query'
) {
return
[
$req
->uri->query_param(
$item
->value )];
}
elsif
(
$item
->key eq
'uri'
) {
my
$u
=
$req
->uri;
if
(
my
$c
=
$u
->can(
$item
->value )) {
return
$c
->(
$u
)
}
else
{
return
}
}
elsif
(
$item
->key eq
'form'
) {
return
$req_params
->{
$item
->value };
}
else
{
croak
sprintf
"Unknown key '%s'"
,
$item
->key;
}
}
sub
get_form_parameters(
$self
,
$req
) {
my
(
undef
,
$boundary
) =
$req
->headers->content_type;
my
$str
=
$req
->content;
$boundary
=~ s!^boundary=!!;
my
%res
;
my
$res
= parse_multipart_form_data( \
$str
,
length
(
$str
),
$boundary
);
if
( !
$res
) {
croak
"Malformed form data"
;
}
for
my
$p
(
$res
->@*) {
$res
{
$p
->{name} } //= [];
push
$res
{
$p
->{name}}->@*,
$p
->{content};
};
return
\
%res
;
}
sub
get_request_header_names(
$self
,
$req
) {
if
(
$req
=~ /\n/ ) {
my
(
$header
) =
$req
=~ m/^(.*?)\r?\n\r?\n/ms
or croak
"No header in request <$req>"
;
my
@headers
= (
$header
=~ /^([A-Za-z][A-Za-z\d-]+):/mg);
return
@headers
;
}
else
{
return
}
}
sub
diff(
$self
,
$actual_or_reference
,
$actual
=
undef
,
%options
) {
$options
{ warn_on_newlines } //=
$self
->warn_on_newlines;
$options
{ mode } //=
$self
->mode;
my
$ref
;
if
(
$actual
) {
$ref
=
$actual_or_reference
or croak
"Need a reference request"
;
}
elsif
(
$actual_or_reference
) {
$ref
=
$self
->reference
or croak
"Need a reference request"
;
$actual
=
$actual_or_reference
//
$self
->actual
or croak
"Need an actual request to diff"
;
}
else
{
$ref
=
$self
->reference
or croak
"Need a reference request"
;
$actual
=
$self
->actual
or croak
"Need an actual request to diff"
;
}
if
(
my
$c
=
$self
->canonicalize ) {
$ref
=
$c
->(
$ref
)
or croak
"Request canonicalizer returned no request"
;
$actual
=
$c
->(
$actual
)
or croak
"Request canonicalizer returned no request"
;
};
my
%ignore_diff
=
map
{;
"headers.$_"
=> 1 }
$self
->ignore_headers->@*;
my
%skip_header
=
map
{
$_
=> 1 }
$self
->skip_headers->@*;
for
(
$ref
,
$actual
) {
if
(
ref
$_
) {
if
(
$_
->can(
'as_string'
)) {
$_
=
$_
->as_string(
"\r\n"
);
}
elsif
(
$_
->can(
'to_string'
)) {
$_
=
$_
->to_string(
"\r\n"
);
}
elsif
( overload::Method(
$_
,
'""'
)) {
$_
=
"$_"
;
}
else
{
croak
"Don't know how to convert $_ to a string"
;
}
}
};
if
(
$options
{ warn_on_newlines }) {
cluck
'Reference input has bare newlines in header, not crlf'
if
$ref
=~ /\A(.*?)[\r]?\n[\r]?\n/ and $1 =~ /[^\r]\n/;
cluck
'Actual input has bare newlines in header, not crlf'
if
$actual
=~ /\A(.*?)[\r]?\n[\r]?\n/ and $1 =~ /[^\r]\n/;
};
my
$r_ref
= HTTP::Request->parse(
$ref
);
my
$r_actual
= HTTP::Request->parse(
$actual
);
my
@diff
;
if
(
$options
{ mode } eq
'strict'
and
my
$q
=
$r_ref
->uri->query ) {
if
(
$q
=~ /([&;])/ ) {
my
$query_separator
= $1;
if
(
my
$q2
=
$r_actual
->uri->query ) {
if
(
$q2
=~ /([&;])/ ) {
if
( $1 ne
$query_separator
) {
push
@diff
, {
reference
=>
$q
,
actual
=>
$q2
,
type
=>
'meta.query_separator'
,
kind
=>
'value'
,
};
}
}
}
}
};
my
@ref_header_order
=
$self
->get_request_header_names(
$ref
);
my
@actual_header_order
=
$self
->get_request_header_names(
$actual
);
my
@headers
=
map
{; (
"headers"
,
$_
) }
grep
{ !
$skip_header
{
$_
} }
uniq(
@ref_header_order
,
@actual_header_order
);
my
@query_params
=
map
{; (
"query"
,
$_
) }
uniq(
$r_ref
->uri->query_param,
$r_actual
->uri->query_param,
);
my
@form_params
;
my
(
$ref_params
,
$actual_params
);
if
(
$self
->mode eq
'semantic'
or
$self
->mode eq
'lax'
) {
if
(
$r_ref
->headers->content_type eq
'multipart/form-data'
and
$r_actual
->headers->content_type eq
'multipart/form-data'
) {
$ignore_diff
{
'headers.Content-Type'
} = 1;
$ignore_diff
{
'headers.Content-Length'
} = 1;
$ref_params
=
$self
->get_form_parameters(
$r_ref
);
$actual_params
=
$self
->get_form_parameters(
$r_actual
);
@form_params
=
map
{; (
"form"
,
$_
) }
uniq(
keys
(
$ref_params
->%* ),
keys
(
$actual_params
->%*),
);
}
elsif
(
$r_actual
->headers->content_type eq
'application/x-www-form-urlencoded'
and
$r_actual
->headers->content_type eq
'application/x-www-form-urlencoded'
) {
$ignore_diff
{
'headers.Content-Type'
} = 1;
my
$force_percent_encoding
= (
$r_ref
->headers->content_length !=
$r_actual
->headers->content_length);
if
(
$force_percent_encoding
) {
for
my
$req
(
$r_ref
,
$r_actual
) {
my
$body
=
$req
->content();
if
(
$body
=~ s!\+!%20!g ) {
$ignore_diff
{
'header.Content-Length'
} = 1;
$req
->content(
$body
);
}
};
};
}
};
my
@check
= (
$self
->compare->@*,
@headers
,
@query_params
,
@form_params
);
if
( !
@form_params
) {
push
@check
,
'request'
=>
'content'
;
};
if
(
$self
->mode eq
'strict'
) {
push
@check
,
'request'
=>
'header_order'
;
}
for
my
$p
(pairs
@check
) {
my
$ref_v
;
my
$actual_v
;
if
(
$p
->value eq
'header_order'
) {
$ref_v
= \
@ref_header_order
;
$actual_v
= \
@actual_header_order
;
}
else
{
$ref_v
=
$self
->fetch_value(
$r_ref
,
$p
,
$ref_params
);
$actual_v
=
$self
->fetch_value(
$r_actual
,
$p
,
$actual_params
);
}
my
$type
=
sprintf
(
'%s.%s'
,
@$p
);
if
( (
defined
$ref_v
xor
defined
$actual_v
)) {
if
( (
$self
->mode eq
'lax'
or
$self
->mode eq
'semantic'
)
and
$type
eq
'headers.Content-Length'
and (
$ref_v
// 0 )== 0 and (
$actual_v
// 0) == 0) {
}
else
{
push
@diff
, {
reference
=>
$ref_v
,
actual
=>
$actual_v
,
type
=>
$type
,
kind
=>
'missing'
,
};
};
}
elsif
(
ref
$ref_v
) {
my
$diff
= Algorithm::Diff->new(
$ref_v
,
$actual_v
);
my
$diff_type
;
my
@ref
;
my
@act
;
while
(
$diff
->Next() ) {
if
(
$diff
->Same() ) {
push
@ref
,
$diff
->Items(1);
push
@act
,
$diff
->Items(2);
}
elsif
( !
$diff
->Items(2) ) {
push
@ref
,
$diff
->Items(1);
push
@act
, (
undef
) x
scalar
(
$diff
->Items(1));
$diff_type
//=
'missing'
;
}
elsif
( !
$diff
->Items(1) ) {
push
@ref
, (
undef
) x
scalar
(
$diff
->Items(2));
push
@act
,
$diff
->Items(2);
$diff_type
//=
'missing'
;
}
else
{
my
$count
= max(
scalar
$diff
->Items(1),
scalar
$diff
->Items(2));
push
@ref
,
$diff
->Items(1);
push
@ref
, (
undef
) x (
scalar
(
$diff
->Items(2)) -
$count
);
push
@act
,
$diff
->Items(2);
push
@act
, (
undef
) x (
scalar
(
$diff
->Items(1)) -
$count
);
$diff_type
=
'value'
;
}
};
if
(
$diff_type
) {
my
$ref_diff
= \
@ref
;
my
$actual_diff
= \
@act
;
push
@diff
, {
reference
=>
$ref_diff
,
actual
=>
$actual_diff
,
type
=>
sprintf
(
'%s.%s'
,
@$p
),
kind
=>
$diff_type
,
};
};
}
elsif
( !
defined
$ref_v
and !
defined
$actual_v
) {
}
elsif
(
$ref_v
ne
$actual_v
) {
my
$type
=
sprintf
(
'%s.%s'
,
@$p
);
if
( !
$ignore_diff
{
$type
}) {
push
@diff
, {
reference
=>
$ref_v
,
actual
=>
$actual_v
,
type
=>
$type
,
kind
=>
'value'
,
};
}
};
}
return
@diff
;
}
sub
as_table(
$self
,
@diff
) {
if
(
@diff
) {
my
$t
= Term::Table->new(
allow_overflow
=> 1,
header
=> [
'Type'
,
'Reference'
,
'Actual'
],
rows
=> [
map
{[
$_
->{type},
ref
$_
->{reference} ?
join
"\n"
,
map
{
$_
//
'<missing>'
}
$_
->{reference}->@* :
$_
->{reference} //
'<missing>'
,
ref
$_
->{actual} ?
join
"\n"
,
map
{
$_
//
'<missing>'
}
$_
->{actual}->@* :
$_
->{actual} //
'<missing>'
,
]}
@diff
],
);
return
join
"\n"
,
$t
->render;
};
}
1;