Implementing WWW::LastFM, a client library to the Last.FM API, with XML::Rabbit
By Robin Smidsrød on Sep 30, 2011 | In Perl, Software Development
- Part 1 of 5
- Part 2 of 5
- Part 3 of 5
- Part 4 of 5
- Part 5 of 5
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.
6 comments
Cheers, Leon.
Because WebService::LastFM implements some kind of old streaming API for playing Last.FM radio which I'm not interested in, and it does not seem to be compatible with the current v2.0 API from Last.FM.With regards to your Net::LastFM module, which I actually considered to extend instead, I have to agree with the CPAN naming guidelines which states that the Net:: namespace should be for wire/socket protocols like NNTP, SMTP, HTTP and such, and that high-level application protocols that implement on top of HTTP should use the WebService:: or WWW:: namespaces.
Once I publish the fifth and final part of the article series you'll see my reason for recreating the wheel. But I'll be more than happy to work with you in creating a good API to communicate with Last.FM if you're interested.
Just hang on for a few days, and you can be the judge if you think I'm on to something useful or if it is a waste of time.
listdeps fails (hard error) because these modules are not installed:
Pod::Weaver::Section::Support
Pod::Weaver:
Pod::Elemental::Transformer::List
What's more - after I installed it, output of dzil listdeps is not parseable by cpan:
=$ dzil listdeps
[Name] couldn't find abstract in lib/WWW/LastFM/Response/MetroList.pm
[Name] couldn't find abstract in lib/WWW/LastFM/Response/Event.pm
[Name] couldn't find abstract in lib/WWW/LastFM/Response/EventList.pm
[Name] couldn't find abstract in lib/WWW/LastFM/Response/Venue.pm
[Name] couldn't find abstract in lib/WWW/LastFM/Response/Metro.pm
[Name] couldn't find abstract in lib/WWW/LastFM/Response.pm
Config::Any
DateTime::Format::HTTP
Encode
English
ExtUtils::MakeMaker
feature
File::HomeDir
Getopt::Long
HTML::FormatText
List::MoreUtils
LWP::UserAgent
Moose
namespace::autoclean
Path::Class:
rlib
strict
Test::More
URI::Escape
warnings
XML::Rabbit
XML::Rabbit::Root
which gives:
Warning: Cannot install [Name], don't know what it is.
Try the command
i /[Name]/
Thanks for the update. I've fixed the abstract problems in all the response classes. Github master branch is updated.How can I modify dist.ini so that authordeps outputs the names of those three missing Pod::Weaver classes?
@despesz: https://metacpan.org/module/Dist::Zilla::App::Command::authordeps shows the solution.I've added the needed lines to the dist.ini so that other people trying it out should have it working properly. Thanks again for the information.
Hope you like the article series.
Leave a comment
| « Implementing WWW::LastFM with XML::Rabbit - Part 2 | JW Player uses term "Open Source", but violates Open Source Definition rule #6 » |



