How To: Create an api client library

In the previous example, there was a lot of duplicated logic for handling the various error conditions and api methods that we wanted to call. Obviously, it's less than ideal to have to one-off code for every api method and deal with every error scenario in a sane way. To alleviate that pain, you should use an api client library to do most of the heavy lifting for you automatically. In this tutorial, we'll go through the various steps we used to create our client library internally, and then provide an example client library you can use in your code. We eventually plan to provide client libraries in most popular languages, but for now, we're providing one in Perl.

  1. Create a skeleton class
  2. Abstract the http request
  3. Automatically serialize/deserialize input and output
  4. Deal with HTTP response codes
  5. The finished client library

Create a skeleton class

We'll be calling our package Storm::Client and we'll start with a sensible constructor and some shared defaults.

package Storm::Client;

use strict;
use LWP::UserAgent;
use HTTP::Request;
use JSON;

our $version = 'v1';
our $host    = 'https://api.stormondemand.com';
our $default_timeout = 30;

sub new {
	my $package = shift;
	my %params  = (UNIVERSAL::isa($_[0], 'HASH')) ? %{ $_[0] } : @_;

	return bless \%params, ref($package) || $package;
}

1;

We'll want some basic parameters to either be passed in the constructor or set later before the request, so we'll make some read/write accessors for those properties. There are lots of better ways to do this with CPAN modules, but we're going for simplicity and fewest dependencies here.

sub timeout {
	my $self = shift;
	if (@_) {
		$self->{timeout} = shift;
	}
	return $self->{timeout} ||= $default_timeout;
}

sub username {
	my $self = shift;
	if (@_) {
		$self->{username} = shift;
	}
	return $self->{username};
}

sub password {
	my $self = shift;
	if (@_) {
		$self->{password} = shift;
	}
	return $self->{password};
}

That covers the basics of the class, now let's get to some actual functionality.

Abstract the http request

We'll want one method we can call to handle all http requests to the various api methods. We'll call that method 'call'.

sub call {
	my $self   = shift;
	my $method = shift;
	my $json   = shift;

	my $ua = LWP::UserAgent->new;
	$ua->timeout($self->timeout);

	my $req = HTTP::Request->new(POST => "$host/$version/$method");
	$req->authorization_basic($self->username, $self->password);

	# make the JSON-encoded string the request body content
	$req->content($json);

	my $response = $ua->request($req);
	return $response;
}

This method abstracts away the HTTP::Request boilerplate for us, so we can focus only on what we care about. Note, it currently requires you to JSON-encode your parameters before calling the method and decode the response. We'll get to doing that automatically later. To use this code, we'd do something like the following:

use Storm::Client;
my $client = Storm::Client->new(
	username => $username,
	password => $password,
);

my $json_response = $client->call('utilities/info/ping', '{"metadata":{"ip":"127.0.0.1"},"params":{}}');

Automatically serialize/deserialize input and output

Obviously it's painful to have to keep converting to/from JSON in your code, so we can easily modify the 'call' method to handle that automatically for us.

sub call {
	my $self   = shift;
	my $method = shift;
	my $params = shift || {};

	my $ua = LWP::UserAgent->new;
	$ua->timeout($self->timeout);

	my $req = HTTP::Request->new(POST => "$host/$version/$method");
	$req->authorization_basic($self->username, $self->password);

	# encode the parameters in the proper format
	# automatically provide the metadata

	my $parser = JSON->new->utf8(1);
	my $json = $parser->encode({
		metadata => { ip => $ENV{REMOTE_ADDR} },
		params   => $params,
	});
	$req->content($json);

	my $response = $ua->request($req);

	# parse the response so we return a Perl data structure
	my $results  = $parser->decode($response);
	return $results;
}

Now we're getting a lot more useful. We pass in Perl data structures and get Perl data structures back. The rest is implicitly done for us. Now our earlier example looks like this:

use Storm::Client;
my $client = Storm::Client->new(
	username => $username,
	password => $password,
);

my $hash_ref = $client->call('utilities/info/ping', {});

Deal with HTTP response codes

So, our client library is pretty useful now, but it doesn't handle anything but completely valid responses. There are a few scenarios we need to consider, such as authorization failures, request timeouts, etc. So, we'll further adjust our 'call' method as such:

sub call {
	my $self   = shift;
	my $method = shift;
	my $params = shift || {};

	my $ua = LWP::UserAgent->new;
	$ua->timeout($self->{timeout});

	my $url = "$host/$version/$method.json";
	my $req = HTTP::Request->new(POST => $url);
	$req->authorization_basic($self->username, $self->password);

	# encode the parameters in the proper format
	# automatically provide the metadata

	my $parser = JSON->new->utf8(1);
	my $json = $parser->encode({
		metadata => { ip => $ENV{REMOTE_ADDR} },
		params   => $params,
	});
	$req->content($json);

	my $response = $ua->request($req);

	my $code    = $response->code;
	my $content = $response->content;
	if ($code == 200) {
		# parse the response so we return a Perl data structure
		my $results  = $parser->decode($content);
		return $results;
	}

	if ($content =~ /timeout/i) {
		# unfortunately, LWP doesn't differentiate a timeout from a 500 error
		# so we have to regex the content
		my $timeout = $self->timeout;
		return {
			error_class  => 'LW::Exception::RemoteService::Timeout',
			url          => $url,
			timeout      => $timeout,
			full_message => "Request to $url timed out after $timeout seconds",
		};
	}
	elsif ($code == 401 || $code == 403) {
		return {
			error_class  => 'LW::Exception::RemoteService::Authorization',
			url          => $url,
			full_message => "Authorization failed for $url",
		};
	}
	elsif ($code == 500) {
		return {
			error_class  => 'LW::Exception::RemoteService::Unavailable',
			url          => $url,
			error        => $content,
			full_message => "Request to $url failed due to a network error: $content",
		};
	}
	else {
		return {
			error_class  => 'LW::Exception::RemoteService',
			url          => $url,
			error        => $content,
			full_message => "Request to $url failed: $content",
		};
	}
}

Now we're handling every known possible response, and have a generic catchall for unforeseen issues. For consistency, we used the same error classes and messages as the Storm Management Console.

The finished client library

Now that we're accounting for errors, we can put everything together to make a finished client library:

package Storm::Client;

use strict;
use LWP::UserAgent;
use HTTP::Request;
use JSON;

our $version = 'v1';
our $host    = 'https://api.stormondemand.com';
our $default_timeout = 30;

sub new {
	my $package = shift;
	my %params  = (UNIVERSAL::isa($_[0], 'HASH')) ? %{ $_[0] } : @_;

	return bless \%params, ref($package) || $package;
}

sub timeout {
	my $self = shift;
	if (@_) {
		$self->{timeout} = shift;
	}
	return $self->{timeout} ||= $default_timeout;
}

sub username {
	my $self = shift;
	if (@_) {
		$self->{username} = shift;
	}
	return $self->{username};
}

sub password {
	my $self = shift;
	if (@_) {
		$self->{password} = shift;
	}
	return $self->{password};
}

sub call {
	my $self   = shift;
	my $method = shift;
	my $params = shift || {};

	my $ua = LWP::UserAgent->new;
	$ua->timeout($self->timeout);

	my $url = "$host/$version/$method.json";
	my $req = HTTP::Request->new(POST => $url);
	$req->authorization_basic($self->username, $self->password);

	# encode the parameters in the proper format
	# automatically provide the metadata

	my $parser = JSON->new->utf8(1);
	my $json = $parser->encode({
		metadata => { ip => $ENV{REMOTE_ADDR} },
		params   => $params,
	});
	$req->content($json);

	my $response = $ua->request($req);

	my $code    = $response->code;
	my $content = $response->content;
	if ($code == 200) {
		# parse the response so we return a Perl data structure
		my $results  = $parser->decode($content);
		return $results;
	}

	if ($content =~ /timeout/i) {
		# unfortunately, LWP doesn't differentiate a timeout from a 500 error
		# so we have to regex the content
		my $timeout = $self->timeout;
		return {
			error_class  => 'LW::Exception::RemoteService::Timeout',
			url          => $url,
			timeout      => $timeout,
			full_message => "Request to $url timed out after $timeout seconds",
		};
	}
	elsif ($code == 401 || $code == 403) {
		return {
			error_class  => 'LW::Exception::RemoteService::Authorization',
			url          => $url,
			full_message => "Authorization failed for $url",
		};
	}
	elsif ($code == 500) {
		return {
			error_class  => 'LW::Exception::RemoteService::Unavailable',
			url          => $url,
			error        => $content,
			full_message => "Request to $url failed due to a network error: $content",
		};
	}
	else {
		return {
			error_class  => 'LW::Exception::RemoteService',
			url          => $url,
			error        => $content,
			full_message => "Request to $url failed: $content",
		};
	}
}

1;

Now that we have a good library, let's reshow the token retrieval example from the previous tutorial, but this time using our library:

#!/usr/local/bin/lwperl
use strict;
use Storm::Client;

my $username = &get_input('username');
my $password = &get_input('password');

my $client = Storm::Client->new(
	username => $username,
	password => $password,
);

my $response = $client->call('account/auth/token');

if ($response->{token}) {
	# successful login, store the token
	my $session = &get_my_session();
	$session->{username} = $username;
	$session->{token}    = $response->{token};
}
elsif ($response->{error_class} eq 'LW::Exception::RemoteService::Authorization') {
	# the credentials are invalid, show the user an error
}
else {
	# some other error occurred, deal with it (look at $response->{error_class})
}