Introduction
In recent years, the number of JavaScript single page application frameworks and mobile applications has increased substantially. This imposes a correspondingly increased demand for server-side APIs. With Ruby on Rails being one of the today’s most popular web development frameworks, it is a natural choice among many developers for creating back-end API applications.
Yet while the Ruby on Rails architectural paradigm makes it quite easy to create back-end API applications, using Rails only for the API is overkill. In fact, it’s overkill to the point that even the Rails team has recognized this and has therefore introduced a new API-only mode in version 5. With this new feature in Ruby on Rails, creating API-only applications in Rails became an even easier and more viable option.
But there are other options too. The most notable are two very mature and powerful gems, which in combination provide powerful tools for creating server-side APIs. They are Sinatra and Sequel.
Both of these gems have a very rich feature set: Sinatra serves as the domain specific language (DSL) for web applications, and Sequel serves as the object-relational mapping (ORM) layer. So, let’s take a brief look at each of them.
Sinatra
Sinatra is Rack-based web application framework. The Rack is a well known Ruby web server interface. It is used by many frameworks, like Ruby on Rails, for example, and supports lot of web servers, like WEBrick, Thin, or Puma. Sinatra provides a minimal interface for writing web applications in Ruby, and one of its most compelling features is support for middleware components. These components lie between the application and the web server, and can monitor and manipulate requests and responses.
For utilizing this Rack feature, Sinatra defines internal DSL for creating web applications. Its philosophy is very simple: Routes are represented by HTTP methods, followed by a route matching a pattern. A Ruby block within which request is processed and the response is formed.
get '/' do
'Hello from sinatra'
end
The route matching pattern can also include a named parameter. When route block is executed, a parameter value is passed to the block through the params
variable.
get '/players/:sport_id' do
# Parameter value accessible through params[:sport_id]
end
Matching patterns can use splat operator *
which makes parameter values available through params[:splat]
.
get '/players/*/:year' do
# /players/performances/2016
# Parameters - params['splat'] -> ['performances'], params[:year] -> 2016
end
This is not the end of Sinatra’s possibilities related to route matching. It can use more complex matching logic through regular expressions, as well as custom matchers.
Sinatra understands all of the standard HTTP verbs needed for creating a REST API: Get, Post, Put, Patch, Delete, and Options. Route priorities are determined by the order in which they are defined, and the first route that matches a request is the one that serves that request.
Sinatra applications can be written in two ways; using classical or modular style. The main difference between them is that, with the classical style, we can have only one Sinatra application per Ruby process. Other differences are minor enough that, in most cases, they can be ignored, and the default settings can be used.
Classical Approach
Implementing classical application is straightforward. We just have to load Sinatra and implement route handlers:
require 'sinatra'
get '/' do
'Hello from Sinatra'
end
By saving this code to demo_api_classic.rb
file, we can start the application directly by executing the following command:
ruby demo_api_classic.rb
However, if the application is to be deployed with Rack handlers, like Passenger, it is better to start it with the Rack configuration config.ru
file.
require './demo_api_classic'
run Sinatra::Application
With the config.ru
file in place, the application is started with the following command:
rackup config.ru
Modular Approach
Modular Sinatra applications are created by subclassing either Sinatra::Base
or Sinatra::Application
:
require 'sinatra'
class DemoApi < Sinatra::Application
# Application code
run! if app_file == $0
end
The statement beginning with run!
is used for starting the application directly, with ruby demo_api.rb
, just as with the classical application. On the other hand, if the application is to be deployed with Rack, the handlers content of rackup.ru
must be:
require './demo_api'
run DemoApi
Sequel
Sequel is the second tool in this set. In contrast to ActiveRecord, which is part of the Ruby on Rails, Sequel’s dependencies are very small. At the same time, it is quite feature rich and can be used for all kinds of database manipulation tasks. With its simple domain specific language, Sequel relieves the developer from all the problems with maintaining connections, constructing SQL queries, fetching data from (and sending data back to) the database.
For example, establishing a connection with the database is very simple:
DB = Sequel.connect(adapter: :postgres, database: 'my_db', host: 'localhost', user: 'db_user')
The connect method returns a database object, in this case, Sequel::Postgres::Database
, which can be further used to execute raw SQL.
DB['select count(*) from players']
Alternatively, to create a new dataset object:
DB[:players]
Both of these statements create a dataset object, which is a basic Sequel entity.
One of the most important Sequel dataset features is that it does not execute queries immediately. This makes it possible to store datasets for later use and, in most cases, to chain them.
users = DB[:players].where(sport: 'tennis')
So, if a dataset does not hit the database immediately, the question is, when does it? Sequel executes SQL on the database when so-called “executable methods” are used. These methods are, to name a few, all
, each
,map
, first
, and last
.
Sequel is extensible, and its extensibility is a result of a fundamental architectural decision to build a small core complemented with a plugin system. Features are easily added through plugins which are, actually, Ruby modules. The most important plugin is the Model
plugin. It is an empty plugin which does not define any class or instance methods by itself. Instead, it includes other plugins (submodules) which define a class, instance or model dataset methods. The Model plugin enables the use of Sequel as the object-relational-mapping (ORM) tool and is often referred to as the “base plugin”.
class Player < Sequel::Model
end
The Sequel model automatically parses the database schema and sets up all necessary accessor methods for all columns. It assumes that table name is plural and is an underscored version of the model name. In case there is a need to work with databases that do not follow this naming convention, the table name can be explicitly set when the model is defined.
class Player < Sequel::Model(:player)
end
So, we now have everything we need to start building the back-end API.
Building the API
Contrary to Rails, Sinatra does not impose any project structure. However, since it is always a good practice to organize code for easier maintenance and development, we’ll do it here too, with the following directory structure:
project root
|-config
|-helpers
|-models
|-routes
The application configuration will be loaded from the YAML configuration file for the current environment with:
Sinatra::Application.config_file File.join(File.dirname(__FILE__),
'config',
"#{Sinatra::Application.settings.environment}_config.yml")
By default, Sinatra::Applicationsettings.environment
value is development,
and it is changed by setting the RACK_ENV
environment variable.
Furthermore, our application must load all files from the other three directories. We can do that easily by running:
%w{helpers models routes}.each {|dir| Dir.glob("#{dir}/*.rb", &method(:require))}
At first glance, this way of loading might look convenient. However, with this one line of the code, we cannot easily skip files, because it will load all the files from the directories in the array. That’s why we will use a more efficient single-file load approach, which assumes that in each folder we have a manifest file init.rb
, which loads all other files from the directory. Also, we will add a target directory to the Ruby load path:
%w{helpers models routes}.each do |dir|
$LOAD_PATH << File.expand_path('.', File.join(File.dirname(__FILE__), dir))
require File.join(dir, 'init')
end
This approach requires a bit more work because we have to maintain require statements in each init.rb
file but, in return, we get more control, and we can easily leave one or more files out by removing them from the manifest init.rb
file in the target directory.
API Authentication
The first thing we need in each API is authentication. We will implement it as a helper module. Complete authentication logic will be in the helpers/authentication.rb
file.
require 'multi_json'
module Sinatra
module Authentication
def authenticate!
client_id = request['client_id']
client_secret = request['client_secret']
# Authenticate client here
halt 401, MultiJson.dump({message: "You are not authorized to access this resource"}) unless authenticated?
end
def current_client
@current_client
end
def authenticated?
!current_client.nil?
end
end
helpers Authentication
end
All we have to do now is to load this file by adding a require statement in the helper manifest file (helpers/init.rb
) and to call the authenticate!
method in Sinatra’s before
hook which will be executed before processing any request.
before do
authenticate!
end
Database
Next, we have to prepare our database for the application. There are many ways to prepare the database, but since we are using Sequel, it is natural to do it by using migrators. Sequel comes with two migrator types – integer and timestamp based. Each one has its advantages and drawbacks. In our example, we decided to use the Sequel’s timestamp migrator, which requires migration files to be prefixed with a timestamp. The timestamp migrator is very flexible and can accept various timestamp formats, but we will only use the one that consists of year, month, day, hour, minute, and second. Here are our two migration files:
# db/migrations/20160710094000_sports.rb
Sequel.migration do
change do
create_table(:sports) do
primary_key :id
String :name, :null => false
end
end
end
# db/migrations/20160710094100_players.rb
Sequel.migration do
change do
create_table(:players) do
primary_key :id
String :name, :null => false
foreign_key :sport_id, :sports
end
end
end
We are now ready to create a database with all the tables.
bundle exec sequel -m db/migrations sqlite://db/development.sqlite3
Finally, we have the model files sport.rb
and player.rb
in the models
directory.
# models/sport.rb
class Sport < Sequel::Model
one_to_many :players
def to_api
{
id: id,
name: name
}
end
end
# models/player.rb
class Player < Sequel::Model
many_to_one :sport
def to_api
{
id: id,
name: name,
sport_id: sport_id
}
end
end
Here we are employing a Sequel way of defining model relationships, where the Sport
object has many players and Player
can have only one sport. Also, each model defines its to_api
method, which returns a hash with attributes that need to be serialized. This is a general approach we can use for various formats. However, if we will only use a JSON format in our API, we could use Ruby’s to_json
with only
argument to restrict serialization to required attributes, i.e. player.to_json(only: [:id, :name, :sport_i])
. Of course, we could also define a BaseModel
that inherits from Sequel::Model
and defines a default to_api
method, from which inherit all models could then inherit.
Now, we can start implementing the actual API endpoints.
API Endpoints
We will keep the definition of all endpoints in files within the routes
directory. Since we are using manifest files for loading files, we will group routes by resources (i.e., keep all sports related routes in sports.rb
file, all players routes in routes.rb
, and so on).
# routes/sports.rb
class DemoApi < Sinatra::Application
get "/sports/?" do
MultiJson.dump(Sport.all.map { |s| s.to_api })
end
get "/sports/:id" do
sport = Sport.where(id: params[:id]).first
MultiJson.dump(sport ? sport.to_api : {})
end
get "/sports/:id/players/?" do
sport = Sport.where(id: params[:id]).first
MultiJson.dump(sport ? sport.players.map { |p| p.to_api } : [])
end
end
# routes/players.rb
class DemoApi < Sinatra::Application
get "/players/?" do
MultiJson.dump(Player.all.map { |p| s.to_api })
end
get "/players/:id/?" do
player = Player.where(id: params[:id]).first
MultiJson.dump(player ? player.to_api : {})
end
end
Nested routes, like the one for getting all the players within one sport /sports/:id/players
, can either be defined by placing them together with other routes, or by creating a separate resource file that will contain only the nested routes.
With designated routes, the application is now ready to accept requests:
curl -i -XGET 'https://localhost:9292/sports?client_id=<client_id>&client_secret=<client_secret>'
Note that as required by the application’s authentication system defined in helpers/authentication.rb
file, we are passing credentials directly in request parameters.
Conclusion
The principles demonstrated in this simple example application apply to any API back-end application. It is not based on the model-view-controller (MVC) architecture, yet it keeps a clear separation of responsibilities in a similar way; complete business logic is kept in model files while handling requests is done in Sinatra’s routes methods. Contrary to MVC architecture, where views are used to render responses, this application does that at the same place where it handles requests – in the routes methods. With new helper files, the application can be easily extended to send pagination or, if needed, request limits information back to the user in response headers.
In the end, we’ve built a complete API with a very simple toolset and without losing any functionality. The limited number of dependencies helps ensure that the application loads and starts much faster, and has a much smaller memory footprint than a Rails based one would have. So, next time you start work on a new API in Ruby, consider using Sinatra and Sequel since they are very powerful tools for such a use case.
Source: Toptal