Developing an API client in Ruby

Connecting to a public API is always exhilarating for me. The idea of quickly having a feature or functionality built into your application by integrating with APIs sounds appealing. After all, why reinvent the wheel when we want to build a car?

Lots of time, when we want to integrate to a public API, we look for an API client/wrapper in the language that we are familliar with or in the same language as our application that we want to integrate. When you are in luck, you will find the ones that match your language and technical requirement. However, should the existing client does not satisfy your needs, or there is no client at all, why not build one yourself? This post hopefully will help you in creating API client in Ruby.

There are several factors to consider when developing your client:

  • Separate the client as a gem or build it inside the application
  • What to use for connecting to the API
  • How to handle errors
  • What the result should be formatted to

Build it as a Gem or one with the application

For the first point, on this post we will discuss to build the client inside the application, but organize it under the lib/ folder. This is good convention in the rails community to put classes that are not related to the application inside lib/ folder. By organizing it inside the lib/ folder, we also indicate to other developers that it is safe to extract the client into a separate gem in the future, should we want to use it in other application. Though your situation and needs may differ, the safest bet is to build it one with the application as it is easier to maintain and develop.

Tools to consider

There are a number of libraries to send http request with ruby, I will only list 3 tools that I have experience with:

  • Net::Http This is the standard ruby library for sending http request. The big plus of using this library is that you do not need to install anything as it is already built-in with ruby.
  • HttpParty This is a popular tool for sending http request, with ease of use as its main selling point. It is just so fun to use, as you can easily create a class that interacts with API and then map the results as attributes, effectively making it like you using a rails model-like class. The negative of this library is the fact that this gem is DSL-heavy.
  • Faraday This gem is interesting. It is not as easy to use as HttpParty and unlike Net::Http, you do have to install it (though it does have very light dependencies). Yet it is very modular and does not feature a heavy use of DSL. Personally, it is my favourite amongst the 3 options on this list and one that I will choose in this post. As an example, I will demonstrate it to connect to Xendit API, a payment gateway, but of course it also works for any JSON API.

Start building the client

First, setup the lib class.

# lib/xendit/client.rb
class Xendit::Client
  def initialize(api_key:)
    @api_key = api_key
    # Xendit requires us to use token in every request
    # This is how to get the token, appending colon at the end then encode it
    @token = Base64.strict_encode64(api_key + ':')
  end
end

One that may be odd to you maybe the reasoning behind allowing api_key as a keyword argument. I find that this gives me flexibility of overriding the API key on the fly.

The next step is to create a private method that sets up the object that will connect and sending request to the API.

