Implementing WWW::LastFM with XML::Rabbit - Part 2

In the previous article we created a simple framework for making HTTP requests to the Last.FM API. In this article we'll go into detail on how XML::Rabbit can help us to extract the information we want from the XML output we saw in the previous article. I've added that XML output here, for your convenience.

<?xml version="1.0" encoding="utf-8"?>
<lfm status="ok">
<metros>
    <metro>
        <name>Sydney</name>
                <country>Australia</country>
    </metro>
...snip...
    <metro>
        <name>Wichita</name>
                <country>United States</country>
    </metro>
</metros></lfm> 

Implementing the geo.getMetros API call

Add the following code at the top of lib/WWW/LastFM.pm to load the WWW::LastFM::API::Geo module (which we'll flesh out in a moment).

use WWW::LastFM::API::Geo;

Next we add another attribute so we can get easy access to the Geo class. Add at the bottom:

# API modules
has 'geo' => (
    is         => 'ro',
    isa        => 'WWW::LastFM::API::Geo',
    lazy_build => 1,
);

sub _build_geo {
    my ($self) = @_;
    return WWW::LastFM::API::Geo->new( lastfm => $self );
}

The next part is to create the basic skeleton that allows us to make API calls with convenience. Add the following code to lib/WWW/LastFM/API/Geo.pm:

package WWW::LastFM::API::Geo;
use Moose;
use namespace::autoclean;

use URI::Escape;

has 'lastfm' => (
    is       => 'ro',
    isa      => 'WWW::LastFM',
    required => 1,
);

# http://www.last.fm/api/show?service=435
# $country must be a binary encoded utf8 string

sub get_metros {
    my ($self, $country) = @_;
    return $self->lastfm->get(
           $self->lastfm->api_root_url
        . '?method=geo.getMetros'
        . ( $country ? '&country=' . uri_escape($country) : "" )
        . '&api_key=' . $self->lastfm->api_key
    );
}

__PACKAGE__->meta->make_immutable();

1;

We perform another one-liner to verify that we're on the right track.

$ perl -Ilib -MWWW::LastFM -E 'say WWW::LastFM->new->geo->get_metros'

What is interesting to see is that we've forwarded the instance of WWW::LastFM into the API class to avoid using global variables to hold our shared state. But let's see if we can't do something with that ugly XML.

Dealing with the root element in the response XML

Let's have a closer look at the XML returned.

<?xml version="1.0" encoding="utf-8"?>
<lfm status="ok">
<metros>
    <metro>
        <name>Sydney</name>
                <country>Australia</country>
    </metro>
... <snip> ...
    <metro>
        <name>Wichita</name>
                <country>United States</country>
    </metro>
</metros></lfm>

What we can see is that Last.FM always wraps its response in a <lfm/> root element, as described in API REST requests. It holds one attribute named status, and it's value is either ok or failed. So we're dealing with a boolean here. If the request failed it will contain an error code, like this:

<?xml version="1.0" encoding="utf-8"?>
<lfm status="failed">
    <error code="10">Invalid API Key</error>
</lfm>

So let's see if we can encapsulate this behaviour in a class with a nice API. Let's first change our get_metros method to return this response class instead of some boring XML. Edit lib/WWW/LastFM/API/Geo.pm and add this after the other imports:

use WWW::LastFM::Response;

And change the get_metros method into this:

sub get_metros {
    my ($self, $country) = @_;
    my $xml = $self->lastfm->get(
           $self->lastfm->api_root_url
        . '?method=geo.getMetros'
        . ( $country ? '&country=' . uri_escape($country) : "" )
        . '&api_key=' . $self->lastfm->api_key
    );
    return WWW::LastFM::Response->new( xml => $xml );
}

So what should this WWW::LastFM::Response class look like? What we know is that it should require some XML to work on, and if it doesn't get that it should blow up with an error message (we want it to throw an exception of some kind). We also want to know if the response failed or not, and if it failed, we should throw an exception as well. If everything is okay, we should be able to get to those metros. Create lib/WWW/LastFM/Response.pm with this content:

package WWW::LastFM::Response;
use XML::Rabbit::Root 0.1.0;

sub BUILD {
    my ($self) = @_;
    return if $self->is_success;
    confess("Last.FM response error " . $self->error_code . ": " . $self->error);
}

has 'is_success' => (
    is         => 'ro',
    isa        => 'Bool',
    lazy_build => 1,
);

sub _build_is_success {
    my ($self) = @_;
    return unless $self->status eq 'ok';
    return 1;
}

has_xpath_value 'status'     => '/lfm/@status';
has_xpath_value 'error'      => '/lfm/error';
has_xpath_value 'error_code' => '/lfm/error/@code';

has_xpath_object 'metros' => '/lfm/metros' => 'WWW::LastFM::Response::MetroList';

finalize_class();

You can test it immediately to see if it works as expected:

$ perl -Ilib -MWWW::LastFM -E 'say WWW::LastFM->new->geo->get_metros->status'

You should get a string that says ok (unless you get an error because the Last.FM API server refuses to answer). If you want to force an error condition, try it out with an invalid API key.

$ perl -Ilib -MWWW::LastFM -E 'say WWW::LastFM->new( api_key => "1234" )->geo->get_metros->status'
Last.FM response error 10: Invalid API key - You must be granted a valid key by last.fm at ...

I snipped the rest of the stack trace, as it is of no interest at this point. We know where we made a mistake.

How XML::Rabbit::Root simplifies dealing with the XML document data

Let's get into detail on how that class definition works.

Using XML::Rabbit::Root does a whole lot of things behind the scenes. It is the equivalent of doing this:

use Moose;
with "XML::Rabbit::RootNode";
use namespace::autoclean;
use XML::Rabbit::Sugar;

The finalize_class() at the bottom is the equivalent of __PACKAGE__->meta->make_immutable(); 1;, which ensures the class executes as fast as possible during runtime and that the file loaded returns a true value, as required by the perl parser. So with that out of the way, let's take a look at the meat of the class and what that imported sugar, has_xpath_value and has_xpath_object, represents.

The first thing to notice is that there is no xml attribute declared in the class. This parameter comes from XML::Rabbit::Role::Document. It is automatically available, and you must specify either file, fh, xml or dom, based on what kind of format your XML document is in. As we have it in a string, we use the xml parameter.

The BUILD method is a special method Moose will automatically call after it has constructed an instance. In our case, we use it to verify the state of our instance. If the attribute is_success is true we just return, as everything is okay. If it is not, we throw an exception with the error code and text from the XML. This is how we can easily make an unwanted value in an attribute cause the entire construction of the object to fail.

The is_success attribute is pretty straight-forward. We take a string value that represents the status and checks if it is negative (that is, NOT the value ok). If that is the case, we return false, otherwise things must be okay and we can return a true value. Notice that I use a guard clause to fail early. This is a best practice to avoid the dreaded arrow anti-pattern.

So what does the has_xpath_value 'status' => '//lfm/@status'; declaration actually mean? Let's have a look in XML::Rabbit::Sugar and see if we can't figure it out. It says:

has_xpath_value($attr_name, $xpath_query, @moose_params)
Extracts a single string according to the xpath query specified. The attribute isa parameter is automatically set to Str. The attribute native trait is automatically set to String.

Okay, so this code creates a Moose attribute with an isa parameter of Str that will actually represent the value of the status attribute on the lfm root element in the XML document. Take a look at an XPath tutorial if you're unfamiliar with the syntax. If you need some additional Moose attribute parameters, you can specify them at the end. In our case there is no need for any. The error and error_code attribute is just the same thing.

So what is this has_xpath_object thing? It represents exactly the same thing as has_xpath_value, but instead of being a string value, it is an object of the specified class. The metros attribute will represent the list of metros returned in the XML document. I was thinking about naming the attribute metro_list, to avoid nouns in plural, but decided to keep it in plural because it more closely matches the XML document layout. To avoid putting too much responsibility inside WWW::LastFM::Response, I've delegated dealing with this list of metros to another class. This follows object orientation best practices, which states that each class should have a clear and defined purpose.

In the next article we'll dive into the details on how this MetroList class is implemented.