Urbanski M Ruby On Roda Rest Apis With Roda Sequel
Urbanski M Ruby On Roda Rest Apis With Roda Sequel
Urbanski M Ruby On Roda Rest Apis With Roda Sequel
Mateusz Urbański
Acknowledgements . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1
Source code . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 1
1. Getting started. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2
2. Starting project . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 7
4. Securing API . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 52
5. JSON Serialization . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 71
7. Authentication . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 81
9. Security. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 149
If you have a problem with your code, please put the code on GitHub and link me to it in the email
to clone it and attempt to reproduce the problem myself.
If you don’t understand something, then it’s more likely that I made a mistake and rushed it when I
wrote it. Let me know!
Acknowledgements
I want to thank my amazing wife Marta for supporting this new adventure and encouraging me to
pursue my passion for writing, teaching, and programming!
I also want to thank all my friends who read the book and gave me feedback about it. Most of them
told me they learned new things, which made me extremely happy and motivated me to keep going.
Source code
You can find the source code for this book on my Github Profile.
1
Chapter 1. Getting started
1.1. Introduction to Roda
Roda is a web framework built on top of Rack, created by Jeremy Evans, that started as a fork of
Cuba and was inspired by Sinatra. The following is the most straightforward app you can make in
Roda, which returns "Hello world!" for every request:
config.ru
require "roda"
Roda.route { "Hello world!" }
run Roda.app
Roda has a unique approach to routing compared to Rails, Sinatra, and other Ruby web
frameworks. In Roda, you route incoming requests dynamically as they come.
First, we create an App class that inherits from the Roda class. The route block is called whenever a
new request comes in. It is yielded to an instance of a subclass of Rack::Request with some
additional methods for matching routes. By convention, this argument is named r (for "request").
If the request’s path starts with /posts, the request will be matched by the r.on call, calling the
given block. Next, it will be matched by the r.is call if the path continues and ends with /recent
(r.is is a terminal matcher). Finally, r.get will match only GET requests. Altogether, this route block
handles GET /posts/recent requests by assigning a list of recent posts.
The reason why this is called a "routing tree" is that routing is branched. If the request doesn’t start
with /posts, the whole r.on "posts" block ("branch") is immediately discarded, and routing
continues to the following branches.
The route block is called whenever a request is coming, so this routing is happening in real-time. It
means that you can handle the request while you’re routing it.
2
class App < Roda
plugin :all_verbs
route do |r|
r.on "posts" do
r.is ":id" do |id|
@post = current_user.posts.find(id)
r.get do
@post
end
r.put do
@post.update(r.params["post"])
end
r.delete do
@post.destroy
end
end
end
end
end
Since all of these 3 /posts/:id routes have first to find the post, we can assign the post as soon as we
know that the path will be posts/:id and then we reference it anywhere down that branch. In other
web frameworks, you would solve this with before filters to avoid duplication, but that splits code
making it harder to follow. With Roda, you can write DRY code in a very readable way.
This is a new concept, and it opens a whole new world of routing possibilities. From other web
frameworks, we are used to routing only by the request path and method.
One downside of using Roda’s routing tree is that, since routes are not stored in any data structure
(because requests are routed dynamically as they come in), you cannot introspect the routes of the
routing tree.
However, you can leave comments above your routes using a special syntax, and use the roda-
route_list plugin to parse those comments and print the routes.
By design, Roda has a minimal core, providing only the essentials. All additional features are loaded
via plugins that ship with Roda. This is why Roda is a "web framework toolkit" using a combination
of Roda plugins you can build your flavor of the web framework that suits your needs and choose
precisely the amount of complexity you need.
3
1.2. Introduction to Sequel
I’ve used ActiveRecord for most of my Ruby life. While I was in Rails, I couldn’t imagine why I
would want to use anything else. When I moved away from Rails, I was still using ActiveRecord at
first, but some things started to bother me:
• limited query interface, you very quickly have to switch to SQL strings
I wanted to try another ORM. I’ve thought about ROM, but he did not convince me. I wanted a gem
that also implements the ActiveRecord pattern but better implementation than the ActiveRecord
gem.
I’ve heard about Sequel before, and I decided to give him a chance. The Sequel has all the features I
wanted from ActiveRecord and so much more. Jeremy Evans, the author of Sequel, keeps Sequel at
0 issues and maintains a mailing list to get help with anything.
While ActiveRecord is one monolithic gem, Sequel utilizes a plugin system. The Sequel consists of a
relatively thin core, which gives you the most common behavior, and you can then choose to add
additional functionality via plugins.
DB = Sequel.connect("postgres:///my_database")
Sequel::Model.plugin :validation_helpers
Sequel::Model.plugin :auto_validations
Sequel::Model.plugin :prepared_statements
Sequel::Model.plugin :single_table_inheritance
Creating the migration files is very similar to ActiveRecord in the layout. It is important to note that
you should still include a number to the beginning of your file name, as Sequel requires it.
4
db/migrate/01_dogs.rb
Sequel.migration do
change do
create_table(:dogs) do
primary_key :id
String :name, null: false
String :breed, null: false
foreign_key :owner_id, :people
end
end
end
db/migrate/02_owners.rb
Sequel.migration do
change do
create_table(:owners) do
primary_key :id
String :name, null: false
end
end
end
def validate
super
errors.add(:name, 'must be present') if name.empty?
errors.add(:breed, 'must be present') if breed.empty?
end
end
def validate
super
errors.add(:name, "must be present") if name.empty?
end
end
Queries within Sequel are somewhat similar to those in ActiveRecord. Using Dog.all will return all
dogs within the database. You can use .first and .last to get the first and last object in the
database. .order allows you to order the instance within the database. You can use .where() to find
5
all instances within the database that match the given object key.
On the other hand, Sequel allows you to write low-level queries using the same query interface you
use for models! Instead of going through models, You can go through the Sequel::Database object
directly, and the records will be returned as simple Ruby hashes.
Jeremy Evans put a lot of effort into supporting as many Postgres features as possible in Sequel:
• Support for reading and writing to JSON columns with nice, readable API.
I have only scratched the surface of Sequel’s features. ActiveRecord was long my ORM of choice
only because it’s part of Rails. After using Sequel for some time, I have found it to be much more
stable (0 issues maintained), better designed, more performant, and more advanced than
ActiveRecord.
6
Chapter 2. Starting project
2.1. Project Setup
Before we start make sure that you have Ruby 3.0.0 installed on your machine:
$ ruby -v
ruby 3.0.0p0 (2020-12-25 revision 95aff21468) [x86_64-darwin20]
So let’s start with creating a directory that will store our source code:
mkdir todo_api
cd todo_api
Gemfile
# frozen_string_literal: true
source 'https://rubygems.org'
ruby '3.0.0'
7
gem 'oj'
# bcrypt-ruby is a Ruby binding for the OpenBSD bcrypt() password hashing algorithm,
allowing you to easily store a secure hash of your users' passwords.
gem 'bcrypt'
# A toolkit of support libraries and Ruby core extensions extracted from the Rails
framework.
gem 'activesupport'
# Plugin that adds BCrypt authentication and password hashing to Sequel models.
gem 'sequel_secure_password'
# A gem providing "time travel" and "time freezing" capabilities, making it dead
simple to test time-dependent code. It provides a unified method to mock Time.now,
Date.today, and DateTime.now in a single call.
gem 'timecop'
# A Ruby static code analyzer and formatter, based on the community Ruby style
guide.
gem 'rubocop'
8
gem 'rubocop-sequel'
Now let’s add the .ruby-version file, which specifies a version number of Ruby we want to use in
our project:
.ruby-version
ruby-3.0.0
We will also add a .gitignore file that will list files we don’t want to track with our version control:
.gitignore
.env.development
.env.test
/doc/*
.yardoc/*
We are going to use Rubocop to make sure our code fits nicely into the Ruby style guide, so let’s add
.rubocop.yml file which store Rubocop configuration:
.rubocop.yml
require:
- rubocop-rspec
- rubocop-performance
- rubocop-thread_safety
- rubocop-sequel
- rubocop-rake
AllCops:
NewCops: enable
EnabledByDefault: true
TargetRubyVersion: 3.0.0
Exclude:
- vendor/bundle/**/*
9
Lint/ConstantResolution:
Enabled: false
Style/Copyright:
Enabled: false
Style/MissingElse:
Enabled: false
Style/MethodCallWithArgsParentheses:
Exclude:
- 'spec/**/*'
Style/StringHashKeys:
Exclude:
- app.rb
- spec/**/*
RSpec/NestedGroups:
Max: 5
Layout/LineLength:
Max: 125
IgnoredPatterns: ['(\A|\s)#']
Layout/SpaceBeforeBrackets:
Exclude:
- 'db/migrate/*'
Metrics/BlockLength:
Max: 40
Exclude:
- 'spec/**/*'
- 'app.rb'
Lint/ToJSON:
Exclude:
- app/serializers/**/*
RSpec/ExpectInHook:
Enabled: false
RSpec/MessageExpectation:
Enabled: false
RSpec/StubbedMock:
Enabled: false
RSpec/MessageSpies:
Enabled: false
10
RSpec/MultipleExpectations:
Enabled: false
RSpec/MultipleMemoizedHelpers:
Max: 10
Naming/VariableNumber:
Enabled: false
Metrics/ClassLength:
Exclude:
- 'app.rb'
Layout/SingleLineBlockChain:
Enabled: false
Layout/RedundantLineBreak:
Enabled: false
Bundler/GemVersion:
Enabled: false
This is just the setup that I use with apps built with Roda but feel free to modify it or add your
favorite Rubocop configuration. Rubocop community is constantly adding new rules. There may be
a scenario that my code that breaks newly added Rubocop rules. In that case, do not hesitate to
update the code or disable Rubocop rules.
Now we will add a configuration file for YARD, which will be used to document our code. Let’s
create a .yardopts file at the root of our project directory:
.yardopts
--private
In the next section we will start organizing our application with dry-system.
11
2.2. Organize application structure with dry-system
If one day you decide to give up on Rails (as we did) and use Sinatra or Roda instead, even the first
server launch might become painful. Rails were carefully autoloading all the gems, running
initializers, managing dependencies. Now we have to manually add require keywords to dozens of
files to launch your app. You have to do the same procedure for all the files with dependencies. It
makes your app way faster but doesn’t bring you any pleasure.
Dry-system is designed for solving such problems. It allows you to divide your app into
independent encapsulated components, facilitates the initializing process, provides an Inversion of
Control (IoC)- a container to register and instantiate dependencies.
The main API of dry-system is the abstract container that you inherit from. It allows you to
configure basic settings and exposes APIs for requiring files quickly. The container is the entry
point to your application, and it encapsulates the application state.
So let’s create our Application container. We need to create a application.rb file in our system
folder:
/system/application.rb
# frozen_string_literal: true
require 'bundler/setup'
require 'dry/system/container'
# we set 'lib' relative to `root` as a path which contains class definitions that
can be auto-registered.
config.auto_register = %w[lib app]
First, we require 'bundler/setup', that ensures we’re loading Gemfile defined gems.
config.auto_register = %w[lib app] Configure the folder in the root folder where the dependencies
will be automatically registered. It means that the require method will be called for all the files in
12
this folder.
load_paths!('lib', 'app') add the folders passed in arguments to $LOAD_PATH, making it easier to
use require.
Before the launch, any app usually needs to initialize external libraries. For this purpose, create one
more system/boot folder that will store our bootable dependencies. It’s the same as a
config/initializers folder in Ruby on Rails. After .finalize! is called on our Application class, the
require for all the files in this folder will be executed.
Our first dry-system component will be responsible for loading environment variables. Let’s create
environment_variables.rb file in system/boot folder:
/system/boot/environment_variables.rb
# frozen_string_literal: true
Application.boot(:environment_variables) do
start do
# Get Application current environment.
env = Application.env
Dotenv.load('.env', ".env.#{env}")
end
end
end
First, get the current environment of our application using Application.env. Then we check our
current environment. If it’s test or production, we require dotenv and load environment variables
from files using Dotenv.load.
13
Next, we need a component that will handle database connection for us. Let’s create database.rb file
in the system/boot folder:
/system/boot/database.rb
# frozen_string_literal: true
Application.boot(:database) do |container|
# Load environment variables before setting up database connection.
use :environment_variables
init do
require 'sequel/core'
end
start do
# Delete DATABASE_URL from the environment, so it isn't accidently passed to
subprocesses.
database = Sequel.connect(ENV.delete('DATABASE_URL'))
Before setting up a database connection, we need to have access to environment variables which
stores our DATABASE_URL. Because of that, we write use :environment_variables at the beginning of
our component to auto-boot the required dependency and make it available in the booting context.
Then we set up our database connection using Sequel.connect, and we register our database
component so we will be able to use it later using Application['database'] notation.
14
Our next component will be responsible for configuring our Application logger. Let’s create
logger.rb in /system/boot folder:
/system/boot/logger.rb
# frozen_string_literal: true
Application.boot(:logger) do
init do
require 'logger'
end
start do
# Define Logger instance.
logger = Logger.new($stdout)
# Because the Logger's level is set to WARN , only the warning, error, and fatal
messages are recorded.
logger.level = Logger::WARN if Application.env == 'test'
First, we create a new Logger instance that outputs to the standard output stream. We set the Logger
level to WARN in the test environment to not see the logs during launch tests with the rspec
command. The last step is to register our component.
/system/boot/oj.rb
# frozen_string_literal: true
Application.boot(:oj) do
init do
require 'oj'
end
start do
# :compat attempts to extract variable values from an Object using
# to_json() or to_hash() then it walks the Object's variables if neither is found.
Oj.default_options = { mode: :compat }
end
end
15
Component for the oj gem configuration is really simple, we require oj gem and set the mode to
:compat. More about oj modes can be found here.
BCrypt, a hashing algorithm used to store passwords securely. It is implemented in Ruby via the
bcrypt gem. Component that configures bcrypt looks like this:
/system/boot/bcrypt.rb
# frozen_string_literal: true
Application.boot(:bcrypt) do
init do
require 'bcrypt'
end
start do
# Set BCrypt::Engine.cost to 1 in test environment to speedup tests.
BCrypt::Engine.cost = 1 if Application.env == 'test'
end
end
Here we require bcrypt gem and set BCrypt::Engine.cost to 1 to speedup our tests.
We need to have the option to translate our application to multiple languages in the future, in that
case, we need to have the configuration for i18n Ruby Gem:
/system/boot/i18n.rb
# frozen_string_literal: true
# This file contains setup for Ruby internationalization and localization (i18n).
Application.boot(:i18n) do
init do
require 'i18n'
end
start do
# Load all locale .yml files in /config/locales folder.
I18n.load_path << Dir["#{File.expand_path('config/locales')}/*.yml"]
Here we require the i18n gem, we load all locale .yml files in /config/locales folder, then we create
a list of available locales using I18n.config.available_locales method.
16
Let’s also add our translations to the /config/en.yml file that I prepared earlier:
/config/locales/en.yml
en:
invalid_params: Your query contains incorrectly formed parameters.
something_went_wrong: Something went wrong.
invalid_email_or_password: Invalid email or password.
invalid_authorization_token: Invalid authorization token.
not_found: Record not found.
To secure our JSON API, we will use token-based authentication, and the
ActiveSupport::MessageVerifier class will generate the tokens. Let’s create a component that will
configure the ActiveSupport module:
/system/boot/active_support.rb
# frozen_string_literal: true
Application.boot(:active_support) do
init do
require 'active_support/message_verifier'
require 'active_support/json'
end
start do
# Sets the precision of encoded time values to 0.
ActiveSupport::JSON::Encoding.time_precision = 0
end
end
And last but not least, we need to create the component that will configure our Sequel models:
17
/system/boot/models.rb
# frozen_string_literal: true
Application.boot(:models) do
init do
require 'sequel/model'
end
start do
# Whether association metadata should be cached in the association reflection.
# If not cached, it will be computed on demand.
# In general you only want to set this to false when using code reloading.
# When using code reloading, setting this will make sure that if an associated
class is removed or modified,
# this class will not have a reference to the previous class.
Sequel::Model.cache_associations = false if Application.env == 'development'
# The prepared_statements plugin modifies the model to use prepared statements for
instance level inserts and updates.
Sequel::Model.plugin(:prepared_statements)
# Allows you to use named timezones instead of just :local and :utc (requires
TZInfo).
Sequel.extension(:named_timezones)
# Freeze all descendent classes. This also finalizes the associations for those
classes before freezing.
Sequel::Model.freeze_descendents unless Application.env == 'development'
end
end
18
This component is pretty big. We require sequel/model, and we enable multiple Sequel plugins that
we will use in our models. Plugins are modules that include submodules for model class methods,
model instance methods, and model dataset methods. More about Sequel plugins can be found
here.
That’s it. We’ve described the container with its components and how it should work. Now you can
run it by calling a .finalize! method. After that, the application classes will be automatically
registered, and the external dependencies from the boot folder will be initialized. Here is how we
can launch our container:
require_relative './system/application'
Application.finalize!
Always running the container this way is not the greatest solution. So let’s save this logic as a
separate system/boot.rb file:
/system/boot.rb
# frozen_string_literal: true
require_relative 'application'
require 'pry'
require 'securerandom'
require 'dry-validation'
# Register automatically application classess and the external dependencies from the
/system/boot folder.
Application.finalize!
require_relative './system/boot'
Launching this inside IRB will raise Sequel::Error (Sequel::Database.connect takes either a Hash
or a String, given: nil). That’s because we do not configure our database connection. We will fix
that in a second.
In the end, let’s create an empty app and lib folder that will store our application code.
19
mkdir app lib
That’s it, we configured and organized our application and its components with dry-system.
20
2.3. Database configuration
This database that we’re connecting to doesn’t exist yet, but we can quickly create it by using the
createdb command-line tool. We need two databases, for the development and test environment:
createdb todo-api-development
createdb todo-api-test
Now let’s create .env files for the development and test environment that dotenv will use to load
environment variables:
.env.development
DATABASE_URL=postgres:///todo-api-development
.env.test
DATABASE_URL=postgres:///todo-api-test
Also, let’s add .env.development.template and .env.test.template files. This will help people who
will launch our project. We will also use those environment templates during CI configurations that
we will do later.
.env.development.template
DATABASE_URL=postgresql://localhost/todo-api-development?user=postgres&
password=postgres
.env.test.template
DATABASE_URL=postgresql://localhost/todo-api-test?user=postgres&password=postgres
A database without tables doesn’t do very much. It’s good practice to create tables within a
database by using migrations; Ruby files allow us to gradually build up our database tables. Sequel
has the concept of migrations too, but we need to do a bit of setup first before we can create our
first migration. That setup will involve creating a Rakefile within our project will then provide us
with some tasks to create and run these migrations. Let’s create that Rakefile now:
Rakefile
# frozen_string_literal: true
require_relative './system/application'
21
Application.start(:database)
migrate =
lambda do |version|
# Enable Sequel migration extension.
Sequel.extension(:migration)
namespace :db do
desc 'Migrate the database.'
task :migrate do
migrate.call(nil)
end
migrate.call(current_version - 1)
end
22
sh %(yard doc *.rb app/ lib/)
end
Now let’s create empty db/seeds.rb, which will be used to prepopulate our database with test data,
and db/migrate folder that will store our database migrations:
/db/seeds.rb
# frozen_string_literal: true
mdkir db/migrate
We are ready to create our first migration that will enable uuid-ossp and citext PostgreSQL
extensions:
23
/db/migrate/001_enable_postgresql_extensions.rb
# frozen_string_literal: true
Sequel.migration do
up do
execute <<-SQL
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "citext";
SQL
end
down do
execute <<-SQL
DROP EXTENSION IF EXISTS "uuid-ossp";
DROP EXTENSION IF EXISTS "citext";
SQL
end
end
After every migration, the database schema will be dumped to structure.sql. file.
24
2.4. Interactive Console
In Ruby on Rails, console is a command-line program for interacting with the Rails applications. It
has the full power of the Ruby language and Rails environment. To have the same functionality in
our application, we need to create it by ourselves.
/bin/console
# !/usr/bin/env ruby
# The ruby bin/console let's you interact with your application from the command line.
require_relative '../system/boot'
Pry.start
In this file, we start it with a shebang that tells the program to execute Ruby.
Then we require our boot script: require_relative '../system/boot' and start Pry with Pry.start
command.
We can now run this console and use it to interact with our application. To start it, run:
$ ruby bin/console
[1] pry(main)> Application['database']
=> #<Sequel::Postgres::Database: "postgres:///todo-api-development">
25
2.5. Continuous integration
GitHub Actions is an automation platform that you run directly from inside a GitHub repository.
Using GitHub Actions, you build workflows triggered by any event. These workflows run arbitrary
code as Jobs, and you can piece together multiple Steps to achieve pretty much whatever you want.
The most obvious use case for this new platform is to build a testing CI/CD pipeline.
/.github/workflows/ci.yml
jobs:
verify:
name: Build
runs-on: ubuntu-latest
services:
postgres:
image: postgres:latest
env:
POSTGRES_USER: postgres
POSTGRES_DB: todo-api-test
POSTGRES_PASSWORD: postgres
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout code
uses: actions/checkout@v2
26
bundle install --jobs 4 --retry 3
runs-on: ubuntu-latest
services:
postgres:
image: postgres:latest
env:
POSTGRES_USER: postgres
POSTGRES_DB: todo-api-test
POSTGRES_PASSWORD: postgres
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
27
- name: Checkout code
uses: actions/checkout@v2
• Install dependencies with bundler and cache the results to speed up builds when they don’t
change:
28
- name: Setup test database
env:
RACK_ENV: test
run: rake db:migrate
That’s it. Github Actions will launch our workflow every time we push a new commit to our
repository.
29
2.6. README section
As a culmination of our work, let’s add a README section to our application.
A README is a text file that introduces and explains a project. It contains information that is
commonly required to understand what the project is.
README.md
# todo-api
```shell
git clone git@github.com:maturbanski/todo-api.git
cd todo-api
```
```shell
ruby -v
```
Using [Bundler](https://github.com/bundler/bundler):
```shell
bundle install
```
This project uses PostgreSQL by default, to setup database for development and test
environment use following instructions:
30
1. To migrate database in development environment use: `rake db:migrate`
2. To migrate database in test environment use: `RACK_ENV=test rake db:migrate`
3. To migrate database in production environment use: `RACK_ENV=production rake
db:migrate`
This is how we completed the configuration of our project. In the next chapter we will create basic
Roda application.
31
Chapter 3. Getting started with Roda
3.1. Basic Roda Application
We finished configuring our project so we can finally create a basic Roda application. We need to
create app.rb file in our project folder:
app.rb
# frozen_string_literal: true
require 'roda'
require_relative './system/boot'
# The symbol_matchers plugin allows you do define custom regexps to use for specific
symbols.
plugin :symbol_matchers
First, we require 'roda' to access the Roda class that our App class will inherit from, then we
32
require_relative './system/boot' to have access to all of our dependencies and classes.
Next, we create an App class that inherits from the Roda class. By inheriting from the Roda class, our
App class is implicitly a Rack application.
First plugin in our Roda class is plugin :environments. With core Roda, the typical way to check or set
which environment the application is operating in is to operate on at ENV["RACK_ENV"]. However,
some people may want a more straightforward way to deal with environments, and for that, Roda
offers an environments plugin. The environments plugin offers an environment method to return the
environment as a symbol and a setter method to modify the environment. It also offers
development?, test? and production? methods for checking for the most common environments.
Finally, it provides a configure method, which can be called with any environment symbols, which
will yield to the block if the application is running in one of those environments.
plugin :heartbeat handles heartbeat requests. If a request for the heartbeat path comes in, a 200
response with a text/plain Content-Type and a body of OK will be returned.
Another essential plugin is plugin :enhanced_logger, which is powerful logger dedicated for Roda
that will make logs much more readable. Here we are using the environments configure method to
enable that plugin in the development and production environment.
plugin :symbol_matchers allows different symbols to match different segments. Let’s say we have
many routes that accept an id, and our application only allows ids that are compatible with the UUID
format. We can use a custom symbol matcher that we will be sure will only match if the id format is
valid.
plugin :error_handler adds an error handler to the routing so when routing the request raises an
error, a nice error message page can be returned to the user.
plugin :default_headers change the response headers that are added by default. By default, the only
header added is the Content-Type header, which is set to text/html. However, there are some
security features we can enable in browsers by specifying headers. So if we are writing an
application that browsers will use (as opposed to an API), we may want to specify these headers.
To keep Roda small, only the methods that browsers support are included by default. However, Roda
ships with an plugin :all_verbs plugin that adds other request methods such as r.head, r.put,
r.patch, and r.delete for handling the other HTTP methods.
Every Ruby web application framework is built on top of a universal compatibility layer called
Rack. Roda is Rack-compatible, so we start by creating a rackup file, using the standard file name
config.ru.
33
config.ru
# frozen_string_literal: true
# This file contains configuration to let the webserver which application to run.
require_relative 'app'
run App.freeze.app
Then from the command line we run the rackup command to start up the web server and start
serving requests.
34
3.2. Setup Test Framework
Now when we have our first endpoint in the application, we can start thinking about writing
automated tests. Our test framework of choice is RSpec. RSpec is a testing tool for Ruby, created for
behavior-driven development (BDD). It is the most frequently used testing library for Ruby in
production applications. Even though it has a very rich and powerful DSL (domain-specific
language), at its core, it is a simple tool that you can start using rather quickly.
Let’s start our RSpec configuration by creating spec_helper.rb file in /spec folder:
/spec/spec_helper.rb
# frozen_string_literal: true
require_relative '../app'
ENV['RACK_ENV'] = 'test'
We need to have access to our App class so we need to need to require our app.rb file:
require_relative '../app'.
/spec/support folder will contain helper methods and modules that we will use during our tests. We
need to load all files from this folder before launching tests:
Ok, now let’s configure rack_test, a tool for testing Rack apps:
35
/spec/support/rack_test.rb
# frozen_string_literal: true
require 'rack/test'
RSpec.configure do |config|
config.include Rack::Test::Methods, type: :request
def app
App.freeze.app
end
end
We require the rack/test gem and include the Rack::Test::Methods module for request type tests,
then we define the app method, which is required to launch our app during tests.
Next we need to configure FactoryBot, a library for setting up Ruby objects as test data:
/spec/support/factory_bot.rb
# frozen_string_literal: true
require 'factory_bot'
# By default, creating a record will call save! on the instance; since this may not
always be ideal,
# you can override that behavior by defining to_create on the factory:
FactoryBot.define do
to_create(&:save)
end
RSpec.configure do |config|
config.include FactoryBot::Syntax::Methods
end
FactoryBot by default does not work with Sequel. First, we need to update FactoryBot to call save
instead of save! during creation, because Sequel models do not implement the save! method. Next,
36
we disable the use_parent_strategy option to created records with nested associations.
/spec/support/database_cleaning.rb
# frozen_string_literal: true
RSpec.configure do |config|
config.around do |example|
Application['database'].transaction(rollback: :always, auto_savepoint: true) {
example.run }
end
end
It’s generally best to run each test in its transaction if possible. That keeps all tests isolated from
each other, and it’s simple as it handles all of the cleanups for you.
Our final step is to create ApiHelpers module that will contain helper methods used during request
testing:
/spec/support/api_helpers.rb
# frozen_string_literal: true
# {ApiHelpers} module contains helper methods that are used in the API request specs.
module ApiHelpers
# It returns the response that our request has returned.
def response
last_response
end
# It parse the response JSON document into a Ruby data structure and return it.
def json_response
JSON.parse(response.body)
end
end
RSpec.configure do |config|
config.include ApiHelpers
end
Here we have two methods, response that returns the response body of our request and
json_response that returns parsed JSON response.
That’s it. We finished our RSpec configuration. We are ready to write our first test. Let’s test that our
/hearbeart endpoint works as expected:
37
/spec/requests/heartbeat_spec.rb
# frozen_string_literal: true
require 'spec_helper'
$ rspec
..
38
3.3. User model
Our API should support user accounts, with each user having the ability to manage their todos.
Let’s start by creating a migration that will create users table in our database:
/db/migrate/002_create_users_table.rb
# frozen_string_literal: true
Sequel.migration do
change do
create_table(:users) do
column :id, :uuid, null: false, default: Sequel
.function(:uuid_generate_v4), primary_key: true
column :email, 'citext', null: false, unique: true
column :password_digest, String, null: false
column :authentication_token, String, null: false, unique: true
column :created_at, DateTime, null: false, default: Sequel
::CURRENT_TIMESTAMP
column :updated_at, DateTime, null: false, default: Sequel
::CURRENT_TIMESTAMP
end
end
end
We want to use UUID primary keys, and because of that, we use the Sequel function for generating
those: Sequel.function(:uuid_generate_v4).
For the email column, we use citext column type. The citext module provides a case-insensitive
character string type, citext. Essentially, it internally calls lower when comparing values.
Otherwise, it behaves almost exactly like text. We also want email to be unique, unique: true will
create a unique constraint in the database.
The password_digest column will be responsible for storing user hashed password.
The authentication_token is unique token among all users that is used during authorization token
verification. This will be necessary to invalidate existing authorization token when user logout or
wants to refresh their token.
created_at and updated_at are timestamps columns that value will be filled automatically by the
PostgreSQL CURRENT_TIMESTAMP(), which returns the current date and time with time zone, which is
the time when the transaction starts.
39
Now we are ready to create our User model:
/app/models/user.rb
# frozen_string_literal: true
We are also adding additional format validation for user email. Our email EMAIL_REGEX constant is
stored in the Constants module that is responsible for storing constants that are used across the
application:
40
/lib/constants.rb
# frozen_string_literal: true
# {Constants} module is responsible for storing constants that are used across the
application.
module Constants
# Regex that is used during email validation process.
EMAIL_REGEX = /^[^,;@ \r\n]+@[^,@; \r\n]+\.[^,@; \r\n]+$/
public_constant :EMAIL_REGEX
public_constant :SORT_DIRECTIONS
public_constant :TODO_SORT_COLUMNS
public_constant :UUID_REGEX
end
Except EMAIL_REGEX, our Constants module also implements other constants that we will use later.
41
/spec/lib/constants_spec.rb
# frozen_string_literal: true
require 'spec_helper'
describe Constants do
it 'defines EMAIL_REGEX constant' do
expect(described_class::EMAIL_REGEX).to eq(/^[^,;@ \r\n]+@[^,@; \r\n]+\.[^,@;
\r\n]+$/)
end
Before testing our User model we need to define first our factory:
/spec/factories/users.rb
# frozen_string_literal: true
FactoryBot.define do
factory :user do
sequence(:email) { |n| "test-#{n}@user.com" }
password { 'password' }
password_confirmation { 'password' }
authentication_token { SecureRandom.hex(40) }
end
end
/spec/models/user_spec.rb
# frozen_string_literal: true
require 'spec_helper'
42
describe 'email presence validation' do
let(:user) { build(:user, email: email) }
before { user.valid? }
before { user.valid? }
before do
create(:user, email: 'test@user.com')
user.valid?
43
end
before { user.valid? }
44
end
before { user.valid? }
before do
create(:user, authentication_token: 'test')
user.valid?
end
describe '#password=' do
45
let(:user) { build(:user, password: 'test') }
describe '#authenticate' do
let(:user) { create(:user) }
A lot is going on here, in our User model tests we are testing following things:
• password validation.
46
3.4. Todo model
Let’s create our Todo model that will store user todo informations. Like always, we will start by
creating a migration:
/db/migrate/003_create_todos_table.rb
# frozen_string_literal: true
Sequel.migration do
change do
create_table(:todos) do
column :id, :uuid, null: false, default: Sequel
.function(:uuid_generate_v4), primary_key: true
column :name, String, null: false
column :description, String, null: false
column :created_at, DateTime, null: false, default: Sequel::CURRENT_TIMESTAMP
column :updated_at, DateTime, null: false, default: Sequel::CURRENT_TIMESTAMP
foreign_key is used to create a foreign key column that references a column in another table. It
takes the column name as the first argument, the table it references as the second argument, and
an options hash as its third argument.
:on_delete Specify the behavior of this foreign key column when the row with the primary key it
references is deleted. We are using cascade option here, which specifies that when a referenced row
is deleted, rows referencing it should be automatically deleted as well.
/app/models/todo.rb
# frozen_string_literal: true
47
#
# @!attribute description
# @return [String] Description of the {Todo}.
#
# @!attribute user_id
# @return [UUID] ID of the {User} which {Todo} belongs to in UUID format.
#
# @!attribute created_at
# @return [DateTime] Time when {Todo} was created.
#
# @!attribute updated_at
# @return [DateTime] Time when {Todo} was updated.
class Todo < Sequel::Model
many_to_one :user
dataset_module do
# It filters todos by their name.
#
# @param [String] name of the {Todo} or its part.
#
# @return [Array<Todo>] Array of {Todo} objects.
#
# @example Search Todo by name:
# Todo.search_by_name('milk')
def search_by_name(name)
where(Sequel.ilike(:name, "%#{name}%"))
end
many_to_one association is used when the table for the current class contains a foreign key that
references the primary key in the table for the associated class. It is named because there can be
many rows in the current table for each row in the associated table. We have many_to_one :user
because User will have many todo.
We are also using here dataset_module to create named dataset methods for Todo dataset. We want
to filter todos by name and description using ILIKE operator.
48
Before writing tests for our new Todo model, we need to setup factory:
/spec/factories/todos.rb
# frozen_string_literal: true
FactoryBot.define do
factory :todo do
name { 'Buy milk' }
description { 'Remember to buy milk.' }
user
end
end
/spec/factories/todos.rb
# frozen_string_literal: true
require 'spec_helper'
before { user.valid? }
before { user.valid? }
49
context 'when is blank' do
let(:description) { nil }
before { todo.valid? }
describe '.search_by_name' do
let!(:todo) { create(:todo, name: 'Buy milk.') }
describe '.search_by_description' do
let!(:todo) { create(:todo, description: 'Remember to buy milk.') }
50
before { create(:todo, description: 'Remember to buy cheese.') }
• filtering by name.
• filtering by description.
The last thing we need to do is to add to the connection to our new Todo model from the User model:
/app/models/user.rb
one_to_many :todos
The one_to_many association is used when the table for the associated class contains a foreign key
that references the primary key in the table for the current class. It is named because for each row
in the current table, there can be many rows in the associated table
51
Chapter 4. Securing API
4.1. Token-based Authentication
Token-based authentication is the way of handling the authentication of users in applications. It is
an alternative to session-based authentication. The most notable difference between session-based
and token-based authentication is that session-based authentication relies heavily on the server. A
record is created for each logged-in user.
Token-based authentication is stateless - it does not store anything on the server but creates a
unique encoded token that gets checked every time a request is made.
Unlike session-based authentication, a token approach would not associate a user with login
information but with a unique token that is used to carry client-host transactions.
The way token-based authentication works is simple. The user enters credentials and sends a
request to the server. If the credentials are correct, the server creates a unique encoded token. The
client stores the newly created token and makes all subsequent requests to the server with the
token attached in the Authorization header.
The server authenticates the user by decoding the token sent in the Authorization header with the
request and finding the user that the token belongs.
52
4.2. Tokens generation
For the token generation we will gonna use ActiveSupport::MessageVerifier. It is helpful for cases
like remember-me tokens and subscribes links or where the session store isn’t available. Let’s see
an example:
secret = '0eb411d07ac481e53b58a782e7d6c59fc51ad634'
verifier = ActiveSupport::MessageVerifier.new(secret)
message = {id: 42}
signed_message = verifier.generate(message) # => "BAh7BjoHaWRpLw==--
156da236d3c30c49a56373fa0b30552b581845f5"
Our data was converted to a long string and back again. If we try to verify invalid signature,
ActiveSupport::MessageVerifier::InvalidSignature error will be raised:
verifier.verify('invalid-signature') # =>
ActiveSupport::MessageVerifier::InvalidSignature
By default, any message can be used throughout app, but we can also associate our message with a
specific purpose:
By default, messages last forever, but messages can be set to expire at a given time expires_at:
/lib/message_verifier.rb
# frozen_string_literal: true
# {MessageVerifier} makes it easy to generate and verify messages which are signed to
prevent tampering.
# @see https://api.rubyonrails.org/v6.0.3.4/classes/ActiveSupport/MessageVerifier.html
ActiveSupport::MessageVerifier Documentation
class MessageVerifier
class << self
53
# It encode data using ActiveSupport::MessageVerifier.
#
# @param [Hash, Array, String] data that will be encoded.
# @param [Integer] expires_at in seconds telling when the message expires.
# @param [Symbol] purpose that describes how the message will be used.
#
# @return [String] signed message for the provided value.
#
# @example Encode a message:
# MessageVerifier.encode(data: 'secret', expires_at: Time.now + 360, purpose:
:test)
def encode(data:, expires_at:, purpose:)
verifier.generate(data, expires_at: expires_at, purpose: purpose)
end
private
For those unfamiliar, a singleton class restricts the instantiation of a class to a single object, which
comes in handy when only one object is needed to complete the tasks at hand.
First of all, we need to have access to the instance of the ActiveSupport::MessageVerifier class. This
54
will be done by the private verifier method. We initialize ActiveSupport::MessageVerifier with a
unique secret that will be used to sign a message and hashing algorithm type SHA512:
def verifier
ActiveSupport::MessageVerifier.new(ENV['SECRET_KEY_BASE'], digest: 'SHA512')
end
Before we start using our new class, we need to add the SECRET_KEY_BASE environment variable. To
generate those, we will use the SecureRandom.hex method that generate random hexadecimal strings:
SecureRandom.hex(40) =>
"8e4b385809c9d81cd28c4734ccf653b789f059224794691714c4bb0e3962d54a8932feac566e4ca2"
Let’s add unique SECRET_KEY_BASE for our .env and .env.template files:
.env.development
SECRET_KEY_BASE=75b92df8aa89df8ae8a24fca30a057a11931c316d3854e725425089ff84cbf334fb607
4d662ea721
.env.development.template
SECRET_KEY_BASE=8e4b385809c9d81cd28c4734ccf653b789f059224794691714c4bb0e3962d54a8932fe
ac566e4ca2
.env.test
SECRET_KEY_BASE=47e024bd7d5f47c2a10b0404a00c1c89c4f4744c146381a81035a245945ee6bfc0be05
95a84e58fe
55
.env.test.template
SECRET_KEY_BASE=0a8fd14ddcf36cf33348c2514efad5d898470cec05fe8fd08be68b8a8005ab2e7e25d2
e205502e59
spec/lib/message_verifier_spec.rb
# frozen_string_literal: true
require 'spec_helper'
describe MessageVerifier do
let(:data) { 'Test message' }
let(:purpose) { :test }
let(:expires_at) { Time.now + 60 }
describe '.encode' do
it 'encodes data' do
expect(described_class.encode(data: data, purpose: purpose, expires_at:
expires_at)).not_to eq data
end
end
describe '.decode' do
let(:message) { described_class.encode(data: data, expires_at: expires_at,
purpose: purpose) }
let(:message) do
described_class.encode(data: data, expires_at: expires_at, purpose: purpose)
end
56
it 'raise ActiveSupport::MessageVerifier::InvalidSignature' do
expect { described_class.decode(message: message, purpose: purpose) }.to
raise_error(
ActiveSupport::MessageVerifier::InvalidSignature
)
end
end
To simplify the generation of access and refresh tokens for the user, we will create dedicated
classes for generating those.
AccessTokenGenerator class will generate an access token for the specified user:
57
/lib/access_token_generator.rb
# frozen_string_literal: true
58
spec/lib/access_token_generator_spec.rb
# frozen_string_literal: true
require 'spec_helper'
require 'timecop'
describe AccessTokenGenerator do
describe '#call' do
let(:user) { create(:user) }
let(:access_token) { 'access_token' }
let(:data) do
{ user_id: user.id, authentication_token: user.authentication_token }
end
before do
Timecop.freeze
expect(MessageVerifier)
.to receive(:encode)
.with(data: data, expires_at: Time.now + 300, purpose: :access_token)
.and_return(access_token)
end
after do
Timecop.return
end
We are mocking here MessageVerifier class to check that it receives correct parameters. We already
have unit tests for MessageVerifier, so we don’t need to do that here.
RefreshTokenGenerator is similar to AccessTokenGenerator and will generate a refresh token for the
specified user:
59
/lib/refresh_token_generator.rb
# frozen_string_literal: true
Here we also mock the MessageVerifier class to verify if the encode method was called with valid
parameters:
60
spec/lib/refresh_token_generator_spec.rb
# frozen_string_literal: true
require 'spec_helper'
require 'timecop'
describe RefreshTokenGenerator do
describe '#call' do
let(:user) { create(:user) }
let(:refresh_token) { 'refresh_token' }
let(:data) do
{ user_id: user.id, authentication_token: user.authentication_token }
end
before do
Timecop.freeze
expect(MessageVerifier)
.to receive(:encode)
.with(data: data, expires_at: Time.now + 900, purpose: :refresh_token)
.and_return(refresh_token)
end
after do
Timecop.return
end
In our tests we will often need to create access and refresh token, because of that let’s add helper
methods to our ApiHelpers module:
61
spec/support/api_helpers.rb
The last thing we are gonna do here is to create a AuthorizationTokensGenerator class that will
generate at once access and refresh token for the user:
62
lib/authorization_tokens_generator.rb
# frozen_string_literal: true
private
63
spec/lib/authorization_tokens_generator_spec.rb
# frozen_string_literal: true
require 'spec_helper'
describe AuthorizationTokensGenerator do
describe '#call' do
let(:user) { create(:user) }
let(:access_token_generator) { instance_double(AccessTokenGenerator) }
let(:refresh_token_generator) { instance_double(RefreshTokenGenerator) }
let(:data) do
{ user_id: user.id, authentication_token: user.authentication_token }
end
let(:tokens) do
{
access_token: { expires_in: 300, token: 'access_token' },
refresh_token: { expires_in: 900, token: 'refresh_token' }
}
end
before do
expect(AccessTokenGenerator)
.to receive(:new)
.with(user: user)
.and_return(access_token_generator)
expect(access_token_generator)
.to receive(:call)
.and_return('access_token')
expect(RefreshTokenGenerator)
.to receive(:new)
.with(user: user)
.and_return(refresh_token_generator)
expect(refresh_token_generator)
.to receive(:call)
.and_return('refresh_token')
end
Here once again we use mocking to check if AccessTokenGenerator and RefreshTokenGenerator are
64
called with valid parameters. In the next section, we will work on the validation of incoming
tokens.
65
4.3. Tokens validation
Access and refresh token creation is done. Now let’s create a dedicated class that we will use for
validating authorization token that will come in the Authorization header.
lib/authorization_token_validator.rb
# frozen_string_literal: true
current_user
66
end
private
Next we are trying to find User in the database by id that was encoded in the authorization token:
67
# It returns {User} found by id in decoded data.
#
# @return [User] when id in the decoded data is valid.
# @return [NilClass] when id in the decoded data is not valid.
def current_user
@current_user ||= User.find(id: data[:user_id])
end
In the call method, we check if User is present in the database and if the authentication_token that
was encoded in the token is the same as user authentication_token:
current_user
end
68
Let’s write tests to check if the AuthorizationTokenValidator class works as expected:
spec/lib/authorization_token_validator_spec.rb
# frozen_string_literal: true
require 'spec_helper'
describe AuthorizationTokenValidator do
describe '#call' do
let(:user) { create(:user) }
it 'raises ActiveSupport::MessageVerifier::InvalidSignature' do
expect { described_class.new(authorization_token: token, purpose:
:access_token).call }
.to raise_error(an_instance_of(ActiveSupport::MessageVerifier
::InvalidSignature))
end
end
it 'raises ActiveSupport::MessageVerifier::InvalidSignature' do
expect { described_class.new(authorization_token: token, purpose:
:access_token).call }
.to raise_error(an_instance_of(ActiveSupport::MessageVerifier
::InvalidSignature))
end
end
it 'raises ActiveSupport::MessageVerifier::InvalidSignature' do
expect { described_class.new(authorization_token: token, purpose:
:invalid).call }
69
.to raise_error(an_instance_of(ActiveSupport::MessageVerifier
::InvalidSignature))
end
end
it 'raises ActiveSupport::MessageVerifier::InvalidSignature' do
expect { described_class.new(authorization_token: token, purpose:
:access_token).call }
.to raise_error(an_instance_of(ActiveSupport::MessageVerifier
::InvalidSignature))
end
end
end
end
With that in place, we’ve finished the process of securing our API, in the next chapter we will talk
about JSON serialization.
70
Chapter 5. JSON Serialization
Since JavaScript has become the primary language of the web and frontend frameworks are based
on JavaScript, JSON serialization has become an essential part of many web apps.
• seamlessly integrates with JavaScript, which makes JSON the standard for streaming data over
AJAX calls.
71
5.2. Our Own JSON Serializer
In the Ruby ecosystem, there are many libraries that we will help you with JSON serialization.
Instead of adding another gem to our application, we will create our serializers from scratch using
oj gem.
Let’s create our ApplicationSerializer that will be a base class for all of ours serializers:
/app/serializers/application_serializer.rb
# frozen_string_literal: true
In the initialize method, we use each_pair for every key-value pair of the object and we set the
instance variables.
render method passes the result of the to_json method that will be implemented in
ApplicationSerializer child classes to Oj.dump and return a string which is compliant with the JSON
standard.
To test how it works, let’s create the first serializer. It will be responsible for serializing users and
their access and refresh tokens information:
72
/app/serializers/user_serializer.rb
# frozen_string_literal: true
private
Serializer classes only need to implement the to_json method that prepares data before
transformation to the JSON format. That’s it. Our serializer is ready to use. Let’s use ruby
bin/console to test this out:
73
user = User.last
tokens = { access_token: 'access_token', refresh_token:'refresh_token' }
We will not write unit tests for serializers because those will be tested during request specs. In the
next chapter, we will work on incoming params validation.
74
Chapter 6. Incoming params validation
Checking the validity of data sent to an API is an important responsibility of any service. Besides
being an important security feature, it’s also crucial for responding intelligently to consumers who
provide invalid input.
rule(:email) do
unless /^[^,;@ \r\n]+@[^,@; \r\n]+\.[^,@; \r\n]+$/.match?(value)
key.failure('has invalid format')
end
end
rule(:age) do
key.failure('must be greater than 18') if value <= 18
end
end
Let’s start by creating the AplicationParams class that is a base class that will store configuration for
our params validation classes:
75
app/params/application_params.rb
# frozen_string_literal: true
raise(invalid_params_error(params)) if params.errors.any?
params.to_h
end
private
In the permit method we check if incoming params are valid, if they are fine then we return those
params, if not we raise Exceptions::InvalidParamsError. This error class is not defined so let’s do
this right now:
76
lib/exceptions.rb
# frozen_string_literal: true
super(message)
end
end
end
Ok, one of the first endpoints we will going to build will be the sign up endpoint. Let create a class
that will validate incoming params for that:
77
app/params/sign_up_params.rb
# frozen_string_literal: true
Here we are defining params coercion schema using dry-validation DSL that will check the
following things:
78
spec/params/sign_up_params_spec.rb
# frozen_string_literal: true
require 'spec_helper'
describe SignUpParams do
describe '#call' do
context 'when params are invalid' do
before do
expect(Exceptions::InvalidParamsError)
.to receive(:new)
.with(object, I18n.t('invalid_params'))
.and_return(Exceptions::InvalidParamsError.new(object, I18n
.t('invalid_params')))
end
let(:object) do
{
email: ['is missing'],
password: ['is missing'],
password_confirmation: ['is missing']
}
end
it 'raises InvalidParamsError' do
expect { described_class.new.permit!(params) }
.to raise_error(an_instance_of(Exceptions::InvalidParamsError))
end
end
let(:object) do
{
email: ['is in invalid format']
}
end
it 'raises InvalidParamsError' do
expect { described_class.new.permit!(params) }
79
.to raise_error(an_instance_of(Exceptions::InvalidParamsError))
end
end
end
It was a long run, but finally, we are ready to start working on our first endpoint!
80
Chapter 7. Authentication
In this chapter, we will work on authentication endpoints. We will create endpoints for registration,
logging in, logging out, and refreshing the tokens.
Our User has an authentication_token attribute that needs to be unique. We need to set up this
during User creation so let’s create a module that will generate a unique authentication token for
the User:
/lib/authentication_token_generator.rb
# frozen_string_literal: true
81
spec/lib/authentication_token_generator_spec.rb
# frozen_string_literal: true
require 'spec_helper'
describe AuthenticationTokenGenerator do
describe '.call' do
let(:token) { described_class.call }
let(:user) { create(:user) }
it 'returns token' do
expect(token).not_to be_blank
expect(token).not_to eq user.authentication_token
end
end
end
Now we are ready to create a service object that will set up user account during sign up:
82
app/services/users/creator.rb
# frozen_string_literal: true
module Users
# {Users::Creator} creates {User} account.
class Creator
# @param [Hash] attributes of the {User}
def initialize(attributes:)
@attributes = attributes
end
private
83
• The initialize method accepts user attributes as an argument and set that to instance variable.
• Private authentiacation_token method generates a unique authentication token for the user
using the AuthenticationTokenGenerator class.
spec/services/users/creator_spec.rb
# frozen_string_literal: true
require 'spec_helper'
describe Users::Creator do
describe '#call' do
let(:result) { described_class.new(attributes: attributes).call }
let(:authentication_token) { 'test_authentication_token' }
before do
expect(AuthenticationTokenGenerator)
.to receive(:call)
.and_return(authentication_token)
end
let(:created_user) do
User.find(
email: attributes[:email],
authentication_token: authentication_token
)
end
84
end
end
it 'raise Sequel::ValidationFailed' do
expect { result }.to raise_error(
Sequel::ValidationFailed
)
end
end
end
end
• When attributes are valid Users::Creator class creates and returns user.
• We have SignUpParams class that will validate incoming params for the /api/v1/sign_up endpoint.
• AuthorizationTokensGenerator will generate access and refresh token for the User object.
• UserSerializer will represent the newly created user in the JSON format.
We want to return a friendly JSON message to the user when one of that scenario happens, so we
need to update the :error_handler plugin rules in the app.rb file:
85
app.rb
response.write(error_object.to_json)
end
• when Exceptions::InvalidParamsError is raised we return 422 HTTP status and we put e.object
that contains error details into the JSON response.
• when Sequel::ValidationFailed is raised, we also return 422 HTTP status, and we put
e.model.errors that contain validation error details in the JSON response.
app.rb
route do |r|
r.on('api') do
r.on('v1') do
r.post('sign_up') do
sign_up_params = SignUpParams.new.permit!(r.params)
user = Users::Creator.new(attributes: sign_up_params).call
tokens = AuthorizationTokensGenerator.new(user: user).call
sign_up_params = SignUpParams.new.permit!(r.params)
86
user = Users::Creator.new(attributes: sign_up_params).call
• The last step is to represent our newly created user in the JSON format:
Ok, let’s write integration tests for our endpoint to check if it works as expected:
spec/requests/api/v1/sign_up_spec.rb
# frozen_string_literal: true
require 'spec_helper'
let(:created_user) do
User.find(email: params[:email])
end
87
let(:authorization_tokens_generator) do
instance_double(AuthorizationTokensGenerator)
end
let(:tokens) do
{
'access_token' => {
'token' => 'authorization_token',
'expires_in' => 1800
},
'refresh_token' => {
'token' => 'refresh_token',
'expires_in' => 3600
}
}
end
let(:sign_up_json_response) do
{
'user' => {
'id' => created_user.id,
'email' => created_user.email,
'created_at' => created_user.created_at.iso8601,
'updated_at' => created_user.updated_at.iso8601
},
before do
expect(AuthorizationTokensGenerator)
.to receive(:new)
.and_return(authorization_tokens_generator)
expect(authorization_tokens_generator)
.to receive(:call)
.and_return(tokens)
it 'returns user data with its access and refresh token informations in the JSON
response' do
expect(json_response).to eq sign_up_json_response
end
88
end
let(:params) do
{
email: user.email,
password: 'password',
password_confirmation: 'password'
}
end
• When the request contains incorrectly formatted params API should return 422 HTTP status
code and error in the JSON response.
• When requests contain valid params API should return 200 HTTP status code and user in the
89
JSON format.
• When password doesn’t match password_confirmation API should return 422 HTTP status code
and error in the JSON response.
• When email has already been taken API should return 422 HTTP status code and error in the
JSON response.
$ rspec
...........................................................
Tests are passing, that’s great. Let’s also test manually our api using curl, let’s launch our
application server:
rackup
{"user":{"id":"9bf090b7-cedb-4bdc-bb46-3f9ceb8ae488","email":"test@user.com"
,"created_at":"2021-04-29T11:20:46+00:00","updated_at":"2021-04-29T13:20:46+00:00"
},"tokens":{"access_token":{"token":"eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9NZFhObGNsO
XBaRWtpS1RsaVpqQTVNR0kzTFdObFpHSXROR0prWXkxaVlqUTJMVE5tT1dObFlqaGhaVFE0T0FZNkJrVlVPaGx
oZFhSb1pXNTBhV05oZEdsdmJsOTBiMnRsYmtraVZXSTFZall4TkRWbFpEVXdPRGcxTTJaaE5ESTVPRGxtWW1ZM
016VTFObU0xT0RnNE1qaGtNVFJqT1RrelpUa3hNelEzT0dNMVpXRXlNak5sTURVek1EUTNaVE5pTXpnell6TXh
NR1kzWlRneUJqc0dWQT09IiwiZXhwIjoiMjAyMS0wNC0yOVQxMToyNTo0NloiLCJwdXIiOiJhY2Nlc3NfdG9rZ
W4ifX0=--
8d325cb844cab4d9ce73a245874a124a3e4cfff1559e5015966615ae31a06b8c76b98928fa30acdaff4a48
2169dd36125728e2ef783f71e3698895a9e8626efa","expires_in":300},"refresh_token":{"token"
:"eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9NZFhObGNsOXBaRWtpS1RsaVpqQTVNR0kzTFdObFpHSXRO
R0prWXkxaVlqUTJMVE5tT1dObFlqaGhaVFE0T0FZNkJrVlVPaGxoZFhSb1pXNTBhV05oZEdsdmJsOTBiMnRsYm
traVZXSTFZall4TkRWbFpEVXdPRGcxTTJaaE5ESTVPRGxtWW1ZM016VTFObU0xT0RnNE1qaGtNVFJqT1RrelpU
a3hNelEzT0dNMVpXRXlNak5sTURVek1EUTNaVE5pTXpnell6TXhNR1kzWlRneUJqc0dWQT09IiwiZXhwIjoiMj
AyMS0wNC0yOVQxMTozNTo0NloiLCJwdXIiOiJyZWZyZXNoX3Rva2VuIn19--
44f54a64213c6233b950c1fd4fa21261f7e1ddcbaafb5fda37296ba29cf056da3fd66ed800183fd0bad04e
4df67f0afcf3feef2fe320fe51d3b728ebcb84a30c","expires_in":900}}}
90
Everything works as expected. This is how we finished the work on user registration. In the next
section, we will work on the api/v1/login endpoint that will be used by the existing users to
authenticate.
91
7.2. Users login
Users of our API already have the opportunity to register. Now we have to give them the way to
sign in. The parameters that will come to our login endpoint are straightforward. They will only
contain the user’s email and password. We will start by creating a class that will validate the
incoming parameters for the api/v1/login endpoint:
/app/params/login_params.rb
# frozen_string_literal: true
/spec/params/login_params_spec.rb
# frozen_string_literal: true
require 'spec_helper'
describe LoginParams do
describe '#call' do
context 'when params are invalid' do
before do
expect(Exceptions::InvalidParamsError)
.to receive(:new)
.with(object, I18n.t('invalid_params'))
.and_return(Exceptions::InvalidParamsError.new(object, I18n
.t('invalid_params')))
92
end
let(:object) do
{
email: ['is missing'],
password: ['is missing']
}
end
it 'raises InvalidParamsError' do
expect { described_class.new.permit!(params) }.to
raise_error(an_instance_of(Exceptions::InvalidParamsError))
end
end
let(:object) do
{
email: ['is in invalid format']
}
end
it 'raises InvalidParamsError' do
expect { described_class.new.permit!(params) }.to
raise_error(an_instance_of(Exceptions::InvalidParamsError))
end
end
end
93
When our incoming parameters are correct, we can think about the authentication of our user.
First, we need to find users by email and then check their passwords. The Users::Authenticator
class will be responsible for this:
/app/services/users/authenticator.rb
# frozen_string_literal: true
module Users
# {Users::Authenticator} checks {User} email and password during authentication
process.
class Authenticator
# @param [String] email
# @param [String] password
def initialize(email:, password:)
@email = email
@password = password
end
raise(Exceptions::InvalidEmailOrPassword)
end
end
end
initialize method accepts email and password attributes that we will use to authenticate the user.
In the call method, we first find the user by email. If a user is present, we will check the password
using the authenticate method, which is defined by sequel_secure_password gem. When the
password is correct, our class returns the user. If not Exceptions::InvalidEmailOrPassword error will
be raised. This error class is not defined yet, so let’s do this now:
94
/lib/exceptions.rb
app.rb
response.write(error_object.to_json)
end
/app/services/users/authenticator.rb
# frozen_string_literal: true
require 'spec_helper'
describe Users::Authenticator do
describe '#call' do
let(:result) { described_class.new(email: email, password: password).call }
95
it 'returns properly formatted hash' do
expect(result).to eq user
end
end
it 'raise Exceptions::InvalidEmailOrPassword' do
expect { result }.to raise_error(
Exceptions::InvalidEmailOrPassword
)
end
end
it 'raise Exceptions::InvalidEmailOrPassword' do
expect { result }.to raise_error(
Exceptions::InvalidEmailOrPassword
)
end
end
end
end
• User object should be returned when email and password are correct.
Now we are ready to add our api/v1/login endpoint to our api/v1 route block:
96
app.rb
r.post('login') do
login_params = LoginParams.new.permit!(r.params)
user = Users::Authenticator.new(email: login_params[:email], password:
login_params[:password]).call
tokens = AuthorizationTokensGenerator.new(user: user).call
login_params = LoginParams.new.permit!(r.params)
• AuthorizationTokensGenerator will generate access and refresh tokens for the user:
The last step will be to write integration tests for our api/v1/login endpoint:
/spec/requests/api/v1/login_spec.rb
# frozen_string_literal: true
require 'spec_helper'
97
it 'returns error message in JSON response' do
expect(json_response).to eq({ 'email' => ['is missing'], 'password' => ['is
missing'] })
end
end
let(:authorization_tokens_generator) do
instance_double(AuthorizationTokensGenerator)
end
let(:tokens) do
{
'access_token' => {
'token' => 'authorization_token',
'expires_in' => 1800
},
'refresh_token' => {
'token' => 'refresh_token',
'expires_in' => 3600
}
}
end
let(:login_json_response) do
{
'user' => {
'id' => user.id,
'email' => user.email,
'created_at' => user.created_at.iso8601,
'updated_at' => user.updated_at.iso8601
},
98
'tokens' => tokens
}
end
before do
expect(AuthorizationTokensGenerator)
.to receive(:new)
.with(user: user)
.and_return(authorization_tokens_generator)
expect(authorization_tokens_generator)
.to receive(:call)
.and_return(tokens)
it 'returns user data with its access and refresh token informations in the JSON
response' do
expect(json_response).to eq login_json_response
end
end
end
• When the request contains incorrectly formatted params, API should return 422 HTTP status
code and error in the JSON response.
• When email or password are invalid, API should return 422 HTTP status code and error in the
JSON response.
• When email and password are valid API should return 200 HTTP status with user data in JSON
response.
As a final step let’s launch server with rackup command and test newly created endpoint using curl:
99
curl --location --request POST 'http://localhost:9292/api/v1/login' \
--header 'Content-Type: application/json' \
--data-raw '{
"email": "test@user.com",
"password": "password"
}'
{"user":{"id":"9bf090b7-cedb-4bdc-bb46-3f9ceb8ae488","email":"test@user.com"
,"created_at":"2021-04-29T11:20:46+00:00","updated_at":"2021-04-29T13:20:46+00:00"
},"tokens":{"access_token":{"token":"eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9NZFhObGNsO
XBaRWtpS1RsaVpqQTVNR0kzTFdObFpHSXROR0prWXkxaVlqUTJMVE5tT1dObFlqaGhaVFE0T0FZNkJrVlVPaGx
oZFhSb1pXNTBhV05oZEdsdmJsOTBiMnRsYmtraVZXSTFZall4TkRWbFpEVXdPRGcxTTJaaE5ESTVPRGxtWW1ZM
016VTFObU0xT0RnNE1qaGtNVFJqT1RrelpUa3hNelEzT0dNMVpXRXlNak5sTURVek1EUTNaVE5pTXpnell6TXh
NR1kzWlRneUJqc0dWQT09IiwiZXhwIjoiMjAyMS0wNS0wNFQxMzo0MzozMVoiLCJwdXIiOiJhY2Nlc3NfdG9rZ
W4ifX0=--
e1ec6de0f1ff1042c159467270b96daf829be97c0dbbf4447941cf3c76c86646ac6ee526be861ef3fce813
cba3ef24df6ff273fffbedeb29d8d7b3c6be894a14","expires_in":300},"refresh_token":{"token"
:"eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9NZFhObGNsOXBaRWtpS1RsaVpqQTVNR0kzTFdObFpHSXRO
R0prWXkxaVlqUTJMVE5tT1dObFlqaGhaVFE0T0FZNkJrVlVPaGxoZFhSb1pXNTBhV05oZEdsdmJsOTBiMnRsYm
traVZXSTFZall4TkRWbFpEVXdPRGcxTTJaaE5ESTVPRGxtWW1ZM016VTFObU0xT0RnNE1qaGtNVFJqT1RrelpU
a3hNelEzT0dNMVpXRXlNak5sTURVek1EUTNaVE5pTXpnell6TXhNR1kzWlRneUJqc0dWQT09IiwiZXhwIjoiMj
AyMS0wNS0wNFQxMzo1MzozMVoiLCJwdXIiOiJyZWZyZXNoX3Rva2VuIn19--
85281b591a39640543f9bc8423084e32a9a1a0b9dc6382326b2ee98738d25b314f458f23f8145017e58a72
3472422b9d770843f9b599945aa20a18994e433a1d","expires_in":900}}}
Everything works as expected. With that in place, we finished our work with the api/v1/login
endpoint. In the next section, we will implement logout mechanism.
100
7.3. Users logout
At this moment in our application, there is no way to invalidate existing access and refresh tokens.
You may ask why we need that? Our tokens are valid for a short period of time, but there is the
possibility that the token can leak and the attacker can use it. The solution for this will be
invalidating existing access and refresh tokens. For that, we will use api/v1/logout endpoint.
The process of invalidating existing user tokens it’s really simple. The only thing we need to do is
update user authentication_token to a new unique value that will be generated by
AuthenticationTokenGenerator. After updating, all of the user existing tokens will no longer be valid
because of check in the AuthorizationTokenValidator class:
lib/authorization_token_validator.rb
Let’s start by creating a service object that will update user authentication_token:
101
app/services/users/update_authentication_token.rb
# frozen_string_literal: true
module Users
# {Users::UpdateAuthenticationToken} updates {User} authentication_token.
class UpdateAuthenticationToken
# @param [User] user
def initialize(user:)
@user = user
end
private
In the initialize method our class accepts user object. In the call method we update user
authentication_token that was generated by the AuthenticationTokenGenerator module.
102
spec/services/users/update_authentication_token_spec.rb
# frozen_string_literal: true
require 'spec_helper'
describe Users::UpdateAuthenticationToken do
describe '#call' do
let(:authentication_token) { 'test_authentication_token' }
let(:user) { create(:user) }
let(:result) { described_class.new(user: user).call }
before do
expect(AuthenticationTokenGenerator)
.to receive(:call)
.and_return(authentication_token)
result
end
To access api/v1/logout endpoint, we will need to send the access token in the Authorization header
and then validate it and get the user account from the database. Let’s create a helper method for
that:
103
app
@current_user = AuthorizationTokenValidator.new(
authorization_token: env['HTTP_AUTHORIZATION'],
purpose: purpose
).call
end
route do |r|
...
end
Here we first check if @current_user is already defined. If is, we will return it. If the @current_user
instance variable is not present, we check what purpose we should use to decode our message. If the
endpoint URL contains refresh_token, then the purpose is :refresh_token, otherwise the purpose is
:access_token. Then we use AuthorizationTokenValidator that will return the user when the token is
valid or raise ActiveSupport::MessageVerifier::InvalidSignature error when the incoming token is
not valid.
104
app
response.write(error_object.to_json)
end
app
route do |r|
r.on('api') do
r.on('v1') do
...
r.delete('logout') do
Users::UpdateAuthenticationToken.new(user: current_user).call
response.write(nil)
end
end
end
end
First, we use the current_user helper method to get the user based on the token passed in the
Authorization header, then we call Users::UpdateAuthenticationToken to update user
authentication_token, and we return an empty response. Simple as that.
Now we can start testing our first endpoint that requires an access token. The problem is that all of
our endpoints that require access token will have repeated tests that check how our endpoint
behaves when token is invalid, expired, etc.
105
context 'when Authorization header does not contain token' do
before { delete '/api/v1/logout' }
delete '/api/v1/logout'
end
before do
header 'Authorization', access_token(user)
user.update(authentication_token: 'test')
delete '/api/v1/logout'
end
To not repeat those tests, we will use the great RSpec feature, shared examples. Let’s add our first
shared examples to extract test for HTTP status and error response:
106
/spec/support/shared_examples/unauthorized.rb
# frozen_string_literal: true
RSpec.shared_examples 'unauthorized' do
it 'returns 401 HTTP status' do
expect(response.status).to eq 401
end
Here we extract examples that test HTTP status and response body when the access token is invalid.
Our next shared example will check the following examples:
107
/spec/support/shared_examples/authorization_check.rb
# frozen_string_literal: true
include_examples 'unauthorized'
end
public_send(method, url)
end
include_examples 'unauthorized'
end
before do
header 'Authorization', access_token(user)
user.update(authentication_token: 'test')
public_send(method, url)
end
include_examples 'unauthorized'
end
end
108
/spec/requests/api/v1/logout_spec.rb
# frozen_string_literal: true
require 'spec_helper'
let(:update_authentication_token) do
instance_double(Users::UpdateAuthenticationToken)
end
before do
expect(Users::UpdateAuthenticationToken)
.to receive(:new)
.with(user: user)
.and_return(update_authentication_token)
expect(update_authentication_token)
.to receive(:call)
delete '/api/v1/logout'
end
In the api/v1/logout endpoint request specs, we first test how our endpoint behaves when the
access token in the Authorization header is invalid. We do that using the authorization check RSpec
shared example. Next, we check our API endpoint when authorization is successful. We mock
Users::UpdateAuthenticationToken and check if API is response is correct.
109
$ rspec
..................................................................................
It works! In the next section will create a mechanism for refreshing tokens for users.
110
7.4. Refreshing an access token
After login user of our API gets two tokens in the JSON response:
It would not be best for our users to retype login and password every 5 minutes. This is where
refresh_token can help us. We need to create api/v1/refresh_token endpoint that will accept refresh
token in the Authorization header and generate pair of new of access and refresh token.
Let’s describe step by step what will happen during the refresh token process:
• We need to get current user from the refresh token sent in the Authorization header.
• Then, we need to update user authentication_token to invalidate existing access and refresh
tokens.
• We need to generate new pair of access and refresh tokens and return them in the JSON
response.
Let’s start by creating TokensSerializer that will represent in the JSON format our access and
refresh token:
/app/serializers/tokens_serializer.rb
# frozen_string_literal: true
TokenSerializer is responsible for representing access and refresh token information in the JSON
111
format. With that in place, we’ve got everything to create an endpoint for refreshing token:
app.rb
route do |r|
r.on('api') do
r.on('v1') do
...
r.post('refresh_token') do
Users::UpdateAuthenticationToken.new(user: current_user).call
TokensSerializer.new(tokens: tokens).render
end
end
end
end
• First, we get the user for which we want to regenerate tokens using the current_user helper
method.
/spec/requests/api/v1/refresh_token_spec.rb
# frozen_string_literal: true
require 'spec_helper'
let(:authorization_tokens_generator) do
instance_double(AuthorizationTokensGenerator)
end
112
let(:tokens) do
{
'access_token' => {
'token' => 'authorization_token',
'expires_in' => 1800
},
'refresh_token' => {
'token' => 'refresh_token',
'expires_in' => 3600
}
}
end
before do
expect(AuthorizationTokensGenerator)
.to receive(:new)
.with(user: user)
.and_return(authorization_tokens_generator)
expect(authorization_tokens_generator)
.to receive(:call)
.and_return(tokens)
expect(Users::UpdateAuthenticationToken)
.to receive(:new)
.with(user: user)
.and_return(update_authentication_token)
expect(update_authentication_token)
.to receive(:call)
post '/api/v1/refresh_token'
user.reload
end
context 'when Authorization headers contains valid authorization token with invalid
purpose' do
113
before do
header 'Authorization', access_token(user)
post '/api/v1/refresh_token'
end
include_examples 'unauthorized'
end
end
• That endpoint needs to receive a valid token in the Authorization header. We are using our
authorization check RSpec shared example.
• We test that it should regenerate tokens when a valid refresh token is sent in the Authorization
header.
• We also check that API should return 401 HTTP status when access token instead of the refresh
token is sent.
$ rspec
......................................................................................
......
In this way, we were able to complete the authentication mechanisms in our API. Our users can sign
up, log in, log out, and refresh their tokens.
In the next chapter we will create endpoints for managing user todos.
114
Chapter 8. Todo Management
In this chapter, we will give our users the possibility to manage their todos. We will create
endpoints for creating, updating, destroying, and presenting todos. Let’s get to work.
• The next thing we need is a query object responsible for filtering and ordering user todos based
on incoming parameters.
• We will also need a serializer that will represent a list of todos in the JSON format.
• The last thing will be adding api/v1/todos endpoint to our routing tree.
/app/params/todos_params.rb
# frozen_string_literal: true
115
• sort value should be one of defined in the Constants::TODO_SORT_COLUMNS.
All of those parameters are optional. As usual let’s write unit tests for that:
/spec/params/todos_params_spec.rb
# frozen_string_literal: true
require 'spec_helper'
describe TodosParams do
describe '#call' do
context 'when params are invalid' do
before do
expect(Exceptions::InvalidParamsError)
.to receive(:new)
.with(object, I18n.t('invalid_params'))
.and_return(Exceptions::InvalidParamsError.new(object, I18n
.t('invalid_params')))
end
let(:params) do
{
direction: 'invalid',
sort: 'invalid'
}
end
let(:object) do
{
direction: ['must be one of: desc, asc'],
sort: ['must be one of: name, description, created_at, updated_at']
}
end
it 'raises InvalidParamsError' do
expect { described_class.new.permit!(params) }.to
raise_error(an_instance_of(Exceptions::InvalidParamsError))
end
end
116
it 'returns validated params' do
expect(described_class.new.permit!(params)).to eq params
end
end
end
end
• When parameters are valid, the class should return validated parameters.
Given the todo list we defined at the beginning of this chapter, the next step will be to create
TodosQuery query object that will be responsible for filtering and ordering todos:
/app/queries/todos_query.rb
# frozen_string_literal: true
117
[:search_by_description]
scoped = scoped.order(Sequel.public_send(direction, sort))
scoped.all
end
private
It is the first query object class in our application, so let’s go into more detail about what’s going on
here.
In the initialize method, we accept two parameters, dataset, which represents SQL query, and
params, that will be used to filter and sort todos.
In the call method, we check if the search_by_name parameter is present. If it is, then we filter our
dataset using the search_by_name method defined in the app/todo.rb file otherwise, we skip this line.
Next, we check the presence of the search_by_description parameter, when its present, our dataset
will be filtered by the search_by_description method defined in the app/todo.rb file, same as in the
previous case if parameters are missing, we skip the filtering by description.
We use Sequel order method together with Sequel.desc and Sequel.asc methods to sort our todos. If
sort and direction parameters are empty, our collection will be by default sorted by created_at
column in descending order: scoped.order(Sequel.desc(:created_at)).
The last step is to use the all method that returns an array with all records in the dataset.
/spec/queries/todos_query_spec.rb
# frozen_string_literal: true
require 'spec_helper'
describe TodosQuery do
118
describe '#call' do
let!(:todo) { create(:todo) }
let(:todos) { described_class.new(dataset: Todo, params: params).call }
let(:dataset) { instance_double(Sequel::Postgres::Dataset) }
before do
expect(Todo)
.to receive(:order)
.with(Sequel.desc(:created_at))
.and_return(dataset)
expect(dataset)
.to receive(:all)
.and_return([todo])
end
before do
expect(Todo)
.to receive(:search_by_name)
.with(params[:search_by_name])
.and_return(dataset)
expect(dataset)
.to receive(:order)
.with(Sequel.desc(:created_at))
.and_return(dataset)
expect(dataset)
.to receive(:all)
.and_return([todo])
end
119
before do
expect(Todo)
.to receive(:search_by_description)
.with(params[:search_by_description])
.and_return(dataset)
expect(dataset)
.to receive(:order)
.with(Sequel.desc(:created_at))
.and_return(dataset)
expect(dataset)
.to receive(:all)
.and_return([todo])
end
before do
expect(Todo)
.to receive(:order)
.with(Sequel.asc(:name))
.and_return(dataset)
expect(dataset)
.to receive(:all)
.and_return([todo])
end
In this test we do not check if searching by name or description works, we already wrote that tests
in spec/models/todo_spec.rb where we test two dataset methods, search_by_name and
search_by_description. Here we only need to check that search_by_description and search_by_name
are called when relevant parameters are present.
The same is with the order method. We only need to check here that this method is called with valid
attributes. Because of that we test the behavior of our class in four scenarios:
120
• When params object is empty.
We need to create a serializer that will represent our Todo object in JSON format. We will create two
serializer classes:
• TodoSerializer, which will represent a single Todo object in the JSON format.
/app/serializers/todo_serializer.rb
# frozen_string_literal: true
In the to_json method, we prepare data before transformation to the JSON format, we are creating
Hash that includes. all todo attributes: id, name, description, created_at and updated_at.
TodosQuery class will return a list of Todo objects so let’s create a TodosSerializer that will use
TodoSerializer under the hood to represent multiple todo objects in the JSON format:
121
/app/serializers/todos_serializer.rb
# frozen_string_literal: true
private
Here we iterate over the @todos instance variable that contains a list of todos. For each of them, we
use TodoSerializer to build an array of todos we later convert to the JSON format.
Now we’ve got everything to add api/v1/todos endpoint to our routing tree:
122
app.rb
route do |r|
r.on('api') do
r.on('v1') do
...
r.on('todos') do
# We are calling the current_user method to get the current user
# from the authorization token that was passed in the Authorization header.
current_user
r.get do
todos_params = TodosParams.new.permit!(r.params)
todos = TodosQuery.new(dataset: current_user.todos_dataset, params:
todos_params).call
TodosSerializer.new(todos: todos).render
end
end
end
end
end
The r.on method creates branches in the routing tree. We called r.on with the string todos, which
will match the current request path if the request path starts with users.
Then we use r.get methods which are for routing based on the GET request method. If it is invoked
without a matcher, it puts a simple match against the request method. If invoked with a matcher, a
terminal match is performed against the request path.
As always, first we validate the incoming parameters using the TodosParams class:
todos_params = TodosParams.new.permit!(r.params)
The next step is to use the TodosQuery class to get the list of todos we want to return:
The last thing is to return the todos collection in the JSON format using the TodosSerializer class:
TodosSerializer.new(todos: todos).render
123
/spec/requests/api/v1/todos/index_spec.rb
# frozen_string_literal: true
require 'spec_helper'
before do
header 'Authorization', token
get '/api/v1/todos'
end
124
end
let(:todos_json_response) do
{
'todos' => [
{
'id' => todo.id,
'name' => todo.name,
'description' => todo.description,
'created_at' => todo.created_at.iso8601,
'updated_at' => todo.updated_at.iso8601
}
]
}
end
before do
header 'Authorization', token
get '/api/v1/todos'
end
let(:params) do
{
search_by_name: 'milk',
search_by_description: 'buy milk',
sort: 'name',
direction: 'asc'
}
end
let(:todos_json_response) do
{
'todos' => [
{
125
'id' => todo.id,
'name' => todo.name,
'description' => todo.description,
'created_at' => todo.created_at.iso8601,
'updated_at' => todo.updated_at.iso8601
}
]
}
end
before do
header 'Authorization', token
• First, we use the authorization check shared example to test how our endpoint behaves when
there is no access token in the Authorization header.
• We check that API should return 422 HTTP status code and error in the JSON response when the
request contains incorrectly formatted parameters.
• When todos are present in the database API should return 200 HTTP status code and todos in
the JSON response.
• When todos are not present in the database API should return 200 HTTP status code and return
an empty array in the JSON response.
• When search parameters are present, API should return 200 HTTP status code and return a list
of filtered todos in the JSON response.
126
rspec
......................................................................................
..........................
Tests are passing, so everything is working as expected. In the next section, we will work on todos
creation.
127
8.2. Todos creation
In the previous section, we added our users the ability to download information about their todos.
In this section, we’ll look at adding the ability to create a todo for a user.
Every Todo has the name and description, so we need to validate that incoming paramters will have
those values:
/app/params/todo_params.rb
# frozen_string_literal: true
In the TodoParams class, we check if the name and description are present, we will also reuse that
class when we work on the endpoint for updating todos later. Let’s write some tests:
128
/spec/params/todo_params_spec.rb
# frozen_string_literal: true
require 'spec_helper'
describe Api::V1::TodoParams do
describe '#call' do
context 'when params are invalid' do
before do
expect(Exceptions::InvalidParamsError)
.to receive(:new)
.with(object, I18n.t('invalid_params'))
.and_return(Exceptions::InvalidParamsError.new(object, I18n
.t('invalid_params')))
end
let(:object) do
{
name: ['is missing'],
description: ['is missing']
}
end
it 'raises InvalidParamsError' do
expect { described_class.new.permit!(params) }.to
raise_error(an_instance_of(Exceptions::InvalidParamsError))
end
end
end
Our tests, in this case, do not differ from the previous ones:
• We make sure that validated params will be returned when they are valid.
We have solved the problem of validating incoming parameters that we will be using when creating
129
a todo. Our next step will be to create a dedicated class responsible for saving todo in the database.
/app/services/todos/creator.rb
# frozen_string_literal: true
module Todos
# {Todos::Creator} creates {Todo} for specified {User}.
class Creator
# @param [User] the user that newly created Todo will belong to.
# @param [Hash] attributes of the {Todo}.
def initialize(user:, attributes:)
@user = user
@attributes = attributes
end
# It creates {Todo} object for specified {User} based on the passed attributes.
#
# @return [Todo] object when attributes are valid.
#
# @raise [Sequel::ValidationFailed] when attributes are not valid
#
# @example When attributes are valid:
# attributes = {name: 'Buy milk.', description: 'Please buy milk.'}
# Todos::Creator.new(user: User.last, attributes: attributes).call
#
# @example When attributes are not valid:
# Todos::Creator.new(user: User.last, attributes: {}).call
def call
Todo.create(
user: @user,
name: @attributes[:name],
description: @attributes[:description]
)
end
end
end
• In the initialize method we accept User object for which newly created Todo will belong to.
• The call method uses Todo.create method provided by Sequel::Model that will raise
Sequel::ValidationFailed when validation fails or return newly created Todo object when
creation will be successfull.
130
/spec/services/todos/creator_spec.rb
# frozen_string_literal: true
require 'spec_helper'
describe Todos::Creator do
describe '#call' do
let(:user) { create(:user) }
let(:result) { described_class.new(user: user, attributes: attributes).call }
let(:created_todo) do
Todo.find(
user: user,
name: 'Buy milk.',
description: 'Please buy milk.'
)
end
it 'raise Sequel::ValidationFailed' do
expect { result }.to raise_error(
Sequel::ValidationFailed
)
end
end
end
end
• When attributes are valid, the Todos::Creator class creates and returns the Todo object.
Now we are ready to add todo creation endpoint to the routing tree:
131
app.rb
route do |r|
r.on('api') do
r.on('v1') do
...
r.on('todos') do
# We are calling the current_user method to get the current user
# from the authorization token that was passed in the Authorization header.
current_user
...
r.post do
todo_params = TodoParams.new.permit!(r.params)
todo = Todos::Creator.new(user: current_user, attributes:
todo_params).call
TodoSerializer.new(todo: todo).render
end
end
end
end
end
As with the previous endpoints, our first step is to validate and make sure the incoming parameters
are correct:
todo_params = TodoParams.new.permit!(r.params)
Next, we user the Todos::Creator class that is responsible for Todo creation:
The last step is to return the newly create Todo object in the JSON format:
TodoSerializer.new(todo: todo).render
That was quick. Let’s write tests to check if we didn’t make any mistakes:
/spec/requests/api/v1/todos/create_spec.rb
# frozen_string_literal: true
require 'spec_helper'
132
describe 'POST /api/v1/todos', type: :request do
include_examples 'authorization check', 'post', '/api/v1/todos'
before do
header 'Authorization', token
let(:created_todo) do
Todo.find(
user: user,
name: params[:name],
description: params[:description]
)
end
let(:todo_json_response) do
{
'id' => created_todo.id,
'name' => created_todo.name,
'description' => created_todo.description,
'created_at' => created_todo.created_at.iso8601,
'updated_at' => created_todo.updated_at.iso8601
}
end
133
it 'returns todo data in the JSON response' do
expect(json_response).to eq todo_json_response
end
end
end
end
• That endpoint needs to receive a valid token in the Authorization header. We are using our
authorization check RSpec shared example.
• The API should return 422 HTTP status code and error in the JSON response when the request
contains incorrectly formatted parameters
• The API should return 200 HTTP status code and the newly created Todo object when incoming
parameters are valid.
rspec
......................................................................................
........................................
Once again, successful, all tests are green. The next step will be to give our users the ability to get
the informations about the particular todo.
134
8.3. Showing particular Todo
In this section, we will work on creating an endpoint for showing particular todo in the JSON
format. Later we will work on updating and deleting todos. Those endpoints have one thing in
common. They have the same URL address, but they require a different HTTP method:
Part of our URL will be todo id in the UUID format eg.: /api/v1/todos/a33253cf-7f03-4b38-b2e4-
f4af966fc8a9. We want to check that the todo id in the URL conforms to the UUID format. We will
use symbol_matchers plugin for that. First, let’s define a custom symbol matcher that will validate
the incoming id part of the api/v1/todos/:id URL:
app.rb
# The symbol_matchers plugin allows you do define custom regexps to use for specific
symbols.
plugin :symbol_matchers
Now we can use our newly created symbol matcher in todos routing branch:
135
app.rb
route do |r|
r.on('api') do
r.on('v1') do
...
r.on('todos') do
# We are calling the current_user method to get the current user
# from the authorization token that was passed in the Authorization header.
current_user
r.on(:uuid) do |id|
todo = current_user.todos_dataset.with_pk!(id)
r.get do
TodoSerializer.new(todo: todo).render
end
end
...
end
end
end
end
First, we use a custom symbol matcher to validate that id in the URL is valid UUID:
r.on(:uuid) do |id|
If the id passed in the URL is valid, we try to find Todo in the database:
todo = current_user.todos_dataset.with_pk!(id)
The last step is to represent found Todo in the JSON format using the TodoSerializer class:
TodoSerializer.new(todo: todo).render
Before we dive into writing integration tests, there is one more thing. the with_pk! method returns
the first record in the dataset with the specified primary key value. If the record is not present,
Sequel::NoMatchingRow will be raised. Because of that, we need to update our error_handler plugin
rules to catch that error and present a friendly message to our users:
136
app.rb
response.write(error_object.to_json)
end
/spec/requests/api/v1/todos/show_spec.rb
# frozen_string_literal: true
require 'spec_helper'
137
'created_at' => todo.created_at.iso8601,
'updated_at' => todo.updated_at.iso8601
}
end
before do
header 'Authorization', token
get "/api/v1/todos/#{todo.id}"
end
get '/api/v1/todos/21c9177e-9497-4c86-945b-7d1097c8865f'
end
before do
header 'Authorization', token
get "/api/v1/todos/#{todo.id}"
end
138
end
end
end
end
• First, we use the authorization check shared example to test how our endpoint behaves when
there is no access token in the Authorization header.
• We check that API should return 404 HTTP status code and error in the JSON response when the
todo id is invalid.
• We check that API should return 404 HTTP status code and error in the JSON response when the
todo belongs to a different user
• When todo id is valid API should return 200 HTTP status code and return particular todo in the
JSON response.
rspec
......................................................................................
....................................................
Everything is green. In the next section, we will create an endpoint for updating existing todos.
139
8.4. Updating Todo
What if our user makes a typo in the name or description of a todo? For this purpose, we will add a
PUT /api/v1/todos/:id endpoint that will allow us to update the existing todo in our system. Let’s
follow our pattern and create dedicated class for updating todos:
/app/services/todo/updater.rb
# frozen_string_literal: true
module Todos
# {Todos::Updater} updates existing {Todo}.
class Updater
# @param [Todo] todo
# @param [Hash] attributes of the {Todo}
def initialize(todo:, attributes:)
@todo = todo
@attributes = attributes
end
• In the initialize method, we accept the Todo object which we want to update and new Todo
attributes that we want to use in the updating process.
• The call method uses the Todo.update method provided by Sequel::Model that will raise
Sequel::ValidationFailed when validation fails or return the updated Todo object when
updating is successful.
140
/spec/services/todo/updater_spec.rb
# frozen_string_literal: true
require 'spec_helper'
describe Todos::Updater do
describe '#call' do
let(:todo) { create(:todo) }
let(:result) { described_class.new(todo: todo, attributes: attributes).call }
it 'raise Sequel::ValidationFailed' do
expect { result }.to raise_error(
Sequel::ValidationFailed
)
end
end
end
end
• When attributes are valid, the Todos::Updator class updates and returns the Todo object.
• When attributes are invalid, the Sequel::ValidationFailed error is raised, and the Todo object is
not updated.
141
app.rb
route do |r|
r.on('api') do
r.on('v1') do
...
r.on('todos') do
# We are calling the current_user method to get the current user
# from the authorization token that was passed in the Authorization header.
current_user
r.on(:uuid) do |id|
todo = current_user.todos_dataset.with_pk!(id)
...
r.put do
todo_params = TodoParams.new.permit!(r.params)
TodoSerializer.new(todo: todo).render
end
end
...
end
end
end
end
• After a successful update, we return the updated Todo object in the JSON format.
The last step is to write the integration test to make sure our endpoint works:
/spec/requests/api/v1/todos/update_spec.rb
# frozen_string_literal: true
require 'spec_helper'
142
let(:user) { create(:user) }
before do
header 'Authorization', token
todo.refresh
end
let(:todo_json_response) do
{
'id' => todo.id,
'name' => todo.name,
'description' => todo.description,
'created_at' => todo.created_at.iso8601,
'updated_at' => todo.updated_at.iso8601
}
end
143
header 'Authorization', token
put '/api/v1/todos/21c9177e-9497-4c86-945b-7d1097c8865f'
end
before do
header 'Authorization', token
put "/api/v1/todos/#{todo.id}"
end
• First, we use the authorization check shared example to test how our endpoint behaves when
there is no access token in the Authorization header.
• We check that API should return 404 HTTP status code and error in the JSON response when the
todo id is invalid.
• We check that API should return 404 HTTP status code and error in the JSON response when the
todo belongs to a different user
• When incoming parameters are invalid, API should return 422 HTTP status code and error in
the JSON response.
• When incoming parameters are valid, API should return 200 HTTP status code and the updated
Todo object in the JSON response.
144
rspec
......................................................................................
....................................................................
They are. In the next section will work on creating the last endpoint for removing todos.
145
8.5. Todo deletion
In this section, we will create an endpoint for removing todos from our system, but don’t worry,
this is not the end of our journey :)
This will be a quick job. We’ve got everything we need. We only need to add a route to the routing
tree and write integration tests.
app
route do |r|
r.on('api') do
r.on('v1') do
...
r.on('todos') do
# We are calling the current_user method to get the current user
# from the authorization token that was passed in the Authorization header.
current_user
r.on(:uuid) do |id|
todo = current_user.todos_dataset.with_pk!(id)
...
r.delete do
todo.delete
response.write(nil)
end
end
...
end
end
end
end
This one is super simple, we delete Todo using the delete method provided by Sequel::Model, and we
return an empty JSON response. Let’s test if it works:
/spec/requests/api/v1/todos/delete_spec.rb
# frozen_string_literal: true
require 'spec_helper'
146
context 'when Authorization headers contains valid token' do
let(:token) { access_token(user) }
let(:user) { create(:user) }
let(:todo) { create(:todo, user: user) }
delete "/api/v1/todos/#{todo.id}"
end
it 'deletes todo' do
expect(Todo.count).to eq 0
end
end
delete '/api/v1/todos/21c9177e-9497-4c86-945b-7d1097c8865f'
end
before do
header 'Authorization', token
delete "/api/v1/todos/#{todo.id}"
end
147
it 'returns 404 HTTP status' do
expect(response.status).to eq 404
end
Those tests scenarios are similar to the tests from the previous chapter:
• We use the authorization check shared example to test how our endpoint behaves when there is
no access token in the Authorization header.
• We check that API should return 404 HTTP status code and error in the JSON response when the
todo id is invalid.
• We check that API should return 404 HTTP status code and error in the JSON response when the
todo belongs to a different user
• When Todo deletion is successful, API should return 200 HTTP status with empty JSON response.
rspec
......................................................................................
.................................................................................
Everything works as expected. With that in place, we finished our work on building REST API with
Roda & Sequel. In the following chapters, we will focus on securing our application from abusive
requests and deploy it to Heroku.
148
Chapter 9. Security
Sites used by people often become the target of malicious activity, whether that be account
enumeration attacks, brute-force login attempts, DDoS attacks, or worse. Aside from the obvious
requirement to protect the potentially sensitive data your application deals with, it’s also crucial
that it’s available to your users when they want to use it and not unavailable due to being flooded
with requests.
It means it’s a component that sits between users and your application and is responsible for
processing requests from these users and returning responses from your application back to them.
In the case of Rack::Attack, it acts as a filter by comparing each request made to your application
against a set of rules you define, either globally or for specific endpoints.
You can prevent attempts at blunt-forcing passwords by throttling requests with the email or
username being attacked or prevent troublesome scrapers or other offenders by throttling requests
from IP addresses, making large volumes of requests. Rack::Attack makes protecting your
applications easy but still provides quite a bit of freedom to choose what to throttle, block, whitelist,
or blacklist.
The first step is to add the required gems to the Gemfile and run the bundle install command. I’ve
opted to use Redis as the cache store for Rack::Attack, so I need the redis and rack-attack gem to
the Gemfile:
Gemfile
Before we start configuring rack-attack, we need to check that Redis is up and running on our
machine. We can use the redis-cli command for that:
$ redis-cli
127.0.0.1:6379>
If Redis is not installed on your machine, you can find instructions on how to do this here.
We will connect to our Redis instance using URL, so let’s add the informations about the Redis URL
to our environment files:
149
env.development.template
REDIS_URL=redis://127.0.0.1:6379
env.test.template
REDIS_URL=redis://127.0.0.1:6379
env.development
REDIS_URL=redis://127.0.0.1:6379
env.test
REDIS_URL=redis://127.0.0.1:6379
We also need a Redis instance in our Continous Integration process, so let’s update:
.github/workflows/ci.yml file:
github/workflows/ci.yml
services:
postgres:
image: postgres:latest
env:
POSTGRES_USER: postgres
POSTGRES_DB: todo-api-test
POSTGRES_PASSWORD: postgres
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis
ports: ['6379:6379']
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
The next step is to create a dry-system component that will be responsible for setting up Redis
connection:
150
/system/boot/redis.rb
# frozen_string_literal: true
Application.boot(:redis) do |container|
# Load environment variables before setting up redis connection.
use :environment_variables
init do
require 'redis'
end
start do
# Define Redis instance.
redis = Redis.new(url: ENV['REDIS_URL'])
Before setting up a Redis connection, we need to have access to environment variables which store
our REDIS_URL. Because of that, we write use :environment_variables at the beginning of our
component to auto-boot the required dependency and make it available in the booting context.
Then we require the redis gem and define a new Redis instance: Redis.new(url: ENV['REDIS_URL']).
The last thing is registering our component, so we will access it from the Application object:
Application['redis'].
With the required gems installed and Redis up and running, the next step is to define the rules to be
used by Rack::Attack in processing requests. Here is a list of rules that we want to implement with
rack-attack:
• Limit POST requests to /api/v1/login to 10 requests every 60 seconds for one IP address.
• Limit POST requests to /api/v1/login to 10 requests every 60 seconds for one e-mail address.
• Limit POST requests to /api/v1/sign_up to 10 requests every 60 seconds for one IP address.
• Limit requests to all endpoints to 20 requests every 20 seconds for one IP address.
/system/boot/rack_attack.rb
# frozen_string_literal: true
Application.boot(:rack_attack) do
init do
151
require 'rack/attack'
end
start do |app|
# Configure Redis cache.
Rack::Attack.cache.store = Rack::Attack::StoreProxy::RedisStoreProxy
.new(app[:redis])
152
# Throttle all requests by IP (60rpm).
#
# Key: "rack::attack:#{Time.now.to_i/:period}:req/ip:#{req.ip}".
#
# If any single client IP is making tons of requests, then they're
# probably malicious or a poorly-configured scraper. Either way, they
# don't deserve to hog all of the app server's CPU. Cut them off!
Rack::Attack.throttle('req/ip', limit: 20, period: 20, &:ip)
• The last step is to allow all requests from the localhost using Rack::Attack.safelist method.
config.ru
# frozen_string_literal: true
# This file contains configuration to let the webserver which application to run.
require_relative 'app'
use Rack::Attack
run App.freeze.app
With that in place, we are ready to write tests that will check if Rack::Attack secures our API as
expected. The most important thing in the tests is clearing the Rack::Attack cache between spec
examples. For that, we will create the RedisHelpers module in /spec/support file:
153
/spec/support/redis_helpers.rb
# frozen_string_literal: true
# {RedisHelpers} module contains helper methods that clean Redis between RSpec
examples.
module RedisHelpers
# It returns Redis client instance.
def redis
@redis ||= Application[:redis]
end
RSpec.configure do |config|
config.include RedisHelpers, type: :throttling
end
• redis method gets the Redis client instance from the Application object:
We want to include the RedisHelpers module only for specs with type throttling. This is because we
don’t want a clean Redis state in tests where we do not use Redis because this will slow down our
specs.
Another thing before writing tests is that we need to update the app method in the
spec/support/rack_test.rb to parse whole config.ru instead using Rack::Builder.parse_file
command:
154
/spec/support/rack_test.rb
def app
Rack::Builder.parse_file('config.ru').first
end
/spec/requests/api/v1/throttling_spec.rb
# frozen_string_literal: true
require 'spec_helper'
155
post '/api/v1/login', params, 'REMOTE_ADDR' => "1.2.3.#{i}"
end
end
156
get '/api/v1/todos', {}, 'REMOTE_ADDR' => '1.2.3.4'
end
end
• In the following scenario, we check POST requests to /api/v1/login endpoint by email param. If
the limit of requests is exceeded, API should return 429 HTTP status code. Otherwise, 429 status
should not be returned.
• The last check is checking all requests by IP address. If more than 20 requests hit our API in 20
seconds, 429 HTTP status code should be returned. Otherwise, requests should not be blocked.
rspec
......................................................................................
......................................................................................
...
Everything is green. There is one last thing we need to fix before finishing our work. If we run the
rubocop command, we will see the following response:
157
rubocop
Inspecting 86 files
......................................................C.............................C.
Offenses:
We are breaking here Style/IpAddresses Rubocop rule. I decided to disable this rule in those two
files:
.rubocop.yml
Style/IpAddresses:
Exclude:
- system/boot/rack_attack.rb
- spec/requests/api/v1/throttling_spec.rb
And that’s pretty much it. With just a little work, my app is now relatively well protected against
misbehaving clients. Suppose I notice any apparent patterns of suspicious behavior in the future.
We have the flexibility to lock the app down further by simply adding the appropriate rules.
158
Chapter 10. Deployment
Heroku is a platform that enables developers to build, run, and operate applications entirely on the
cloud with multi-language support for Ruby, Go, Scala, PHP, etc. In this chapter, we will deploy our
application to the Heroku server.
159
/config/puma.rb
# frozen_string_literal: true
# Puma forks multiple OS processes within each dyno to allow a Rails app to support
multiple concurrent requests.
# In Puma terminology, these are referred to as worker processes
# (not to be confused with Heroku worker processes which run in their dynos).
# Worker processes are isolated from one another at the OS level, therefore not
needing to be thread-safe
workers Integer(ENV['WEB_CONCURRENCY'] || 1)
# Puma can serve each request in a thread from an internal thread pool.
# This behavior allows Puma to provide additional concurrency for your web
application.
# Loosely speaking, workers consume more RAM and threads consume more CPU, and both
offer more concurrency.
threads_count = Integer(ENV['MAX_THREADS'] || 5)
threads threads_count, threads_count
# Preloading your application reduces the startup time of individual Puma worker
processes and
# allows you to manage the external connections of each worker using the
on_worker_boot calls.
# In the config above, these calls are used to establish Postgres connections for each
worker process correctly.
preload_app!
# Heroku will set ENV['PORT'] when the web process boots up. Locally, default this to
3000 to match the Rails default.
port ENV['PORT'] || 3000
# *Cluster mode only* Code to run immediately before master process forks workers
(once on boot).
# These hooks can block if necessary to wait for
# background operations unknown to puma to finish before the process terminates.
# This can be used to close any connections to remote servers (database, redis)
# that were opened when preloading the code.
# This can be called multiple times to add hooks.
before_fork do
Sequel::Model.db.disconnect if defined?(Sequel::Model)
end
160
• First, we set a number of workers to 1. Each worker process used consumes additional memory.
This behavior limits how many processes you can run in a single dyno. With a typical Rails
memory footprint, you can expect to run 2-4 Puma worker processes on a free, hobby or
standard-1x dyno.
• Next, we set the number of threads. Puma allows you to configure your thread pool with a min
and max setting, controlling the number of threads each Puma instance uses.
• preload_app! reduces the startup time of individual Puma worker processes and allows you to
manage the external connections of each worker using the on_worker_boot calls.
• Heroku will set ENV['PORT'] when the web process boots up. Locally, default this to 3000 to
match the Ruby on Rails default.
• In the before_fork we put code to run immediately before master process forks workers. In our
case, we use this to close connection to our database using Sequel::Model.db.disconnect.
The next thing we need to do before the deployment to Heroku is adding Procfile file. The Procfile
specifies the commands that are executed by the app on startup. You can use a Procfile to declare a
variety of process types, including:
Procfile
• A Heroku app’s web process type is special: it’s the only process type that can receive external
HTTP traffic from Heroku’s routers. If your app includes a web server, you should declare it as
your app’s web process.
• The release process type is used to specify the command to run during your app’s release phase.
We want to run migrations of our database during release.
We also need to add x86_64-linux platform to the Gemfile.lock to be able to run bundle install on
Heroku, we can do that with bundle lock command:
Ok, we’ve got everything we need to start configuring our Heroku server. Before going further,
make sure that you have heroku-cli installed and configured.
161
Make sure you are in the directory that contains our app, then create an app on Heroku:
You can verify that the remote was added to your project by running:
Last step is to add SECRET_KEY_BASE environment variable that will be used to sign in messages
generated by MessageVerifier class:
heroku config:set
SECRET_KEY_BASE=274259656c6bf25bd9f48ecec253523348f7c44973198b86a42bcf0b47cdb3064a7d52
b8a08559e2
162
remote: -----> Compiling Ruby/Rack
remote: -----> Using Ruby version: ruby-3.0.0
remote: -----> Installing dependencies using bundler 2.2.16
remote: Running: BUNDLE_WITHOUT='development:test' BUNDLE_PATH=vendor/bundle
BUNDLE_BIN=vendor/bundle/bin BUNDLE_DEPLOYMENT=1 bundle install -j4
remote: Fetching gem metadata from https://rubygems.org/.......
remote: Fetching rake 13.0.3
remote: Installing rake 13.0.3
remote: Fetching concurrent-ruby 1.1.8
remote: Fetching minitest 5.14.4
remote: Fetching bcrypt 3.1.16
remote: Fetching zeitwerk 2.4.2
remote: Installing minitest 5.14.4
remote: Installing zeitwerk 2.4.2
remote: Installing bcrypt 3.1.16 with native extensions
remote: Installing concurrent-ruby 1.1.8
remote: Using bundler 2.2.16
remote: Fetching coderay 1.1.3
remote: Fetching dry-equalizer 0.3.0
remote: Installing dry-equalizer 0.3.0
remote: Installing coderay 1.1.3
remote: Fetching dry-inflector 0.2.0
remote: Installing dry-inflector 0.2.0
remote: Fetching dry-initializer 3.0.4
remote: Fetching ice_nine 0.11.2
remote: Installing dry-initializer 3.0.4
remote: Installing ice_nine 0.11.2
remote: Fetching method_source 1.0.0
remote: Installing method_source 1.0.0
remote: Fetching nio4r 2.5.5
remote: Fetching oj 3.11.2
remote: Fetching tty-color 0.6.0
remote: Installing tty-color 0.6.0
remote: Installing nio4r 2.5.5 with native extensions
remote: Installing oj 3.11.2 with native extensions
remote: Fetching pg 1.2.3
remote: Installing pg 1.2.3 with native extensions
remote: Fetching rack 2.2.3
remote: Installing rack 2.2.3
remote: Fetching redis 4.2.5
remote: Installing redis 4.2.5
remote: Fetching sequel 5.42.0
remote: Installing sequel 5.42.0
remote: Fetching timecop 0.9.4
remote: Installing timecop 0.9.4
remote: Fetching yard 0.9.26
remote: Installing yard 0.9.26
remote: Fetching pry 0.14.0
remote: Installing pry 0.14.0
remote: Fetching i18n 1.8.9
remote: Installing i18n 1.8.9
163
remote: Fetching tzinfo 2.0.4
remote: Installing tzinfo 2.0.4
remote: Fetching dry-core 0.5.0
remote: Installing dry-core 0.5.0
remote: Fetching pastel 0.8.0
remote: Installing pastel 0.8.0
remote: Fetching rack-attack 6.5.0
remote: Installing rack-attack 6.5.0
remote: Fetching roda 3.42.0
remote: Installing roda 3.42.0
remote: Fetching sequel_secure_password 0.2.15
remote: Installing sequel_secure_password 0.2.15
remote: Fetching activesupport 6.1.3
remote: Installing activesupport 6.1.3
remote: Fetching dry-configurable 0.12.1
remote: Installing dry-configurable 0.12.1
remote: Fetching dry-logic 1.1.0
remote: Installing dry-logic 1.1.0
remote: Fetching tty-logger 0.6.0
remote: Installing tty-logger 0.6.0
remote: Fetching dry-container 0.7.2
remote: Installing dry-container 0.7.2
remote: Fetching roda-enhanced_logger 0.4.0
remote: Installing roda-enhanced_logger 0.4.0
remote: Fetching dry-auto_inject 0.7.0
remote: Installing dry-auto_inject 0.7.0
remote: Fetching dry-types 1.5.1
remote: Installing dry-types 1.5.1
remote: Fetching dry-schema 1.6.1
remote: Installing dry-schema 1.6.1
remote: Fetching dry-struct 1.4.0
remote: Installing dry-struct 1.4.0
remote: Fetching dry-validation 1.6.0
remote: Installing dry-validation 1.6.0
remote: Fetching dry-system 0.18.1
remote: Installing dry-system 0.18.1
remote: Fetching puma 5.2.1
remote: Installing puma 5.2.1 with native extensions
remote: Fetching sequel_pg 1.14.0
remote: Installing sequel_pg 1.14.0 with native extensions
remote: Bundle complete! 28 Gemfile dependencies, 43 gems now installed.
remote: Gems in the groups 'development' and 'test' were not installed.
remote: Bundled gems are installed into `./vendor/bundle`
remote: Bundle completed (36.13s)
remote: Cleaning up the bundler cache.
remote: -----> Writing config/database.yml to read from DATABASE_URL
remote: -----> Detecting rake tasks
remote:
remote: ###### WARNING:
remote:
remote: There is a more recent Ruby version available for you to use:
164
remote:
remote: 3.0.1
remote:
remote: The latest version will include security and bug fixes. We always
recommend
remote: running the latest version of your minor release.
remote:
remote: Please upgrade your Ruby version.
remote:
remote: For all available Ruby versions see:
remote: https://devcenter.heroku.com/articles/ruby-support#supported-runtimes
remote:
remote:
remote: -----> Discovering process types
remote: Procfile declares types -> release, web
remote: Default types for buildpack -> console, rake
remote:
remote: -----> Compressing...
remote: Done: 30.8M
remote: -----> Launching...
remote: ! Release command declared: this new release will not be available until
the command succeeds.
remote: Released v8
remote: https://sequel-roda-json-todo-api.herokuapp.com/ deployed to Heroku
remote:
remote: Verifying deploy... done.
remote: Running release command...
remote:
remote: I, [2021-05-17T12:38:58.938274 #15] INFO -- : (0.001952s) SELECT
CAST(current_setting('server_version_num') AS integer) AS v
remote: I, [2021-05-17T12:38:58.947783 #15] INFO -- : (0.008786s) CREATE TABLE IF NOT
EXISTS "schema_info" ("version" integer DEFAULT 0 NOT NULL)
remote: I, [2021-05-17T12:38:58.949452 #15] INFO -- : (0.001319s) SELECT * FROM
"schema_info" LIMIT 1
remote: I, [2021-05-17T12:38:58.953065 #15] INFO -- : (0.003305s) SELECT 1 AS "one"
FROM "schema_info" LIMIT 1
remote: I, [2021-05-17T12:38:58.956344 #15] INFO -- : (0.002872s) SELECT
pg_attribute.attname AS pk FROM pg_class, pg_attribute, pg_index, pg_namespace WHERE
pg_class.oid = pg_attribute.attrelid AND pg_class.relnamespace = pg_namespace.oid AND
pg_class.oid = pg_index.indrelid AND pg_index.indkey[0] = pg_attribute.attnum AND
pg_index.indisprimary = 't' AND pg_class.oid = CAST(CAST('"schema_info"' AS regclass)
AS oid)
remote: I, [2021-05-17T12:38:58.959209 #15] INFO -- : (0.002414s) INSERT INTO
"schema_info" ("version") VALUES (0) RETURNING NULL
remote: I, [2021-05-17T12:38:58.961262 #15] INFO -- : (0.001373s) SELECT count(*) AS
"count" FROM "schema_info" LIMIT 1
remote: I, [2021-05-17T12:38:58.962621 #15] INFO -- : (0.001121s) SELECT "version"
FROM "schema_info" LIMIT 1
remote: I, [2021-05-17T12:38:58.965307 #15] INFO -- : Begin applying migration
version 1, direction: up
remote: I, [2021-05-17T12:38:58.966802 #15] INFO -- : (0.001348s) BEGIN
165
remote: I, [2021-05-17T12:38:58.992704 #15] INFO -- : (0.025666s) CREATE
EXTENSION IF NOT EXISTS "uuid-ossp";
remote: CREATE EXTENSION IF NOT EXISTS "citext";
remote:
remote: I, [2021-05-17T12:38:58.994776 #15] INFO -- : (0.001632s) UPDATE
"schema_info" SET "version" = 1
remote: I, [2021-05-17T12:38:59.003593 #15] INFO -- : (0.008481s) COMMIT
remote: I, [2021-05-17T12:38:59.003999 #15] INFO -- : Finished applying migration
version 1, direction: up, took 0.038689 seconds
remote: I, [2021-05-17T12:38:59.004229 #15] INFO -- : Begin applying migration
version 2, direction: up
remote: I, [2021-05-17T12:38:59.005901 #15] INFO -- : (0.001287s) BEGIN
remote: I, [2021-05-17T12:38:59.036234 #15] INFO -- : (0.023781s) CREATE TABLE
"users" ("id" uuid DEFAULT uuid_generate_v4() NOT NULL PRIMARY KEY, "email" citext NOT
NULL UNIQUE, "password_digest" text NOT NULL, "authentication_token" text NOT NULL
UNIQUE, "created_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, "updated_at"
timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL)
remote: I, [2021-05-17T12:38:59.039308 #15] INFO -- : (0.002787s) UPDATE
"schema_info" SET "version" = 2
remote: I, [2021-05-17T12:38:59.046310 #15] INFO -- : (0.006730s) COMMIT
remote: I, [2021-05-17T12:38:59.046470 #15] INFO -- : Finished applying migration
version 2, direction: up, took 0.042235 seconds
remote: I, [2021-05-17T12:38:59.046521 #15] INFO -- : Begin applying migration
version 3, direction: up
remote: I, [2021-05-17T12:38:59.059121 #15] INFO -- : (0.012453s) BEGIN
remote: I, [2021-05-17T12:38:59.069829 #15] INFO -- : (0.009719s) CREATE TABLE
"todos" ("id" uuid DEFAULT uuid_generate_v4() NOT NULL PRIMARY KEY, "name" text NOT
NULL, "description" text NOT NULL, "created_at" timestamp DEFAULT CURRENT_TIMESTAMP
NOT NULL, "updated_at" timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL, "user_id" uuid
NOT NULL REFERENCES "users" ON DELETE CASCADE)
remote: I, [2021-05-17T12:38:59.075917 #15] INFO -- : (0.005666s) UPDATE
"schema_info" SET "version" = 3
remote: I, [2021-05-17T12:38:59.079352 #15] INFO -- : (0.003255s) COMMIT
remote: I, [2021-05-17T12:38:59.079505 #15] INFO -- : Finished applying migration
version 3, direction: up, took 0.032976 seconds
remote: Waiting for release.... done.
To https://git.heroku.com/sequel-roda-json-todo-api.git
* [new branch] main -> main
That’s it. Our application was successfully deployed. Let’s test if we can create an account:
166
curl --location --request POST 'https://sequel-roda-json-todo-
api.herokuapp.com/api/v1/sign_up' \
> --form 'email="user@test.com"' \
> --form 'password="password"' \
> --form 'password_confirmation="password"'
{"user":{"id":"f193f999-2900-46c9-a625-7a4d6bf55e1b","email":"user@test.com"
,"created_at":"2021-05-17T12:40:21+00:00","updated_at":"2021-05-17T12:40:21+00:00"
},"tokens":{"access_token":{"token":"eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9NZFhObGNsO
XBaRWtpS1dZeE9UTm1PVGs1TFRJNU1EQXRORFpqT1MxaE5qSTFMVGRoTkdRMlltWTFOV1V4WWdZNkJrVlVPaGx
oZFhSb1pXNTBhV05oZEdsdmJsOTBiMnRsYmtraVZXTm1aV1ZsWlRNeE1HSXlOMk5pWkRoaVltUmhNbU16WkRVM
04ySmtOV1EwTXpka1ltTmhaamd4TWpKbU1qbG1ZV0kyT0dJeE5EUmlNalJrWldRMU56RTNPVFU0Tm1FMVltWmh
NbU13TnpobEJqc0dWQT09IiwiZXhwIjoiMjAyMS0wNS0xN1QxMjo0NToyMVoiLCJwdXIiOiJhY2Nlc3NfdG9rZ
W4ifX0=--
eef3e13ce7cbab0c89ea948306dcb187bcd683e57cdceed8e680fb1b15b9945de19c548d4793bd4f3b3426
c8dfbcee07b5e077d0bafd21fe2e0ce04f15fc02b3","expires_in":300},"refresh_token":{"token"
:"eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaDdCem9NZFhObGNsOXBaRWtpS1dZeE9UTm1PVGs1TFRJNU1EQXRO
RFpqT1MxaE5qSTFMVGRoTkdRMlltWTFOV1V4WWdZNkJrVlVPaGxoZFhSb1pXNTBhV05oZEdsdmJsOTBiMnRsYm
traVZXTm1aV1ZsWlRNeE1HSXlOMk5pWkRoaVltUmhNbU16WkRVM04ySmtOV1EwTXpka1ltTmhaamd4TWpKbU1q
bG1ZV0kyT0dJeE5EUmlNalJrWldRMU56RTNPVFU0Tm1FMVltWmhNbU13TnpobEJqc0dWQT09IiwiZXhwIjoiMj
AyMS0wNS0xN1QxMjo1NToyMVoiLCJwdXIiOiJyZWZyZXNoX3Rva2VuIn19--
9b47e08779e7c568b9a061d94cb15caa9d70ec0f7ef6ba92781dfc223a4a1ab5c44add88dcc8a46900f15d
888e7bad4be4b87c93400d6e00e907186fecefd6c6","expires_in":900}}}
The icing on the cake will be generating code documentation using the rake docs command. After
that, /doc folder will be created that will contain documentation generated by yard.
167
Chapter 11. Epilogue
So there you have it, a Roda application. Hopefully, this has been a good demonstration about
organizing a Roda application in a better fashion than what’s given to us by default. The separation
between classes makes this application a little more tedious to setup than a traditional Ruby on
Rails application but lends itself to future maintenance. I hope you enjoyed reading this book. If you
have some ideas or thoughts about the book, do not hesitate to contact me.
Happy Hacking!
168