Tejas Rana

How Sequel and Sinatra Solve Ruby’s API Problem

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.

Ruby API on a diet: introducing Sequel and Sinatra.

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

Code Structure

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

Exit mobile version