As you've probably guessed by the title of my article, I still consider Ruby on Rails as a relevant technology that offers a lot of value, especially when combined with ReactJS as it's frontend counterpart. Here's how I approach the topic.
Introduction
Have you ever had the problem when you wanted to use an external API that didn’t have a ready-made library and you needed to write everything from scratch? You weren’t sure how everything should be split and separated in order to write readable and clear code?
If you answered yes to any of these questions, this article is for you. I’ll show you how to write code that is responsible for connecting with the external API and how to split everything in order to write clear and maintainable code. It doesn’t matter if you use Rails or plain Ruby code, this code is flexible, so you can use it wherever you want.
Let’s add a structure
Your API wrappers should be kept in the app/apis folder. In the Rails application there is a folder for models, views, controllers – so why not add another one, for APIs?
Let’s assume that there is no ready-made gem for Github API and we need to write their API wrapper. Start with creating a github_api folder in the apis folder.
The next step is adding another folder, a version of our API, and in this case, we’ll use Github API v3, so you should create a v3 folder. This is really important; in this case, when there are v4 and v4 APIs, you’ll just create another folder and everything will be separated.
The next step is to create a file called client.rb – in this file you’ll keep the whole logic!
It’ll be the heart of our wrapper!
External gems
In order to build a wrapper, we’ll need an HTTP client gem and a gem which is able to parse and decode JSON files. We’ll use two gems:
- Faraday – a gem used for HTTP requests, which is quite powerful and really easy to use.
- Oj – a gem used for JSON parsing, it’s written in C, so it’s really fast, much faster than original JSON gem.
If you use the Ruby/Rails app, add these two following gems into your Gemfile. If you don’t have a Gemfile, simply require these libraries in your client.rb file:
require ‘oj’ require ‘faraday’
API Client
You should start with reading API docs, checking how to make a request, what the endpoint is named if the API requires any API token, and what the required parameters are. Everything else we can find here – in the official docs.
Basically, each API client should look pretty similar and should have a client variable on which you’ll base all endpoints. During the initialization process, you should think about if you need to pass an API token and how your response should be parsed.
Now, when you have learned more about Github API and have read about it in the docs, you can then write some code inside your client class. Let’s start with the client method and request method. Your client method will be responsible for the initialization of the Faraday client, a request method, and will send a request based on a passed HTTP method, endpoint, and parameters. It should also parse a JSON response.
A simple version of the client should look like this:
module GithubAPI module V3 class Client API_ENDPOINT = 'https://api.github.com'.freeze attr_reader :oauth_token def initialize(oauth_token = nil) @oauth_token = oauth_token end def user_repos(username) request( http_method: :get, endpoint: "users/#{username}/repos" ) end def user_orgs(username) request( http_method: :get, endpoint: "users/#{username}/orgs" ) end private def client @_client ||= Faraday.new(API_ENDPOINT) do |client| client.request :url_encoded client.adapter Faraday.default_adapter client.headers['Authorization'] = "token #{oauth_token}" if oauth_token.present? end end def request(http_method:, endpoint:, params: {}) response = client.public_send(http_method, endpoint, params) Oj.load(response.body) end end end end
You should only allow calling HTTP endpoint methods (like user_repos), each of these methods should just call the request private method with proper parameters.
In this case, you can use your API wrapper like this:
github_client = GithubAPI::V3::Client.new('myApiKey') user_repos = github_client.user_repos('piotrjaworski') user_orgs = github_client.user_orgs('piotrjaworski')
Error handling
If you’re observant, you probably noticed that we didn’t handle any of the exceptions. What happens when the Github API returns with an error or we reach a limit of 60 requests per hour without an API token? The client will crash!
What you should do is check the HTTP response status. If it’s 200 (ok), it means that an external API (in this case Github) has returned a successful response. In any other case, you should handle client errors.
You must handle exceptions and send an error message.
A Faraday response variable has access to a response’s status variable, which keeps an HTTP status of the response. A good practice would be to create a separate exception for each client error, like for 404 – NotFoundError and return it to the client.
You can write something like this:
module GithubAPI module V3 class Client GithubAPIError = Class.new(StandardError) BadRequestError = Class.new(GithubAPIError) UnauthorizedError = Class.new(GithubAPIError) ForbiddenError = Class.new(GithubAPIError) ApiRequestsQuotaReachedError = Class.new(GithubAPIError) NotFoundError = Class.new(GithubAPIError) UnprocessableEntityError = Class.new(GithubAPIError) ApiError = Class.new(GithubAPIError) HTTP_OK_CODE = 200 HTTP_BAD_REQUEST_CODE = 400 HTTP_UNAUTHORIZED_CODE = 401 HTTP_FORBIDDEN_CODE = 403 HTTP_NOT_FOUND_CODE = 404 HTTP_UNPROCESSABLE_ENTITY_CODE = 429 ... private def request(http_method:, endpoint:, params: {}) response = client.public_send(http_method, endpoint, params) parsed_response = Oj.load(response.body) return parsed_response if response_successful? raise error_class, "Code: #{response.status}, response: #{response.body}" end def error_class case response.status when HTTP_BAD_REQUEST_CODE BadRequestError when HTTP_UNAUTHORIZED_CODE UnauthorizedError when HTTP_FORBIDDEN_CODE return ApiRequestsQuotaReachedError if api_requests_quota_reached? ForbiddenError when HTTP_NOT_FOUND_CODE NotFoundError when HTTP_UNPROCESSABLE_ENTITY_CODE UnprocessableEntityError else ApiError end end def response_successful? response.status == HTTP_OK_CODE end def api_requests_quota_reached? response.body.match?(API_REQUSTS_QUOTA_REACHED_MESSAGE) end ... end end end
What you’re doing here is checking if a response’s status is 200, if it’s 200, just return a parsed JSON. If something has gone wrong, throw an exception with a response error message. Everything is clear and readable!
Another thing which we need to handle is the Github API request quota limit.
In this case, Github API will also return a 403 HTTP status code, but you can check the JSON response body and see if you have reached the limit.
Remember, without a token, you can only make 60 requests per hour!
Small cleanups
As you’ve probably noticed, when you create any other API wrapper, you’ll duplicate the HTTP status codes and exceptions. You should definitely move all exception classes and HTTP status code constants to a separate module. Let’s do it!
Create an HttpStatusCodes module under the app/apis folder – it can be reused for all providers!
module HttpStatusCodes HTTP_OK_CODE = 200 HTTP_BAD_REQUEST_CODE = 400 HTTP_UNAUTHORIZED_CODE = 401 HTTP_FORBIDDEN_CODE = 403 HTTP_NOT_FOUND_CODE = 404 HTTP_UNPROCESSABLE_ENTITY_CODE = 429 end
Also, create an ApiExceptions module under the same folder, for exceptions.
module ApiExceptions APIExceptionError = Class.new(APIExceptionError) BadRequestError = Class.new(APIExceptionError) UnauthorizedError = Class.new(APIExceptionError) ForbiddenError = Class.new(APIExceptionError) ApiRequestsQuotaReachedError = Class.new(APIExceptionError) NotFoundError = Class.new(APIExceptionError) UnprocessableEntityError = Class.new(APIExceptionError) ApiError = Class.new(APIExceptionError) end
As you can see, each exception class inherits from the APIExceptionError class. You may be wondering why – if you want to customize any exception messages or similar, you can just modify the parent class, in this case, the APIExceptionError class, without needing to touch any other exception class like NotFoundError or ForbiddenError.
The final implementation of the client class should look like this:
module GithubApi module V3 class Client include HttpStatusCodes include ApiExceptions API_ENDPOINT = 'https://api.github.com'.freeze API_REQUSTS_QUOTA_REACHED_MESSAGE = 'API rate limit exceeded'.freeze attr_reader :oauth_token def initialize(oauth_token = nil) @oauth_token = oauth_token end def user_repos(username) request( http_method: :get, endpoint: "users/#{username}/repos" ) end def user_orgs(username) request( http_method: :get, endpoint: "users/#{username}/orgs" ) end private def client @_client ||= Faraday.new(API_ENDPOINT) do |client| client.request :url_encoded client.adapter Faraday.default_adapter client.headers['Authorization'] = "token #{oauth_token}" if oauth_token.present? end end def request(http_method:, endpoint:, params: {}) response = client.public_send(http_method, endpoint, params) parsed_response = Oj.load(response.body) return parsed_response if response_successful? raise error_class, "Code: #{response.status}, response: #{response.body}" end def error_class case response.status when HTTP_BAD_REQUEST_CODE BadRequestError when HTTP_UNAUTHORIZED_CODE UnauthorizedError when HTTP_FORBIDDEN_CODE return ApiRequestsQuotaReachedError if api_requests_quota_reached? ForbiddenError when HTTP_NOT_FOUND_CODE NotFoundError when HTTP_UNPROCESSABLE_ENTITY_CODE UnprocessableEntityError else ApiError end end def response_successful? response.status == HTTP_OK_CODE end def api_requests_quota_reached? response.body.match?(API_REQUSTS_QUOTA_REACHED_MESSAGE) end end end end
API Wrapper in Rails – summary
As you can see, in a few steps you have been able to write a readable Github API wrapper using Faraday. It’s ready for any extensions and new endpoints, all you need is to create a new method with a valid endpoint name and parameters. It’s pretty simple!
The current implementation of the wrapper can even be moved to a Ruby gem, and in this case, our application will have less code and the API wrapper logic will be separated. Just use the library directly in the code without using an implementation!