Home Introduction to API Clients in Ruby
Post
Cancel

Introduction to API Clients in Ruby

In this article, I’m going to begin to outline my approach for creating API clients in Ruby. I’ve worked on quite a few web applications that interact with a lot of internal and external services. These services were shared by a number of other internal Rails applications, so I found myself creating Ruby API client gems to make it easier and more consistent to interact with these services. My ideas on this has changed and evolved over the years, but I thought I’d take some time to document my current approach when creating API clients in Ruby.

I recently had a good example of this surface when working on my podshare.fm application. I had a need to search podcast metadata, and I came across podcastindex.org. It’s a great repository of podcast feeds and episodes with a public-facing API. However, I noticed that they didn’t yet have a Ruby client, so I decided to create one to use in my app and contribute it back to the project.

Overall Design

I like to break my API clients into two major interface layers: a low-level API client and a domain model.

API Client

The purpose of the low-level API client is to handle all the raw HTTP calls and mimic the external API as close as possible. This provides a familiar interface to the API documentation and can provide some flexibility in calling the API if the domain model isn’t fully featured.

Although it may be tempting, I try not to impose my own design preferences on external API layer. This layer should mirror the external documentation. For example, looking at the podcastindex.org documentation, it’s broken down into about 10 different sections. As of the time of this writing, my API client handles six of them: Episodes, Podcasts, Recent, Search and Value. Therefore, I create a class for each section and a method for each endpoint.

Episode Client Example

Here’s an abbreviated version of what the Episode client looks like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
module PodcastIndex
  module Api
    class Episodes
      class << self
        # https://podcastindex-org.github.io/docs-api/#get-/episodes/byfeedid
        def by_feed_id(id:, since: nil, max: nil, fulltext: nil); end

        # https://podcastindex-org.github.io/docs-api/#get-/episodes/byfeedurl
        def by_feed_url(url:, since: nil, max: nil, fulltext: nil); end

        # https://podcastindex-org.github.io/docs-api/#get-/episodes/bypodcastguid
        def by_podcast_guid(podcast_guid:, since: nil, max: nil, fulltext: nil); end

        # https://podcastindex-org.github.io/docs-api/#get-/episodes/byitunesid
        def by_itunes_id(id:, since: nil, max: nil, fulltext: nil); end

        # https://podcastindex-org.github.io/docs-api/#get-/episodes/byid
        def by_id(id:, fulltext: nil);  end

        # https://podcastindex-org.github.io/docs-api/#get-/episodes/byguid
        def by_guid(guid:, feedurl: nil, feedid: nil, fulltext: nil); end

        # https://podcastindex-org.github.io/docs-api/#get-/episodes/live
        def live(max: nil); end

        # https://podcastindex-org.github.io/docs-api/#get-/episodes/random
        def random(max: nil, lang: nil, cat: nil, notcat: nil, fulltext: nil); end
      end
    end
  end
end

As you can see, there is a single method corresponding to each of the “Episode” endpoints with the same name. Additionally, all of the method arguments match the API parameter names.

The body of the methods are all fairly similar. The class extends a common PodcastIndex::Api::Request module that handles most of the HTTP request work and error handling. Each method calls into that module and the result is a JSON representation of the response.

Testing

As I’m working through each method, I manually request and save off a sample response from the API to be used as a fixture for my tests. I then use the webmock gem to stub out the response as I’m writing my tests. So, a simple test for Episode.by_id might look like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
RSpec.describe PodcastIndex::Api::Episodes do
  describe ".by_id" do
    subject(:response) { described_class.by_id(id: id) }

    let(:fixture) { file_fixture("episodes/by_id_response.json").read }
    let(:id) { 8031009367 }

    before do
      stub_request(:get, %r{/episodes/byid})
        .to_return(body: fixture, status: 200)
    end

    it "returns the body of the response" do
      expect(response["episode"]["id"]).to eq id
    end
  end 
end 

Depending on the API, building out this interface layer will be fairly repetitive, but provides a good foundation for the next layer: domain models.

Domain Models

This is where you begin to apply your own design principles to the code. For this client, I ended up with four domain models (so far): Episode, Podcast, Soundbite and Value. A major consideration was based on the entities in the API responses.

Domain Model Interface

For my domain models, I like to follow a pattern similar to ActiveRecord when possible. A Ruby client for an API should “feel” like Ruby (and/or Rails in this case) and follow patterns that will be familiar to the user. Since this API is fairly “search” heavy, it lends itself well to an ActiveRecord pattern. However, this will be very dependent on the API you’re writing against.

For example, an easy one to start with is finding a single Episode by its id. This is analogous to the ActivRecord.find method. An implementation might be as simple as:

1
2
3
4
5
6
7
8
9
10
module PodcastIndex
  class Episode
    class << self
      def find(id, fulltext: nil)
        response = Api::Episodes.by_id(id: id, fulltext: fulltext)
        from_response(response)
      end
    end 
  end 
end 

Response Handling

