The London Perl and Raku Workshop takes place on 26th Oct 2024. If your company depends on Perl, please consider sponsoring and/or attending.
package SOAP::Transport::HTTP::Log4perl;

=head1 NAME

SOAP::Transport::HTTP::Log4perl - SOAP::Lite plugin that adds Log4perl traces

=head1 SYNOPSIS

    use Log::Log4perl qw(:easy);
    
    # Load this module and request to send all log messages through the logger 'mysoap.soap'
    use SOAP::Transport::HTTP::Log4perl logger => 'myapp.soap';
    
    # Load SOAP lite *AFTER* this module
    use SOAP::Lite 
        uri   => 'http://www.soaplite.com/Server',
        proxy => 'http://localhost:8080/',
    ;
    
    # Initialize log4perl
    Log::Log4perl->easy_init($TRACE);
    my $LOG = Log::Log4perl->get_logger("myapp.client");
    $LOG->info("Program start");
    
    # Make a SOAP::Lite call and watch for the logs
    my $value = SOAP::Lite->new->test(1)->result;

=head1 DESCRIPTION

This module logs all L<SOAP::Lite>'s messages (requests and responses) through
L<Log::Log4perl>. The module works by changing SOAP::Lite's default HTTP client,
which means that the module has to be loaded before SOAP::Lite is first loaded.

It's a simple debugging tool that provides a good overview of what's been
sent and received by L<SOAP::Lite> that can be quite handy when dealing with
services over secure channels (HTTPS) or when going through corporate a proxy.

This module is more of a proof of concept that can be used by anyone that wants
to have a closer understanding of what's being passed through the network.

=head1 DISCLAIMER

This module takes no approach regarding privacy and simply dumps everything that's
sent and received. If you need to remove sensitive data (passwords, personal information)
make sure that you modify the module or don't log the data into a persistent appender.

This module was developed for debugging purposes where privacy is not a concern.

=head1 API

=head2 import

This module can be configured through the import mechanism.

To change the name of the logger (I<soap>) used simply provide a name through
the parameter I<logger>:

    use SOAP::Transport::HTTP::Log4perl logger => 'app.soap';

=head1 AUTHOR

Emmanuel Rodriguez E<lt>potyl@cpan.orgE<gt>

=head1 COPYRIGHT

(C) 2010 Emmanuel Rodriguez

=cut

use strict;
use warnings;

use base 'LWP::UserAgent';

use Log::Log4perl ':nowarn';

use XML::LibXML;
use LWP;
use SOAP::Lite;
use SOAP::Transport::HTTP;

# Register this class as being the LWP::UserAgent instance to use for all SOAP communication.
BEGIN {
    $SOAP::Transport::HTTP::Client::USERAGENT_CLASS = __PACKAGE__;
}

our $VERSION = '0.01';

# We get a default logger, the function import allows us to redefine the name
# of the logger through:
#  use SOAP::Transport::HTTP::Log4perl logger => 'SOAP.transport';
my $LOG = Log::Log4perl->get_logger('soap');


sub import {
    my $class = shift;
    my %args = @_;

    if (my $name = $args{logger}) {
        $LOG = Log::Log4perl->get_logger($name);
    }
}


#
# Pretty print the SOAP envelopes with nicely formatted XML.
#
sub request {
    my $self = shift;
    my ($request) = @_;
    
    _log_message($request);

    # Ask our parent to perform the real SOAP call
    my $response = $self->SUPER::request($request);
    
    _log_message($response);
    
    return $response;
}


#
# Logs an HTTP message (request or response) through log4perl.
#
sub _log_message {
    my ($message) = @_;
    my $content_type = $message->content_type;
    my $content = $message->decoded_content;

    $LOG->trace("SOAP HTTP Headers:\n", $message->headers_as_string);

    my $is_response = 0; # We want to inspect responses for faults
    if (_isa_http_type($message, 'request')) {
        # SOAP requests sent through SOAP::Lite don't have a content type.
        $content_type = 'text/xml' if $content_type eq '';
    }
    elsif (_isa_http_type($message, 'response')) {
        $is_response = 1;
    }
    else {
        $LOG->error("Unknwon SOAP message type ", ref($message), " with content:\n", $content);
        return;
    }

    # Ideally we will only deal with xml but Google can send HTML responses
    # sometimes. In that case we pretty format the response too and assume that
    # it's an error.
    my $is_html = 0;
    if ($content_type eq 'text/xml') {
        # XML is what we expect, nothing to do here at this moment
    }
    elsif ($content_type eq 'text/html') {
        # We will use the HTML parser and issue an error later on
        $is_html = 1;
    }
    else {
        $LOG->error("SOAP message not in xml ($content_type), content:\n$content");
        return;
    }


    # Pretty print the content. We use LibXML's toString function, so the idea
    # is to parse the SOAP message and print the DOM tree. That's how pretty
    # print is done. Once we have a DOM object we can then look for faults in
    # the document too!
    my $parser = XML::LibXML->new();
    $parser->load_ext_dtd(0);
    $parser->validation(0);
    $parser->pedantic_parser(0);

    my $dom;
    eval {
        if ($is_html) {
            $dom = $parser->parse_html_string($content);
        }
        else {
            $dom = $parser->parse_string($content);
        }
        1;
    } or do {
        $LOG->error("Got error $@ when parsing:\n$content");
        return;
    };
    my $pretty = $dom->toString(1);


    if ($is_html) {
        # That's no legit SOAP response!
        $LOG->error("SOAP HTML:\n", $pretty);
        return;
    }


    # In the case of a response check if we have a fault
    if ($is_response) {
        # NOTE: I was using a very old version of XML::LibXML which doesn't support namespaces!
        my $result = $dom->find('/*[local-name() = "Envelope"]/*[local-name() = "Body"]/*[local-name() = "Fault"]');
        if ($result) {
            $LOG->warn("SOAP Fault:\n", $pretty);
            return;
        }

        $LOG->debug("SOAP Response:\n", $pretty);
        return;
    }


    $LOG->debug("SOAP Request:\n", $pretty);
}


# Returns true if the given message is of the given type.
# Ex: $message is a HTTP::Response
sub _isa_http_type {
    my ($message, $type) = @_;
    $type = ucfirst lc $type;
    return UNIVERSAL::isa($message, "HTTP::$type");
}


1;