class Xendit::Client
  --redacted--

  private

  def setup_connection
    # start setting up connections
    @connection = Faraday.new(url: ENV['XENDIT_API_BASE_URL') do |faraday|
      faraday.response :logger
      faraday.request  :url_encoded
      faraday.adapter  Faraday.default_adapter
    end

    # Set authorization, adjust this according to your case
    # Usually, it is either of these values: [:Basic, :Bearer, :Token]
    @connection.authorization(:Basic, @token)
    # finish setting up connection      
  end    
end

after setting the private method, then we call it in the initializer of the class

class Xendit::Client
  def initialize(api_key:)
    -- redacted --

    setup_connection
  end  
end

What this does is setup an instance variable, available throughout the class, that we will use to send all types of HTTP request. The block of lines inside that define the @connection is typically standard when instantiating a Faraday object, so do not worry about it, except this line:

  faraday.request  :url_encoded

Basically this line determines the format of the payload you will send in the request, you MUST find out which format of the payload the API you want to integrate wiht accepts then adjust accordingly. In this case, Xendit API asks for 'x-www-form-urlencoded' hence the use :url_encoded in the example.

Sending request

Now that all the foundations are in place, let starts sending some request, shall we?

# lib/xendit/client.rb
class Xendit::Client
  --redacted--

  def get_invoice(id:)
    return nil if @api_key.empty?

    # Concat the :id keyword argument to form the complete endpoint url 
    endpoint = 'v2/invoices/' + id.to_s
    # Then send GET request to the endpoint
    response = @connection.get endpoint
    # Parse the JSON body of the response
    JSON.parse(response.body)
  end
 end

You may notice that the endpoint variable only features the path part of url, this is because we have supplied the base url in the setup_connection private method to the @connection object.

Now, instead of typing out HTTP request, we can retrieve the invoice using this line:

  @client = Xendit::Client.new(api_key: ENV['XENDIT_API_KEY'])

  @client.get_invoice(id: 'sample-xendit-invoice-uid')

Simple enough, is it not? What about POST request?

# lib/xendit/client.rb
class Xendit::Client
  --redacted--

  def create_invoice(data:, headers: {})
    return nil if @api_key.empty?

    response = @connection.post('v2/invoices', data, headers)
    JSON.parse(response.body)
  end
 end

Just allow the payload and request headers to be passed as an argument, this way you can adjust headers on the fly. We can call the method like this:

  @client.create_invoice(
    data: {
      external_id: 'sample-internal-id', 
      payer_email: 'customer_email@lorem.com', 
      description: 'a purchase', 
      amount: '500000'    
    }
  )

In this case, we do not need to insert any headers so we can allow the headers keyword argument to use the default value, an empty hash.

Formatting the result

Should there be no problem, your request will generate a result from the body of the response (I assume the API is of JSON format) in raw JSON. You can parse it then use it as is (in the form of JSON hash), like the examples above, or go one step further and create a class that maps and formats the result. The second one is very useful if you need to further format the result hence saving you time from formatting every single time you make the the request.

Back to the XenditClient example, we will create class that maps the result of invoice endpoints.

# lib/xendit/entity/invoice.rb
require 'model_attribute'

class Xendit::Entity::Invoice
  extend ModelAttribute

  attribute :id,                  :string
  attribute :user_id,             :string
  attribute :external_id,           :string
  attribute :is_high,             :boolean
  attribute :status,              :string
  attribute :merchant_name,             :string
  attribute :amount,              :integer
  attribute :payer_email,           :string
  attribute :description,           :string
  attribute :invoice_url,           :string
  attribute :xendit_fee_amount,             :integer
  attribute :expiry_date,           :string

  def initialize(attributes = {})
    set_attributes(attributes)
  end
end

This kind of entity class uses a lightweight gem called 'ModelAttribute' that basically enables you to define non-active record models. By having class like this, you can introduce an instance method that uses the existing attributes. This entity also type cast the attributes according to your liking.

Now that we have introduced the entity, let's use it:

# lib/xendit/client.rb
class Xendit::Client
  --redacted--

  def get_invoice(id:)
    --redacted--
    response = @connection.get endpoint
    attrs = JSON.parse(response.body)
    Xendit::Entity::Invoice.new(attrs)
  end

  def create_invoice(data:, headers: {})
    --redacted--
    attrs = JSON.parse(response.body)
    Xendit::Entity::Invoice.new(attrs)
  end
end

Now everytime we call .get_invoice and .create_invoice validly, we will get an instance of Xendit::Entity::Invoice class instead of a JSON hash.

Handling errors

The extent of error handling required for the client depends a lot on: 1)whether you will develop the client as a separate gem, with the view of opening it for public use (open source, hell yeah!), or 2)one with the application and for private use only. Obviously, if you develop an API client for public use, you should map all failed requests with proper error messages and statuses so that you do not lead developers into the wall of frustrations when they experience error from connecting to the API using your client. You should also understand that each API may have different error responses and rules that associated with them, such as maximum number of request per seconds and so on.

There are 3 ways to handle the errors gracefully:

The lazy way - use the built-in error handler from Faraday
# lib/xendit/client.rb
class Xendit::Client
  --redacted--

  def setup_connection
    @connection = Faraday.new(url: XenditApi::BASE_URL) do |faraday|
      --redacted--
      faraday.use Faraday::Response::RaiseError
    end
  end
end
The easy way - if you want more and get creative with it
# lib/xendit/custom_errors.rb
require 'faraday'

class Xendit::CustomErrors < Faraday::Response::Middleware
  def on_complete(response)
    case response[:status]
    when 404
      raise RuntimeError, 'Custom 404 response'
    end
  end
end

In the class Xendit::CustomErrors above we lists all possible errors that we will map from the response returned by Xendit API, by using inheritance from Error class from ruby. On the example, we only define handler for 404 error but you of course can extend the coverage as you wish.

We are not done, though, we still have to call and assign these error classes according to the status code from the response we get after sending request to Xendit API.

# lib/xendit/client.rb
class Xendit::Client
  --redacted--

  def setup_connection
    @connection = Faraday.new(url: XenditApi::BASE_URL) do |faraday|
      --redacted--
      faraday.use Xendit::CustomErrors
    end
  end
end
The hard, strong-as-ironclad way

The third way is a bit complicated and daunting, but if you are up for it, I suggest looking at the implementation of the new Instagram gem. I will cover this bit in detail in another post.

Closing

Hopefully I have convinced you that wirting an API client is easy enough, so the next time you want to integrate an API but does not found a ruby client for it, just build one yourself. Should you up for a challenge, build/move it as a gem so that other people can benefit from it as well. Long live free software!