How to Build a Robust JSON API client with Ruby
This tutorial will teach you how to use HTTParty to build a simple JSON API client with Ruby on Rails, which will save you from implementing a third-party API and pulling in what could potentially be low-quality or overly large code into your own application.
Building a robust JSON API client with Ruby
If you’re building a Ruby on Rails site and consuming a popular API, odds are there’s a gem for it and it’s as simple as a few lines of code to query and return what you need. But on the other hand, you could be introducing low-quality gem code into your application, a much bigger library than your use case requires, or code you just don’t understand well. Instead of pulling in 60k of Ruby, you might be able to build your own in 60 lines. If it’s a smaller service or a private API that was built just for you, you probably need to roll your own API client anyway. When I needed to integrate a service for showing sample photographs on my camera rental web site, I built a very simple JSON API client using HTTParty in Ruby.
Testing the API
Nothing is more frustrating than wasting time debugging an API only to discover that your API key was invalid or the failure was otherwise on the side of the API server. In my case, the API maintainer gave me the following test API call:
http://www.pixel-peeper.com/rest/?method=list_photos&api_key=[REDACTED]&camera=[CAMERAID]
Before doing anything else, you should test the API in your browser (if it’s a simple GET based API) or using curl from the command line if you need to POST data. You should see the JSON response:
{
data: {
results: [
{
camname: "EOS 5D Mark III",
cammake: "Canon",
camexifid: "CANON EOS 5D MARK III",
lensname: "Canon EF 24-105mm f/4 L IS USM",
author_name: null,
author_url: null,
id: "8175491230",
iso: "1600",
aperture: "4",
exposure: "0.00625",
focal_length: "105",
small_url: "http://farm9.staticflickr.com/8478/8175491230_c94258586b_m.jpg",
pixels: "22118400",
lens_id: "920",
flickr_user_id: "23722023@N06",
camera_id: "1659",
big_url: "https://www.flickr.com/photos/23722023@N06/8175491230/sizes/o/"
},
...
Minimum viable integration
Ok, the API works manually — time to code the bare minimum to replicate in Ruby. One of the decisions to make is which library to use to interact with the API. Ruby’s standard library has open-uri
built in which provides you with open(url). However, there doesn’t seem to be a good way to set timeouts using open()
. The best “solution” I found to this was to require 'timeout'
and use a Timeout::timeout(seconds) do
block — which seemed like a hack to me. Net::HTTP is a great library for interacting over HTTP, but I’m going one step further and using HTTParty, which uses Net::HTTP under the hood. HTTParty provides much of the boilerplate around interacting with an API, is well tested, etc. Other good alternatives include Faraday, which provides low-level controls over the HTTP requests. If you have a finicky or complicated API, I’d be more inclined to use Faraday. For our purposes, HTTParty will be just fine.
Following the sample code for HTTParty, we end up with a relatively short library:
require 'httparty'
class PixelPeeper
include HTTParty
base_uri 'www.pixel-peeper.com'
def api_key
ENV['PIXELPEEPER_API_KEY']
end
def base_path
"/rest/?method=list_photos&api_key=#{ api_key }"
end
def examples_for_camera(camera_id, options = {})
url = "#{ base_path }&camera=#{ camera_id }"
self.class.get(url, options)['data']['results']
end
def examples_for_lens(lens_id, options = {})
url = "#{ base_path }&lens=#{ lens_id }"
self.class.get(url, options)['data']['results']
end
end
It’s a best practice to not include secrets like API keys in source code/source control, so we’re reading the key from an environment variable. If you’re running Heroku, you’ll have to set this via heroku config:set PIXELPEEPER_API_KEY=$key
. For your local server/consoles, you’ll need to export the variable or set it inline when you invoke your command.
By including HTTParty in your class, all of your API GET requests are invoked through self.class.get
, it will use all the built-in HTTParty tricks, like using the base_uri
and automatically decoding the JSON response into a native hash. As a result of leveraging HTTParty, you don’t need much code for a fully-functional API client.
Timeouts
A fully-functional, but not a robust API client. If your Rails app calls the API inline with a request (which is common), a 30-second API response will mean your site also takes 30 seconds to respond. If many requests hit the same slow API, it could tie up all your web servers and bring your site down. This used to be common for Facebook apps before they solidified their graph API. The best practice for consuming an API inline with requests is to hard timeout and gracefully degrade. In our case, we want to hard timeout after just one second and return a “fake” empty request, so the template that consumes it can gracefully degrade.
HTTParty will raise Net::OpenTimeout
if it can’t connect to the server and Net::ReadTimeout
if reading the response from the server times out (both in the case that it stalls sending data or is still sending data). So we simply need to handle both exceptions to return empty hashes, and set the timeout to 1 second. Instead of implementing this timeout-handling logic in both methods, I’m opting for a handle_timeouts
function that takes a block. If the block raises an exception, handle_timeouts
will catch the exception and return an empty hash.
require 'httparty'
class PixelPeeper
include HTTParty
base_uri 'www.pixel-peeper.com'
default_timeout 1 # hard timeout after 1 second
def api_key
ENV['PIXELPEEPER_API_KEY']
end
def base_path
"/rest/?method=list_photos&api_key=#{ api_key }"
end
def handle_timeouts
begin
yield
rescue Net::OpenTimeout, Net::ReadTimeout
{}
end
end
def examples_for_camera(camera_id, options = {})
handle_timeouts do
url = "#{ base_path }&camera=#{ camera_id }"
self.class.get(url, options)['data']['results']
end
end
def examples_for_lens(lens_id, options = {})
handle_timeouts do
url = "#{ base_path }&lens=#{ lens_id }"
self.class.get(url, options)['data']['results']
end
end
end
This class now meets my requirements for “production-ready,” since the worst case is adding one second to the request and not showing content, in the case of a timeout. The API server going down or taking a long time to respond can never adversely affect our site more than that.
Caching API responses locally
We’ve protected our site, but one second is still a lot to add to each request — especially if it’s a response we see quite a lot. By implementing a local cache in Redis, we can obviate the need for an external API request and return in mere milliseconds with the cached data. Additionally, your cached responses will still be there if the API server goes down. And in general, it’s being a nice Internet neighbor to not overwhelm the API server with what are essentially duplicate requests. Be careful though; some API servers disallow local caching of responses in their Terms of Service. But unless it’s sensitive user data, most API servers would prefer that you cache.
Following the style of handle_timeouts
, we’re handling caching via the handle_caching
method, which again takes a block (and also an options parameter). Both camera and lense methods have been folded into one examples(options)
method, where options
is a hash with either a camera_id
or lends_id
key set.
require 'httparty'
class PixelPeeper
include HTTParty
base_uri 'www.pixel-peeper.com'
default_timeout 1 # hard timeout after 1 second
def api_key
ENV['PIXELPEEPER_API_KEY']
end
def base_path
"/rest/?method=list_photos&api_key=#{ api_key }"
end
def handle_timeouts
begin
yield
rescue Net::OpenTimeout, Net::ReadTimeout
{}
end
end
def cache_key(options)
if options[:camera_id]
"pixelpeeper:camera:#{ options[:camera_id] }"
elsif options[:lens_id]
"pixelpeeper:lens:#{ options[:lens_id] }"
end
end
def handle_caching(options)
if cached = REDIS.get(cache_key(options))
JSON[cached]
else
yield.tap do |results|
REDIS.set(cache_key(options), results.to_json)
end
end
end
def build_url_from_options(options)
if options[:camera_id]
"#{ base_path }&camera=#{ options[:camera_id] }"
elsif options[:lens_id]
"#{ base_path }&lens=#{ options[:lens_id] }"
else
raise ArgumentError, "options must specify camera_id or lens_id"
end
end
def examples(options)
handle_timeouts do
handle_caching(options) do
self.class.get(build_url_from_options(options))['data']['results']
end
end
end
end
All of the caching logic is in handle_caching(options)
and cache_key(options)
, the latter of which builds a unique key to store the cached response based on which type of request and for which ID. Redis encourages human-readable key names, but if your key name starts getting unwieldy or unpredictabel in length, it’s perfectly reasonable to compute a hash of your unique identifiers, like SHA1(options.to_json)
(pseudocode).
handle_caching(options)
checks for the existence of a key and returns the payload if available. Otherwise, it yields to the block passed in and stores the result in Redis. Object#tap
is a neat method that always returns the Object, but gives you a block with the object as the first named parameter. It’s a nice pattern for when you finish computing your return value but still need to reference it (to store in a cache, to log, to send an email with, etc).
Consuming the API
Using the API client is very straightforward: one line to create an API client instance, and one line to query by camera_id
or lens_id
.
def example_pictures_for(gear)
pp = PixelPeeper.new
if gear.pp_lens_id.present?
pp.examples(lens_id: gear.pp_lens_id)
elsif gear.pp_camera_id.present?
pp.examples(camera_id: gear.pp_camera_id)
else
[]
end.take(8)
end
Summary and final thoughts
As we’ve shown, HTTParty can be used to quickly create a robust and performant JSON API client. Using helper methods that take blocks allow us to create composable helpers and keep concerns separated into their own functions, instead of intermingled.
Should you build your own API client or use an existing gem? There’s not a single answer for every context; some API client gems are very high quality and maintained quite well. But if the quality of the client is doubtable or you only need to use a small subset of an API’s functionality, you may want to consider writing your own simple API client.
Codementor Ruby Expert Adam Derewecki is a former director of web engineering at ApartmentList.com, hacker at Causes.com, and one of the first technical leads at Yelp.com. Presently he’s bootstrapping his P2P camera rental startup, www.CameraLends.com, using Rails/Heroku.
This article was originally posted at BinPress.