Rewriting our Ruby API Client
Having recently joined Bigcommerce in San Francisco, I wanted to get up to speed quickly on the API and development process. When I was given then opportunity to work on rewriting our Ruby API Client, I jumped at the chance, knowing it would serve me well to learn the ins and outs of the API, but to also have the opportunity to publish a open source ruby gem. For me, that’s just exciting!
Jumping at the chance to bump the major version
With our API client, we try our very best to maintain Semantic Versioning. When my boss let me know that I could bump the major version, I had to pounce at the opportunity.
Given a version number MAJOR.MINOR.PATCH, increment the:
- MAJOR version when you make incompatible API changes,
- MINOR version when you add functionality in a backwards-compatible manner, and
- PATCH version when you make backwards-compatible bug fixes. Additional labels for pre-release and build metadata are available as extensions to the MAJOR.MINOR.PATCH format.
The key is incompatible changes. I had the flexibility to completely change the developer facing API. With that in mind, I set off defining what I wanted to see in a rewrite.
The right interface
Just like how front-end developers start with designing wire frames and mockups, I started by defining how I wanted developers to use the API client. I wanted a simple to use interface that closely resembled the simplicity of ActiveRecord.
I wrote a few variants of the interface, with different benefits in each, and then passed this document off internally to some of our senior developers. I found that to be a really useful exercise, they were able to give some extremely valuable advice and before we hit the code, we knew what our interface would look like.
Completeness
The previous version of the Gem had been unmaintained, we had been adding new features and expanding the API, and our API client did not reflect those changes. I knew when working on the rewrite, an important goal was to develop abstractions, to reduce duplication, and speed up development.
Documentation and examples
Something which I really wanted to create for the new API client was robust documentation and examples. I think its important for people evaluating a library to not have doubt whether it can satisfy their needs. With that, I wrote a comprehensive test suite, full example coverage, and an extensive set of docs on using the library, and the process to maintain it.
Rubocop
One thing which is always frustrating when opening PR’s or doing code reviews, is giving style feedback. Its important when developing a codebase to have a consistent set of patterns and style to ensure clean interfaces and consistency.
For this we used rubocop. Rubocop will quickly analyze code and enforce ruby style patterns. We had to make some small tweaks, but now that it is run along with our RSpec test suite, when people open a PR, it will automatically check for style, taking that burden away from our code reviewers and allowing for more streamlined code review.
Cool code stuff
Module Factory
One cool pattern I used was the module factory. In this pattern, you can dynamically construct a module with methods you define based on a set of conditions you can set or control externally.
The problem was that some resources have limited actions that we expose on the API. We properly handle the case when a developer accidentally sends a request to one of these endpoints, but as a client library developer, I have the opportunity to help devs find this behavior out in a development environment, before dealing with production or integration settings.
Lets take a look at how we can solve this with a little ruby magic.
module Bigcommerce
class Actions < Module
attr_reader :options
def initialize(options = {})
@options = options
tap do |mod|
mod.define_singleton_method :_options do
mod.options
end
end
end
# [...]
end
end
At this phase, we define a new class Actions
which will inherit from Module
. What this does is allow us to call Actions.new()
with some params.In our case, we want to keep the API flexible so we allow a hash of arguments.
module Bigcommerce
class Actions < Module
# [...]
def included(base)
base.extend(ClassMethods)
options[:disable_methods] ||= []
methods = ClassMethods.public_instance_methods & options[:disable_methods]
methods.each { |name| base.send(:remove_method, name) }
end
module ClassMethods
# [...]
end
end
end
We are defining the way in which our new module will be included, this is a great point to manage what methods we will dynamically include in this module.
When we call base.extend(ClassMethods)
, we are pulling all of the ClassMethods
into the new module, then look at the options hash we used in the initialization and call remove_method
if a method is defined inside the :disable_methods
array.
The top level API looks like the following to configure a new resource and its actions.
include Bigcommerce::Actions.new(
disable: [:create, :update, :destroy, :destroy_all]
)
Constructing domain objects
One thing I really enjoy about using other API clients is when you are returned a nice ruby object with clean interfaces to the data. For this, we felt Hashie was a good choice. This allows us to define properties on an object, that once called .new()
on, can be automatically set.
def build_response_object(response)
json = parse response.body
if json.is_a? Array
json.map { |obj| new obj }
else
new json
end
end
This trick turned out to be quite handy when I wrote the response parser and object factory. After I complete the response status, I am able to simpley create a new object, based on which class made the request.
Faraday
In the API client, we support two different authentication schemes. This present a little bit of a challenge when developing a generalized authentication middleware.
For the private app authentication scheme, we use a http basic authentication. We provide our developers with the ability to create usernames, and we associate an API key which is used as the password. Luckily this is build directly into Faraday, so we just had to write a simple flag to handle this case.
The more complex case is using a custom middleware built to handle our OAuth flow. We expect very specific headers to get sent with the payload, something which should be very transparent to our developers. For this we can define a simple middleware which sets the headers we care about.
module Bigcommerce
module Middleware
class Auth < Faraday::Middleware
def initialize(app, options = {})
@app = app
@options = options
end
def call(env)
env[:request_headers]['X-Auth-Client'] = @options[:client_id]
env[:request_headers]['X-Auth-Token'] = @options[:access_token]
@app.call env
end
end
end
end