Implementing WWW::LastFM with XML::Rabbit - Part 2
- Part 1 of 5
- Part 2 of 5
- Part 3 of 5
- Part 4 of 5
- Part 5 of 5
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 attributeisa
parameter is automatically set toStr
. The attribute native trait is automatically set toString
.
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.