Building A Creative & Fun API Client In Ruby: A Builder Pattern Variation
I stumbled upon the Datamuse API the other day as I was looking for thesaurus-like data for some small app I am currently toying with. If you are not a 100% sure what “thesaurus-like data” means, check out Thesaurus.com. For those who consider accuracy and creativity in their choice of words an asset, this is an invaluable tool. Moreover, I find myself using it quite often at work whenever I am looking for a different, sharper name for some class, variable or method; and of course it has many, many more usages. Thesaurus is fun, and the Datamuse API supplies thesaurus-like data that is simple to consume and understand.
In this story, I will demonstrate a clean and accessible approach to writing API clients that are a pleasure to work with. The Datamuse API is a great demo case since it requires no authentication, as well as providing interesting parameterization options. Of course, the approach I am about to present is suitable for other APIs as well, and also for designing interfaces for internal classes. Along the way, I will touch on topics such as the builder pattern, self-chainability of objects and immutability in Ruby.
The Datamuse API’s endpoints are quite modest in size, yet for clarity I will not supply a full coverage of all their options; rather, I will concentrate on several query parameters from the words endpoint. You can check out the API yourself to see how easy it is to make a call through a simple HTTP request, or just copy-paste the following to another browser tab:
https://api.datamuse.com/words?&ml=love&sl=earning&max=10
Having covered that, let’s discuss our API client’s design!
The Dream Interface: What We Would Like To Have
There actually exists a Ruby client for the Datamuse API, and it works well. As programmers, we are sometimes warned against writing something that was already written by somebody else, but in this case I fancied a different interface. I was not entirely happy with the existing client: I wanted to phrase my queries in a clearer language, more English-like. My inspiration was the well-known ActiveRecord syntax, which allows developers to actually be oblivious to the SQL queries they are performing. This aspect of ActiveRecord is often criticized, yet I find that at its core, its a fantastic idea that helps in better separating levels of abstraction and concerns. Having said that, do learn SQL! It’s a highly valuable skill.
Instead of being aware of the internals of the Datamuse API as the existing client requires, I wanted to write Ruby code that possibly looked like this:
DataMuse.words.with_similar_meaning_to('love')
.that_sound_like('earning').limit(5).fetch
This query is very easy to grasp: go fetch 5 words that has a similar meaning to the word love, which also sound like the word earning. The word I wish most to get back is yearning, of course!
In comparison, the existing client feels slightly more obscure in the same task:
Datamuse.words(ml: 'love', sl: 'earning', maximum: 10)
It seems to me that there is a special emphasis in the Ruby ecosystem on how things feel and read, aside from the fact that they just work; this is one of the things that makes me love Ruby so much, so crafting this new interface seems like a worthy challenge to take.
Fundamentals: Immutable, Self-Chained Objects
What makes the suggested syntax above attractive to me is how the query specifications flow one into the next, without the need to assign an initial object to a variable and mutate it several times. This can be achieved by the simple technique of creating a new object upon each such method call: a different instance of the same class with the new data in addition to (or instead of) the existing one.
Here is a simple and rather abstract example to demonstrate this idea:
Lets try it out:
2.5.0 :012 > first = ImmutableIncrementer.new
=> #<ImmutableIncrementer:0x00007fa00711b9b8 @n=0>2.5.0 :013 > second = first.increment(5)
=> #<ImmutableIncrementer:0x00007fa006069c78 @n=5>2.5.0 :014 > third = first.increment(17).increment(3)
=> #<ImmutableIncrementer:0x00007fa00709d748 @n=20>
Each assignment ended up in a new, unique object. If we turn to each of these objects, we can see that all of them remained with the same state they had upon their creation —hence, in fact, immutable:
2.5.0 :015 > first
=> #<ImmutableIncrementer:0x00007fa00711b9b8 @n=0>
2.5.0 :016 > second
=> #<ImmutableIncrementer:0x00007fa006069c78 @n=5>
2.5.0 :017 > third
=> #<ImmutableIncrementer:0x00007fa00709d748 @n=20>
This is quite a different approach than the usual attr_writer
or attr_accessor
which is very common in the Ruby world. I will not delve into discussing the merits of immutability here, but it sure is an interesting and important topic to explore. Check out the ImmutableRuby gem for more ideas about that!
A Gradually Constructed Path
Let’s apply this technique to our aspired Datamuse API client. An easy way to start is to supply an entry module to the API which will represent the different possible endpoints, creating a basic Request
object per endpoint. In this case, we are only covering the words endpoint, so it may look like this:
Upon calling DataMuse.words
, a new Request
object is born, with its path initially set at the desired endpoint route, waiting for parameters to be added.
Moving on, the Request
class might look like this:
I’ve used the HTTParty gem to make the actual calls to the API, as can be seen in the #fetch
method. The interesting bits are the expressive query-crafting methods: #with_similar_meaning_to(phrase)
, #that_sound_like(word)
, #that_are_spelled_like(word)
and #limit(max_result_count)
. They employ the very technique described in the previous section, creating a new Request
object upon each call and adding another parameter to the @path
string every time, in a way that creates a @path
the way the Datamuse API expects it. When we are done carving our query, what we have at hand is a Request
object whose @path
is ready to be used by calling the #fetch
method.
Essentially, this is a variation on the familiar builder pattern. We tackle the complex building of the Request
object with friendly methods until we have the desired object available for us to use.
Let’s try it out!
2.5.0 :019 > DataMuse.words.with_similar_meaning_to('love').that_sound_like('earning').limit(5).fetch => [{"word"=>"yearning", "score"=>50641, "tags"=>["n"]}, {"word"=>"raining", "score"=>26364, "tags"=>["adj"]}, {"word"=>"nine", "score"=>6364, "tags"=>["n", "adj"]}, {"word"=>"none", "score"=>6364, "tags"=>["n"]}, {"word"=>"ern", "score"=>5304, "tags"=>["n"]}]
Voilà, works like a charm. Our client performed the call for us and returned a list of the 5 top matching words according to our criteria (the Datamuse API returns the most relevant matches first, sorted by score
in a descending order).
Duplicating and Updating Data Classes
The above client seems to serves our needs: it calls the Datamuse API the way it wishes to be interacted with while we write Ruby code the way we want to in order to achieve it. However, I have to admit that I am slightly disturbed by the way this was accomplished: the ever-growing, single string in @path
feels somewhat sketchy to me. It will probably be quite messy to perform validations before fetching the query, as all the parameters are tunnelled into a single variable. What if instead of adding the parameters to @path
along each method call we could keep track of each parameter by itself, only to compose the final path once, at the end of the process, upon calling #fetch
?
To do that, I suggest choosing a keyword argument interface to create Request
objects. The #initialize
method might look like that:
An #initialize
method with only keyword arguments as parameters is a very elegant way to ensure your objects are created properly while maintaining a readable code. Since data classes such as the Request
class often have more than 3 parameters, keyword argument initialization just makes the creation of such objects clearer.
The plan is to define the query-shaping methods in a way that will create a new object with the same data as in the existing one — except for the data to be supplied or overridden. But how can this be accomplished without adding attr_writer
s on the Request
class? In other words, how can we keep our objects immutable by nature in this task?
To combat this pitfall, I realized I needed something like a dup_and_update
method. Obviously it is relevant only for data-classes, such as this one. If we indeed keep all the parameters of the #initialize
method as keyword arguments, we may benefit from some simple-yet-neat Ruby tricks to solve this problem. I came up with the UpDupable
module (as in “update-able and duplicate-able”) which introduces the #dup_and_update
instance method. It is intended to be included in such data classes and be available on their instances. Here is the essential code for it:
The important bits here are in lines 2 and 3. The #dup_and_update
method accepts **args
: the double splat(**
) is the way we indicate to Ruby that we are expecting keyword arguments as input, and we wish to treat them as a hash. (Read more about Ruby’s double splat notation here).
Then, in line 3, we simply create a new instance of our class with the existing data it holds (represented by clean_ivars
) merged with the new data from args
.
A side note: this interface assumes that the initialization of the including class is fully matched in terms of the names used for the keywords arguments and the instance variables within the body of the #initialize
method. I find that this is a good practice for data-classes in general. Obviously there are further validations to make, such as making sure that the including class is indeed initialized with keywords arguments only, or that non-initialization instance variables, if there are such, are disregarded.
A Refined Path
With our new UpDupable
module at hand, we could assemble our improved API client. The entry-point DataMuse
module remains quite similar:
The .words
method now simply creates a new empty Request
object. We do not need to pass the beginning of the HTTP path since it will be composed at the end of the assembling of the request. Had we covered additional endpoints, I would probably name the Request
class WordsRequest
(or perhaps use namespacing), but for now it should do.
Our Request
class now looks like this:
The public interface is the same as before, but the way it works is essentially different, with the help of the added UpDupable
interface and the keyword argument initialization. The composition of the HTTP path is done at the end of the process, upon calling to #fetch
, so if we wanted, we could include specific validations here. In my view, this approach is significantly more elegant for maintenance and extension, all the while offering the developer who uses it the exact same feel.
Summary
We’ve came a long way from exploring the initial existing API client that forced us to learn the external API in order to make proper calls, through the initial alternative using self-chaining objects, to this final, cleaner client. There is obviously a lot more to do in order to make this a real, gem-like API client, but as a sketch, I am content due to the extensible and composable code and the English-like interface. The techniques I’ve covered in this story can apply to the design of classes’ interfaces in general as well. I hope reading it has inspired you!
If you found this story interesting or helpful, please support it by clapping it👏 . Thank you for reading!