This brings up the obvious question of how to handle the response and translate it into an actual object. For this simple API client, I decided to use SimpleDelegator and blindly accept all of the JSON response attributes as attributes of my object by transforming it to an OpenStruct:

1
2
3
4
5
6
7
8
9
10
11
module PodcastIndex
  class Episode < SimpleDelegator
    class << self
      # ...
      def from_response(response)
        episode = response["episode"].transform_keys(&:underscore)
        new(JSON.parse(episode.to_json, object_class: OpenStruct))
      end
    end 
  end 
end 

This may not be a good design choice depending on your situation. This method is not necessarily performant, or consistent in its response. You may want to validate the JSON response against a schema or permit only a subset of attributes. You’ll need to weigh the potential for the API to change against the maintenance cost of the API client and the desired consistency.

Response Collections

For API calls that respond with an array of entities, I chose to follow the ActiveRecored.where pattern. For example, to find all episodes by feed_url, I first create a private method find_all_by_feed_url that essentially proxies to the Api layer and returns an array of objects:

1
2
3
4
5
6
7
8
9
10
11
def find_all_by_feed_url(feed_url:, since: nil, max: nil, fulltext: nil)
  response = Api::Episodes.by_feed_url(url: feed_url, since: since, max: max, fulltext: fulltext)
  from_response_collection(response)
end

def from_response_collection(response, collection_key = "items")
  response[collection_key].map do |item|
    episode = item.transform_keys(&:underscore)
    new(JSON.parse(episode.to_json, object_class: OpenStruct))
  end
end

Then I create a public .where method that dynamically calls the correct find_all_by method and passes the remaining arguments:

1
2
3
4
5
6
7
8
9
FIND_MANY_ATTRIBUTES = %i[feed_id feed_url podcast_guid live itunes_id person recent].freeze

def where(attributes)
  match = (attributes.keys & FIND_MANY_ATTRIBUTES)
  raise ArgumentError, "Must supply one of the attributes: #{FIND_MANY_ATTRIBUTES}" unless match.present?
  raise ArgumentError, "Must supply only one of the attributes: #{FIND_MANY_ATTRIBUTES}" if match.length > 1

  send("find_all_by_#{match.first}", **attributes)
end

This allows for a call like:

1
episodes = Episode.where(feed_url: "https://example.com/feed.rss")

Simplified Interface

This design also allows me to present a more cohesive domain model. For example, in the API, finding an episode by itunes id is a request to /episodes/byitunesid, but finding an episode by person is a request to /search/byperson. These are represented by two different classes in our lower-level API layer, but the domain model can easily abstract the calls:

1
2
3
4
5
6
7
8
9
def find_all_by_itunes_id(itunes_id:, since: nil, max: nil, fulltext: nil)
  response = Api::Episodes.by_itunes_id(id: itunes_id, since: since, max: max, fulltext: fulltext)
  from_response_collection(response)
end

def find_all_by_person(person:, fulltext: nil)
  response = Api::Search.by_person(person: person, fulltext: fulltext)
  from_response_collection(response)
end

Therefore, the caller only needs to invoke .where with the correct attribute and the domain model takes care of calling the correct API method.

Abstracting API Complexity

Now, let’s take a look at a more complex example that simplifies the search for podcasts. The API provides the ability to search for podcasts by “medium”, such as “podcast”, “music”, or “video” (/podcasts/bymedium). Additionally, there is a “search music podcasts” endpoint (/search/music/byterm) that allows for a text search of only music podcasts. From a domain model perspective, this should just be designated by attributes to the .where method:

1
2
3
Podcast.where(medium: "video")
# or
Podcast.where(medium: "music", term: "salsa")

Therefore we can perform this check in the domain model’s .where method and call the correct underlying API method:

1
2
3
4
5
6
7
8
9
def where(attributes)
  return find_all_music_by_term(**attributes) if attributes[:medium] == "music" && attributes[:term].present?

  match = (attributes.keys & FIND_MANY_ATTRIBUTES)
  raise ArgumentError, "Must supply one of the attributes: #{FIND_MANY_ATTRIBUTES}" unless match.present?
  raise ArgumentError, "Must supply only one of the attributes: #{FIND_MANY_ATTRIBUTES}" if match.length > 1

  send("find_all_by_#{match.first}", **attributes)
end

If a term attribute is not supplied, the code will fall through to the correct find_all_by_ method, otherwise if there is a term and the medium is “music” the special find_all_music_by_term method is called. So, this is an example of hiding the details of the external API, and normalizing the interface to the client.

Conclusion

This was a fairly simplistic example, and there are a lot of other important topics I didn’t cover here, such as:

  • Updating the API with POST, PUT and DELETE requests
  • Handling pagination
  • Dependencies between domain models, eg a Soundbite references an Episode
  • Error handling client side and/or server side
  • Validating arguments
  • Namespacing concerns

I’ll likely follow up with future articles diving into some of these topics. Hopefully this at least gives you some ideas on how to get started with an API client in Ruby.

Additional Resources

Gateway Pattern