Solving Ruby's API Problem with Sequel and Sinatra

Introduction

In recent times, there’s been a significant surge in the use of JavaScript single-page application frameworks and mobile apps. Consequently, the demand for robust server-side APIs has also skyrocketed. Ruby on Rails, being a leading web development framework, has naturally become a popular choice for building these back-end API applications.

However, while Rails’ architecture makes crafting back-end APIs straightforward, relying solely on Rails for this purpose can be excessive. The Rails team themselves have acknowledged this, leading to the introduction of a dedicated API-only mode in version 5, simplifying API-only application development in Rails.

However, there are alternative approaches worth considering. Two well-established and powerful gems, Sinatra and Sequel, provide a compelling toolkit for creating server-side APIs.

Both gems come packed with features: Sinatra acts as the domain-specific language (DSL) for web applications, while Sequel serves as the object-relational mapping (ORM) layer. Let’s delve into each of them briefly.

API With Sinatra and Sequel: Ruby Tutorial
Ruby API on a diet: introducing Sequel and Sinatra.

Sinatra

Sinatra, built on the Rack interface, is a web application framework for Ruby. Widely used by frameworks like Ruby on Rails, Rack supports numerous web servers such as WEBrick, Thin, and Puma. Sinatra provides a streamlined interface for crafting web applications in Ruby and boasts a powerful middleware component system. Positioned between the application and the web server, these components can intercept and manipulate both requests and responses.

Leveraging this Rack functionality, Sinatra implements an internal DSL for building web applications. Its philosophy is elegantly simple: routes are defined by HTTP methods followed by a pattern for matching. A Ruby block handles request processing and response generation.

1
2
3
get '/' do
  'Hello from sinatra'
end

Route matching patterns can incorporate named parameters. When a route’s block is executed, the corresponding parameter value is passed to it via the params variable.

1
2
3
get '/players/:sport_id' do
  # Parameter value accessible through params[:sport_id]
end

The splat operator * in matching patterns captures multiple values, accessible through params[:splat].

1
2
3
4
get '/players/*/:year' do
  # /players/performances/2016
  # Parameters - params['splat'] -> ['performances'], params[:year] -> 2016
end

Sinatra’s route matching capabilities extend further with support for sophisticated logic using regular expressions and custom matchers.

Sinatra seamlessly handles standard HTTP verbs essential for RESTful APIs: Get, Post, Put, Patch, Delete, and Options. Route precedence is determined by definition order, with the first matching route handling the request.

Sinatra applications can be structured in two ways: classical or modular. The primary difference lies in the classical style permitting only one Sinatra application per Ruby process. Other distinctions are generally minor and can often be disregarded, allowing for default settings.

Classical Approach

Implementing a classical application is straightforward. Simply load Sinatra and define route handlers:

1
2
3
4
5
require 'sinatra'

get '/' do
  'Hello from Sinatra'
end

Saving this code as demo_api_classic.rb allows direct application startup with:

1
ruby demo_api_classic.rb

However, for deployment with Rack handlers like Passenger, starting with a Rack configuration file (config.ru) is preferable.

1
2
3
require './demo_api_classic'

run Sinatra::Application

With config.ru in place, the application is launched using:

1
rackup config.ru

Modular Approach

Modular Sinatra applications are created by subclassing either Sinatra::Base or Sinatra::Application:

1
2
3
4
5
6
7
require 'sinatra'

class DemoApi < Sinatra::Application
  # Application code

  run! if app_file == $0
end

Similar to the classical approach, run! starts the application directly with ruby demo_api.rb. Conversely, for Rack deployments, the rackup.ru file should contain:

1
2
3
require './demo_api'

run DemoApi

Sequel

Sequel, the second tool in our arsenal, is a database library with minimal dependencies, unlike ActiveRecord, which is tightly coupled with Rails. Despite its lightweight nature, Sequel is remarkably feature-rich and handles various database operations effectively. Its intuitive DSL simplifies database interactions, abstracting away connection management, SQL query construction, and data retrieval and persistence.

For instance, establishing a database connection is remarkably simple:

1
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, for executing raw SQL.

1
DB['select count(*) from players']

Alternatively, a new dataset object can be created:

1
DB[:players]

Both statements generate a dataset object, the fundamental entity in Sequel.

A noteworthy feature of Sequel datasets is their deferred query execution. This allows for storing and chaining datasets before interacting with the database.

1
users = DB[:players].where(sport: 'tennis')

If datasets don’t immediately query the database, when does it happen? Sequel executes SQL when “executable methods” are invoked, such as all, each, map, first, and last.

