Building a basic API client in Ruby
- Name
- Dennis Paagman
- @djfpaagman
Let’s start with figuring out what the most simple client can look like. I’m using the Github API as an example throughout this blog post, since it’s a well documented REST API of a decent size and you have most likely used Github, so the concepts of the different endpoints should be familiar.
Let’s say we want to grab a list of a user’s Github repositories. The most basic implementation could be a system call to curl.
class MyGithubClient
def self.repos
JSON.parse(`curl https://api.github.com/users/djfpaagman/repos`)
end
end
That’s it! If you now call MyGithubClient.repos
it wil return a long list of my Github public repositories. But that’s very unpractical when you want to do more complex things, and a bit cheating since you’re doing a system call (usually not recommended). Let’s build a proper class, while still keeping it as simple as possible using only built-in Ruby libraries.
class MyGithubClient
def self.repos
uri = URI("https://api.github.com/users/djfpaagman/repos")
response = Net::HTTP.get_response(uri)
JSON.parse(response.body)
end
end
Of course this does not implement basic things like authentication yet. But for some use cases, a simple class like this can be enough! Always consider what kind of implementation you can be happy with for your use case.
Even in this small example, you can already feel the clunkiness of the built-in net/http library. Why do we need to care about URI
for example? That’s probably why a lot of people thought they could do better and wrote a http library. We ended up with so many (and a lot of very good!) http libraries. We’ll have a look at all (well, a few) of them later.
For the rest of this post I will use the http.rb gem, which is a mature, fast library with a very concise and clean API. It also has great documentation. With it you will be able to do most things you need with a few lines of code.
Using the http gem, the above example can then be rewritten as:
class MyGithubClient
def self.repos
HTTP.get("https://api.github.com/users/djfpaagman/repos").parse(:json)
end
end
The library abstracts away some stuff with a clean and chain-able syntax. Perfect!
We are missing a few important things, though. It does not do any form of authentication which is probably something you need. So let’s add that! Instead of using a class method we’ll want to have a class we can instantiate with an API token and then call the API from the instance of that class. Let’s set that up:
class MyGithubClient
def initialize(api_token:)
@api_token = api_token
end
def repos
HTTP
.auth("Bearer #{@api_token}")
.get("https://api.github.com/users/djfpaagman/repos")
.parse(:json)
end
end
From here on you will call the API slightly different, by creating a new client instance first:
client = MyGithubClient.new(api_token: "...")
client.repos
Suppose we need a few more endpoints, you can duplicate the call to HTTP in every method, but that’s not very clean and DRY as you will need to include the API token every time and make sure to parse the response. We can pull the generic behaviour into a private method though:
class MyGithubClient
def initialize(api_token:)
@api_token = api_token
end
def repos
get("/users/djfpaagman/repos")
end
def profile
get("/users/djfpaagman")
end
private
def get(path)
HTTP
.auth("Bearer #{@api_token}")
.get("https://api.github.com#{path}")
.parse(:json)
end
end
Neat and clean! I also moved the hostname to the new get
method itself, only requiring paths to be passed down from our individual methods.
Let’s say we also want to create new repositories from our client. This requires POSTing data to the API. We can create a new private post
method to handle that, as it will also needs some kind of body to be sent along.
def post(path, data)
HTTP
.auth("Bearer #{@api_token}")
.post("https://api.github.com#{path}", json: data)
end
Once again the http library abstracts away everything here, we can simple pass in a Hash of data to the json
parameter and it makes sure our data is encoded as JSON and send along in the body of the request.
But there is still some duplication going on, we pass the in the authentication header exactly the same way as we do for the GET requests. There’s one more round of abstraction we can introduce here:
class MyGithubClient
# ...
def get(path)
request(:get, path).parse(:json)
end
def post(path, data)
request(:post, path, json: data)
end
def request(method, path, options = {})
HTTP
.auth("Bearer #{@api_token}")
.request(method, "https://api.github.com#{path}", options)
end
end
We introduce a new request method, which has arguments the method (we only use :get
and :post
in the example, but all HTTP methods are available), the path of the endpoint and optionally some options. For GET requests we simply call the request method with :get
and the path and parse the response as before. For POST requests we also supply the json data through the options argument, similar to our earlier example.
If you tie this all together you end up with a class that looks like this:
class MyGithubClient
def initialize(api_token:)
@api_token = api_token
end
def repos
get("/users/djfpaagman/repos")
end
def profile
get("/users/djfpaagman")
end
def create_repo(name)
post("/users/djfpaagman/repos", {name:})
end
private
def get(path)
request(:get, path).parse(:json)
end
def post(path, data)
request(:post, path, json: data)
end
def request(method, path, options = {})
HTTP
.auth("Bearer #{@api_token}")
.request(method, "https://api.github.com#{path}", options)
end
end
Testing
We also want to write at least some tests for our little API client. I really like to use WebMock as it allows you to easily stub requests and integrates nicely with almost all http and testing libraries. To get our tests going we need to initialise the client and stub the request. Since we want to test the response from the API, we need to stub that as well.
class TestClient < Minitest::Test
def setup
@client = MyGithubClient.new(api_token: "123")
end
def test_profile
stub_request(:get, "https://api.github.com/users/djfpaagman")
.with(headers: {Authorization: "Bearer 123"})
.to_return(body: {name: "Dennis Paagman"}.to_json)
assert_equal("Dennis Paagman", @client.profile["name"])
end
end
This test initializes the client, and stubs a request with the right Authorization header and a simple json response with my name in it. In this example we just return a simplified version of the actual API response, only including the fields we want to test on. At this moment we mostly want to test the behaviour of the client itself, not the actual output of the API. We can assume that to be correct based on what we put in.
This is a trade off: you will need to manually manage the fields returned, it’s possible an error sneaks or you could start assuming that this is actually all data the API returns. If you want to make this more robust, there are a couple of options. You can put the whole JSON response in a fixture and load that file as the response. You can also use the vcr gem to actually replay the whole API request (including all request and response headers). For now we stick with simplified stubbed responses!
A test for the create_repo
method (which does a POST instead of a GET) can look like:
def test_create_repo
stub_request(:post, "https://api.github.com/users/djfpaagman/repos")
.with(
body: {name: "awesome-new-project"},
headers: {Authorization: "Bearer 123"}
)
assert @client.create_repo("awesome-new-project").status.success?
end
As you can see there’s a bit of duplication going on again. Let’s abstract that away, similarly as we did before:
class TestClient < Minitest::Test
def setup
@client = MyGithubClient.new(api_token: "123")
end
def test_profile
stub(:get, "/users/djfpaagman", response: {name: "Dennis Paagman"})
assert_equal("Dennis Paagman", @client.profile["name"])
end
def test_create_repo
stub(:post, "/users/djfpaagman/repos", body: {name: "awesome-new-project"})
assert @client.create_repo("awesome-new-project").status.success?
end
private
def stub(method, path, body: nil, response: {})
stub_request(method, "https://api.github.com#{path}")
.with(body: body, headers: {Authorization: "Bearer 123"})
.to_return(body: response.to_json)
end
end
Wrapping up
All together, this gives a nice basic API client that can make different types of requests and has some tests to make sure it does what you want. Of course it does not do a lot of things yet, like dealing with pagination, rate limits, errors, etc. You can build these out when you need them or when you encounter issues.
The architecture of a simple class with instance methods for different endpoints can go a long way and has served me well over the years. As your client grows, I can imagine you want to split them out into different modules. It could also be nice to handle the returned data more cleanly, for example using classes (or structs) instead of just hashes.