Implementing WWW::LastFM, a client library to the Last.FM API, with XML::Rabbit

In this series of articles I'm going to implement a client to the Last.FM web services API which allows us to find concerts and other events in your local area. We'll use a CPAN module I've created called XML::Rabbit to deal with all the mundane details of XML document handling.

Most of you probably don't like XML a lot. JSON is the new kid on the block, and dealing with all the crummy details of XML encoding, parsing and such is boring. Writing long incantations of XML::LibXML code to extract the required data is just something you would prefer not to do (unless you get paid for it, maybe not even then). I'm going to show you a way to deal with XML that requires a lot less boilerplate code than you're most likely used to. Hopefully it will make dealing with XML-based APIs a lot more fun for you. In the process of showing you how to deal with XML with ease I'll also implement a simple, but extensible, framework for communicating with the Last.FM API. You can use the same framework design to build client libraries against other HTTP-based APIs. My hope is that the code I show you will inspire you to work with me on this particular Last.FM API or create clients for other interesting APIs.

I have done my best to follow good Perl programming practices, as advocated by chromatic's Modern Perl book, Damian Conway's Perl Best Practices book, and the Moose Manual. I've also separated as much responsibility as possible into separate classes, attributes and methods. This should make it easier to create good tests that require a minimum amount of mocking to test both positive and negative failure scenarios.

Getting a Last.FM API key and secret

If you want to follow along, you'll need to get an API key from http://www.last.fm/api/account. Just fill out the required fields with something useful to your person and get your API key and secret. When you have that, stick it in a file in your home directory called .lastfm.ini.

The contents should be something like this:

[API]
key = 012345678948bb4c75ff9608aac4fe83
secret = abcdef57349f6ad7e9959a63aa472

That should ensure that the configurable information is tucked away in a personal file outside any code repository.

On Windows the correct directory will probably be C:\Users\<username>\AppData\Local. Run the following command to figure out the correct location:

$ perl -MFile::HomeDir -E "say File::HomeDir->my_data";

If you don't feel like registering, you can actually use the API key used in the examples on the Last.FM API page, currently b25b959554ed76058ac220b7b2e0a026. I'm not sure how long it will work, but as we're not going to touch any of the authenticated API calls you won't need the API secret quite yet.

Setting up your environment

If you want to follow along without typing in all the code, look at the WWW-LastFM github repository. The project uses Dist::Zilla, so installing all the dependencies should be as easy as running the following commands:

$ cpan Dist::Zilla
$ git clone git://github.com/robinsmidsrod/WWW-LastFM.git
$ cd WWW-LastFM
$ dzil authordeps | xargs cpan # or dzil authordeps | cpanm
$ dzil listdeps | xargs cpan   # or dzil listdeps | cpanm

The entry point class

Okay, now we're ready to dive in!

Let's create some basic code to read that config file and get access to our API key and secret. Create the file lib/WWW/LastFM.pm with this content:

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

use File::HomeDir;
use Path::Class::Dir;
use Config::Any;

# Sometimes we like some extra debugging output
has 'debug' => (
    is      => 'ro',
    isa     => 'Bool',
    default => 1,
);

# Standard stuff to read our config file
has 'config_file' => (
    is         => 'ro',
    isa        => 'Path::Class::File',
    lazy_build => 1,
);

sub _build_config_file {
    my ($self) = @_;
    my $home = File::HomeDir->my_data;
    my $conf_file = Path::Class::Dir->new($home)->file('.lastfm.ini');
    return $conf_file;
}

# This is where our config file data ends up
has 'config' => (
    is         => 'ro',
    isa        => 'HashRef',
    lazy_build => 1,
);

sub _build_config {
    my ($self) = @_;
    my $cfg = Config::Any->load_files({
        use_ext => 1,
        files   => [ $self->config_file ],
    });
    foreach my $config_entry ( @{ $cfg } ) {
        my ($filename, $config) = %{ $config_entry };
        warn("Loaded config from file: $filename\n") if $self->debug;
        return $config;
    }
    return {};
}

# And here we have our api key and secret
has 'api_key' => (
    is         => 'ro',
    isa        => 'Str',
    lazy_build => 1,
);

sub _build_api_key { return (shift)->config->{'API'}->{'key'}; }

has 'api_secret' => (
    is         => 'ro',
    isa        => 'Str',
    lazy_build => 1,
);

sub _build_api_secret { return (shift)->config->{'API'}->{'secret'}; }

__PACKAGE__->meta->make_immutable();

1;

At this point you should be able to do read your API key from your config file with this small one-liner.

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

This code is not really anything special. It's just a bunch of best-of-breed modules used to read an INI file in a dynamic directory based on your platform and putting its contents into a hash reference. Notice the use of lazy Moose attributes to transform your data from a filename, .lastfm.ini, to a specific value, api_key, one step at a time. Also notice that we do not need to use strict and warnings, as Moose takes care of that for us.

The next thing we need is a way to make requests, so let's use the tried and true LWP::UserAgent. Add this at the top of the file after the other imports:

use LWP::UserAgent;

our $VERSION = "0.0.1";

And then continue and add the api_root_url and ua attribute that contains our HTTP client.

# All access to the Last.FM API starts with this URL
has 'api_root_url' => (
    is      => 'ro',
    isa     => 'Str',
    default => 'http://ws.audioscrobbler.com/2.0/',
);

# And finally our HTTP client that we will use to make requests
has 'ua' => (
    is         => 'ro',
    isa        => 'LWP::UserAgent',
    lazy_build => 1,
);

sub _build_ua {
    my ($self) = @_;
    return LWP::UserAgent->new( agent => 'WWW::LastFM/' . $VERSION );
}

# A utility method for making requests, returns raw XML
# or throws exception if no content was generated
sub get {
    my ($self, $url) = @_;
    confess("No URL specified") unless $url;
    my $response = $self->ua->get($url);
    my $content = $response->content;
    confess("HTTP error: " . $response->status_line) unless defined $content;
    confess("HTTP error: " . $response->status_line) if $response->code >= 500;
    return $content;
}

We've also added a basic get method that fetches the data on the specified URL and returns the raw content (bytes). This should make it trivial to make API calls and get back XML we can work with.

The geo.getMetros API call

To test out that our HTTP client works, we can make another one-liner that fetches the locations (metros) the Last.FM service knows about.

$ perl -Ilib -MWWW::LastFM -E 'my $lfm = WWW::LastFM->new; say $lfm->get($lfm->api_root_url . "?method=geo.getMetros&api_key=" . $lfm->api_key)'

You should get some XML spewed out on your screen that looks something like this:

<?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>

Let's see if we can't expose that information in a better way. If you have a closer look at Last.FM's API you'll notice that they divide their API calls into sub-sections. In the next part we'll make a separate class for the specific API calls we want to provide, in this case the geo sub-section.