Sequel’s extensibility stems from its core architectural principle: a small core augmented by a plugin system. Features are seamlessly integrated through plugins, essentially Ruby modules. The most crucial plugin is the Model plugin. This plugin acts as a foundation, including other plugins (submodules) that define class, instance, or model dataset methods. This enables Sequel’s use as an ORM tool, often referred to as the “base plugin”.

1
2
class Player < Sequel::Model
end

The Sequel model automatically analyzes the database schema and sets up appropriate accessor methods for columns. It assumes plural, underscored table names derived from the model name. To accommodate databases deviating from this convention, table names can be explicitly specified during model definition.

1
2
class Player < Sequel::Model(:player)
end

We’re now equipped to start constructing our back-end API.

Building the API

Code Structure

Unlike Rails, Sinatra doesn’t enforce a specific project structure. However, for maintainability and ease of development, we’ll adopt the following structure:

1
2
3
4
5
project root
   |-config
   |-helpers
   |-models
   |-routes

Application configuration will be loaded from a YAML file based on the current environment:

1
2
3
Sinatra::Application.config_file File.join(File.dirname(__FILE__),
                                           'config',
                                           "#{Sinatra::Application.settings.environment}_config.yml")

By default, Sinatra::Applicationsettings.environment is set to development, modifiable via the RACK_ENV environment variable.

Our application needs to load files from the other three directories. A simple approach is:

1
%w{helpers models routes}.each {|dir| Dir.glob("#{dir}/*.rb", &method(:require))}

While seemingly convenient, this approach lacks flexibility in skipping files, as it loads everything within the specified directories. We’ll opt for a more efficient single-file loading approach, utilizing a manifest file (init.rb) within each folder to load other files in that directory. Additionally, we’ll add a target directory to the Ruby load path:

1
2
3
4
%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 necessitates maintaining require statements in each init.rb file. However, it grants greater control, allowing for easy exclusion of files by removing them from the corresponding init.rb.

API Authentication

Authentication is paramount in any API. We’ll implement it as a helper module within helpers/authentication.rb.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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

We’ll load this file by adding a require statement in the helper manifest (helpers/init.rb) and invoke the authenticate! method in Sinatra’s before hook, ensuring execution before any request processing.

1
2
3
before do
  authenticate!
end

Database

Let’s prepare our database. While various methods exist, we’ll leverage Sequel’s migrators. Sequel offers two types: integer-based and timestamp-based, each with its pros and cons. Our example will use the timestamp-based migrator, which prefixes migration files with timestamps. This migrator is versatile, supporting multiple timestamp formats, but we’ll stick to the year-month-day-hour-minute-second format. Here are our migration files:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# 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

Now, let’s create the database and tables:

1
bundle exec sequel -m db/migrations sqlite://db/development.sqlite3

Finally, we have sport.rb and player.rb model files in the models directory.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 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 define model relationships the Sequel way, with Sport having many players and Player belonging to one sport. Each model defines a to_api method, returning a hash for serialization. This generic approach accommodates various formats. For JSON-only APIs, Ruby’s to_json with the only argument can restrict serialization to specific attributes, like player.to_json(only: [:id, :name, :sport_i]). Alternatively, a BaseModel inheriting from Sequel::Model could define a default to_api method for inheritance.

With models in place, we can define API endpoints.

API Endpoints

Endpoint definitions will reside in files within the routes directory. Utilizing manifest files for loading, we’ll group routes by resources (e.g., sports.rb for sports-related routes, players.rb for player routes).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 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 /sports/:id/players for fetching players within a sport, can be defined alongside other routes or in separate resource files dedicated to nested routes.

With routes defined, our application is ready to handle requests:

1
curl -i -XGET 'http://localhost:9292/sports?client_id=<client_id>&client_secret=<client_secret>'

As per our authentication in helpers/authentication.rb, credentials are passed directly in request parameters.

Conclusion

The principles showcased in this example extend to any API back-end. While not strictly MVC, it maintains a clear separation of concerns. Business logic resides within model files, while request handling occurs in Sinatra’s route methods. Unlike MVC, where views render responses, this application handles it directly within route methods. Adding helper files can easily introduce features like pagination or rate-limiting information in response headers.

In conclusion, we’ve built a complete API using a minimal toolset without sacrificing functionality. Limited dependencies ensure faster loading, startup, and a smaller memory footprint compared to a Rails-based equivalent. For your next Ruby API, consider Sinatra and Sequel; they’re powerful tools for the job.

Licensed under CC BY-NC-SA 4.0