This book was originally written as a collaboration between myself (Federico) and Avdi Grimm (from RubyTapas/Graceful.dev)
It is now an Open Source effort maintained by myself and Jeremy Evans (Roda's creator and maintainer).
Mastering Roda by Federico Iachetti is licensed under a Creative Commons Attribution 4.0 International License .
You can contribute and propose changes to this book by submitting a merge request to the repository at https://gitlab.com/fiachetti/mastering-roda .
If you would like to support this project monetarily, you can do so via be a direct donation through PayPal. Although, you wouldn't get anything in return, except for my eternal gratitude towards you.
Another way to support the project is to share it. More eyes on an open source book means more ideas and more feedback.
Thank you very much for helping!
I've been a Ruby on Rails developer since 2011. Rails is a great framework. It's also a big and opinionated framework. It allows us to do almost anything out of the box (whether we need to or not), with little or no extra configuration needed. Unless we want to do something that's "off the rails"… in which case, we're on our own.
After a while using Rails I decided I wanted to move on to smaller frameworks, taking a more minimalistic approach. After some time trying a number of gems I landed on Roda, a small library created by Jeremy Evans, which I really liked. In fact, I liked so much that I wanted to share my knowledge to how to use it.
Roda is a routing tree web toolkit. The Roda philosophy prefers simplicity, reliability, extensibility, and performance, with only the most basic features enabled by default. While only the most basic features are enabled by default, Roda ships with an extensive set of features. All other features are enabled separately using a very powerful plugins library.
Each of the features (plugins) that Roda ships with can be thought of as a tool, and depending on the type of web application we are building, we may need different tools. Roda lets us choose the tools that we use to build our web application. In general, Roda operates more like a library than a web framework, though it is often compared with other web frameworks.
Roda performs routing by implementing what its developer calls a routing tree. As we'll see in the pages ahead, this routing tree approach is what gives Roda a lot of its flexibility and power. The primary advantage of the routing tree is that request handling and routing are integrated, so that we can handle a request while routing it. This can remove a lot of duplication inherent in web frameworks that separate routing from request handling.
Roda is a lightweight library. The basic features enabled by default are implemented in fewer than 800 lines of code. However, Roda ships with over 100 plugins that can handle the needs of most web applications.
Roda was designed with performance in mind, and it is widely considered the fastest Ruby web framework. While some of the optimizations that Roda uses make the code more difficult to understand, most of Roda's code is easy to understand. Applications built using Roda are also easy to understand, since it is possible to trace the logic and see exactly how a request will be routed/handled.
Roda takes special care to avoid polluting the application scope with many instance variables, constants or methods. This helps avoid unexpected name clashes. All internal instance variables that Roda uses are prefixed with an underscore, and all constants are prefixed with Roda. There are only a handful of methods defined in the application scope.
In this book, we'll cover the basic concepts and tools Roda provides, along with conventions and good practices that will help us get started using this amazing library.
This book is completely driven by examples. Every concept introduced is described by providing a problem or situation to solve.
You're welcome to simply read this book from cover to cover. Each example is written in a way that you can follow along at home, and I suggest you do. Applying examples to code is a great way to cement the concepts in your mind.
I'd also suggest that you go further and experiment on the code. If there's something you didn't completely understand, before contacting us, modify the code in order to discover what's missing. If you find something that you think should be changed, please submit a merge request to change the book.
lucid_http
Before starting with Roda itself, I'd like to introduce
lucid_http
, a gem I created for showing
off HTTP interactions. This gem is used in the rest of the book for the
example code for submitting requests and receiving responses. I won't go
into much detail here. For a more detailed explanation, please consult
the appendix called "The lucid_http
gem".
lucid_http
is a thin wrapper over the
http.rb
library, which provides a very
simple and consistent API for performing HTTP requests. What lucid_http
adds is a higher level presentation
abstraction.
I created a small app that will allow me to show how the gem works.
The code for this app is included in the Mastering Roda repository, in the appendix_lucid_http_app.ru
file. In the
appendix I go into detail about how to get it running.
So, we start by making a GET
request to
the hello
path.
To do this, we need to call the GET
method, passing the required path as an argument.
require "lucid_http"
GET "/hello"
# => "<h1>Hello World!</h1>"
The method will return the rendered body, which is shown as the
string after the # =>
comment
marker.
By default, the base URL we're targeting is http://localhost:9292. Notice that
there's not a trailing slash on that string, which means that we need to
include it on the path we want to request, hence the /hello
argument path.
By calling the GET
method we're
returning the body of our response. However, what about other relevant
information? Well, lucid_http
also
provides the following methods:
require "lucid_http"
GET "/hello/you"
# => "200 OK"
status .to_i # => 200
status# => "text/html"
content_type # => "http://localhost:9292/hello/you"
url # => "/hello/you" path
When we decide to make the next request, the current information gets cleaned up, and the new request starts with a clean slate.
require "lucid_http"
GET "/hello/you"
# => "200 OK"
status # => "text/html"
content_type # => "http://localhost:9292/hello/you"
url # => "/hello/you"
path [/\>(.+)\</, 1] # => "Hello, You!"
body
GET "/403"
# => "403 Forbidden"
status # => "text/html"
content_type # => "http://localhost:9292/403"
url # => "/403"
path # => "The request returned a 403 status." body
We can follow redirections passing the follower: :follow
attribute
require "lucid_http"
GET "/redirect_me"
# => "302 Found"
status
GET "/redirect_me", follower: :follow
# => "200 OK"
status # => "You have arrived here due to a redirection." body
If we get an error status (500
), we can
see what happened by calling error
, which
will return the first line of the body in order to show a succinct
message.
require "lucid_http"
GET "/500"
# => "500 Internal Server Error"
status # => "ArgumentError: wrong number of arguments (given 0, expected 2+)" error
However, if the request doesn't return a 500
code, the library will be nice enough to let
us know.
require "lucid_http"
GET "/not_500"
# => "200 OK"
status # => "No error found" error
If we have a json
endpoint, the string
output might not be the best way to show it
require "lucid_http"
GET "/hello_world"
# => "You said: hello_world"
GET "/hello_world.json"
# => "{\"content\":\"You said: hello_world\",\"keyword\":\"hello_world\",\"timestamp\":\"2016-12-31 15:00:42 -0300\",\"method\":\"GET\",\"status\":200}"
However, passing the formatter: :json
attribute, we see it as a Hash, which is much nicer to look at. That's
better.
require "lucid_http"
GET "/hello_world"
# => "You said: hello_world"
GET "/hello_world.json", formatter: :json
# => {"content"=>"You said: hello_world",
# "keyword"=>"hello_world",
# "timestamp"=>"2016-12-31 15:00:42 -0300",
# "method"=>"GET",
# "status"=>200}
lucid_http
also support a number of
other HTTP verbs we can use.
require "lucid_http"
GET "/verb" # => "<GET>"
# => "GET"
verb
POST "/verb" # => "<POST>"
# => "POST"
verb
PUT "/verb" # => "<PUT>"
# => "PUT"
verb
PATCH "/verb" # => "<PATCH>"
# => "PATCH"
verb
DELETE "/verb" # => "<DELETE>"
# => "DELETE"
verb
OPTIONS "/verb" # => "<OPTIONS>"
# => "OPTIONS" verb
Finally, we can submit a form with the request using the :form
option.
require "lucid_http"
POST "/params?item=book", formatter: :json
# => {"item"=>"book"}
POST "/params", formatter: :json, form: { item: "book", quantity: 1, price: 50.0, title: "The complete guide to doing absolutely nothing at all." }
# => {"item"=>"book",
# "quantity"=>"1",
# "price"=>"50.0",
# "title"=>"The complete guide to doing absolutely nothing at all."}
Now that we have a basic understanding of how results will be displayed throughout the book, we can begin.
In this section we'll explore Roda's core classes, and the behavior and capabilities that Roda offers by default. This is referred to as core Roda. However, when discussing many core Roda features, we'll also bring up related plugins.
We'll learn the basic structure of a Roda application, how it routes requests from the user to the target code, how to return the appropriate response, and how to handle sessions.
We'll kick things off by creating a very small web application to help get a grasp of what it looks like. We first need to create a new Roda project.
Some users that are used to working in a heavyweight web framework like Rails might be expecting Roda to include a command to generate a new project. However, Roda is more of a library than a framework, and does not come with such a command.
So our first step is simply to create a new, empty directory.
mkdir my_app
Let's add a Gemfile
, to control the
Rubygems this project will use.
Naturally, our first gem to add is roda
. Then we need a web application server.
We'll go with puma
, which is a simple,
mature, and fast choice. Lastly, since we'd like to use the rackup
executable, we'll add the rackup
gem for that because, as of Rack 3, the
rackup
executable has moved out of the
rack
gem and into a separate rackup
gem. So our Gemfile
will look like this.
"https://rubygems.org"
source
"roda"
gem "puma"
gem "rackup" gem
Now we run bundle install
, to install
the gems we've added.
bundle install
For the rest of the book, unless noted otherwise, we'll be using this
configuration for every example. The gems we installed are going to be
present in every Gemfile
from here on. Now
we're ready to start writing some code.
Every major Ruby web application framework is built on top of a
universal compatibility layer called Rack. Roda too is
Rack-compatible, so we start out by creating a "rackup file",
using the standard file name config.ru
.
In it we require roda
, and then create
a new class to represent our application. This app will inherit from the
Roda class.
Roda is built around the idea of a routing tree,
which implies creating branches by adding routes. So
we start by defining a route
block. This
block will receive a request as an argument, which, by convention we
abbreviate as r
.
Our first route checks to see if the HTTP request is for the
/hello
path. If so, the block that we
provide will be executed. We'll just return the string "hello!" from our
block.
By inheriting from the Roda class, our App
class is implicitly a Rack
application. In order to tell Rack (and the web server) to
execute our app for HTTP requests, we have to tell it to run
the App
class.
require "roda"
class App < Roda
do |r|
route .get "hello" do
r"hello!"
end
end
end
App run
Then from the command line we run the rackup
command to start up the web server and
start serving requests.
rackup
If we now load http://localhost:9292/hello in our browser, we see our first message. Yay!
Now let's make a change to our tiny application. We'll change the return value of the block.
require "roda"
class App < Roda
do |r|
route .get "hello" do
r"Hello, world!"
end
end
end
App run
If we navigate again to http://localhost:9292/hello, we see … nothing has changed. Why not? Because the server is still running our original code.
This is another reminder that we're not building on a fancy web framework. If we want bells and whistles like automatic code reloading, we can have them! … but we have to ask for them.
For a very simple way to reload the code every time we change it,
we'll use the rerun
gem (RubyTapas
Episode #320 provides a quick introduction to the rerun
gem).
rerun rackup
Now we can navigate to http://localhost:9292/hello, and see the updated output.
Again, for this moment on assume that, for every example, we'll be
running rackup
using rerun
in order to avoid having to start and stop
the server by hand.
We have a working web application, but we'd like to be able to
interact with it. Let's give the user the ability to specify who or what
is being greeted. For this, let's create a second route. As before,
we'll match again the string "hello". However, this time, we'll follow
the "hello" with String
, which indicates
that any string segment will match. This means that /hello/Frank
and /hello/Nancy
will both match.
When we specify the String
class as a
matcher, the block we provide will receive a corresponding block
argument for the string that matched. Let's give the block argument an
appropriate name, and then interpolate it into the output.
require "roda"
class App < Roda
do |r|
route .get "hello", String do |name|
r"<h1>Hello #{name}!</h1>"
end
end
end
App run
When we navigate to http://localhost:9292/hello/Roda we see that the string Roda is taken from the path and inserted into the page.
So far, we've been working out of the config.ru
rackup file. However, a
rackup file is really intended just for configuring the app server
startup process, not for including a whole application. Let's organize
our app a little better, and move the application code out of the config.ru
file.
We remove the entire App
class, along
with the require
statement, and in its
place, we require a file named app.rb
.
require "./app"
App run
Then we proceed to create the app.rb
file and paste the code in.
require "roda"
class App < Roda
do |r|
route .get "hello", String do |name|
r"<h1>Hello #{name}!<h1>"
end
end
end
We can check that everything still works.
So there we have it, our very first Roda app.
Now let's imagine we are writing a site that tells us about a mystery
guest. The user doesn't know who the mystery guest is, but they should
be able to see it by browsing to the /mystery_guest
path.
For reasons beyond the scope of this book, we are going to have the
mystery guest be a mozzarella pizza. We'll be using Ruby's Struct
class to create a Pizza
class, and then create an instance of
Pizza
. Then we'll use that Pizza
instance in our mystery_guest
route. Just for whimsy, we'll
misspell Guest
on the page.
require "roda"
Pizza = Struct.new(:flavor)
class App < Roda
= Pizza.new("Mozzarella")
mystery_guest
do |r|
route .get 'mystery_guest' do
r"The Mystery Gest is: #{mystery_guest}"
end
end
end
That's not very helpful. What's going on here? If we use LucidHttp
to fetch the page, we see that the
result is coming back correctly, it's a Pizza
!
require "lucid_http"
GET "/mystery_guest"
# => "The Mystery Gest is: #<struct Pizza flavor=\"Mozzarella\">"
Why can't we see that on the browser if the response is getting
properly returned? If we take a closer look, everything breaks after the
#
. The next string is
<struct Pizza flavor="Mozzarella">
, which a lot like
at HTML tag. What's the solution for this?, well, one solution would be
to implement a to_s
method in our pizza
class and everything would be fine. Unfortunately, this approach is too
naive. Even though for this particular case is the actual solution, it
excludes at least two major considerations:
Pizza
class and we didn't expect it. In that case, the solution is to put a
to_s
method somewhere, but we probably
don't know where, because we're unsure about the class we're dealing
with.<
and >
.For both cases, the h
plugin provides a
solution. We can load it into our Roda application using plugin :h
. After loading it, we can pass the
string we need to escape to the h
method
require "roda"
Pizza = Struct.new(:flavor)
class App < Roda
:h
plugin
= Pizza.new("Mozzarella")
mystery_guest
do |r|
route .get 'mystery_guest' do
r"The Mystery Gest is: #{h mystery_guest}"
end
end
end
and the response will be properly escaped
require "lucid_http"
GET "/mystery_guest"
# => "The Mystery Gest is: #<struct Pizza flavor="Mozzarella">"
It makes the raw HTML look ugly, but it allows for correctly displaying the result in the browser
At a basic level, HTTP is just a bunch of requests and responses. If we only ever needed to respond to one kind of request, we could handle it all with one big hunk of code.
However, web applications usually support a lot more than one type of request. To keep ourselves sane as developers, we usually try to separate out the different bits of code which handle different types of request. Likewise, we try to avoid duplicating the bits of code that are the same across requests.
Taking a client's request and directing it to the bit of code that should handle it is called routing and in this section we'll take a look how Roda does it.
As we saw in the previous section's examples, to create a Roda app we first define a class which inherits from Roda.
Now, in order to actually add our routes, we go into our
Roda app class and call the route
class method. We pass a block, which will receive as a parameter an
object representing the current request.
Let's stop right here and take a look at what the contents of the
r
parameter look like. We'll use the Kernel#p
method for this. Since we are only
using p
for debugging, we will not indent
it, so that it is easier to spot to remove later. We'll also return an
empty string in order to avoid cluttering our output with error messages
(we'll explain why this step is necessary later).
require "roda"
class App < Roda
do |r|
route p r
""
end
end
When we browse any path on the app, we see that our block has been
passed an App::RodaRequest
instance.
#<App::RodaRequest GET />
127.0.0.1 - - [07/Apr/2023:10:57:40 -0300] "GET / HTTP/1.1" 200 - 0.0009
Roda::RodaRequest
is a subclass of the
Rack::Request
class, with methods added to
handle routing. App::RodaRequest
is a
subclass of Roda::RodaRequest::
, which is
automatically created when App
is created,
which allows for customization of App
request instances in plugins.
We can see from the p
output that the
request is a GET
request for the /
path.
We can call methods on the request object (r
) to handle routing, and we're going to explore
some of them in the next few sections. For now, let's start by defining
a single route.
We call the on
method on our request
object, passing it a string parameter and a block. From the block, we
return a string.
class App < Roda
do |r|
route .on "hello" do
r"Hello Roda!"
end
end
end
The r.on
method is one of the methods
that Roda calls a match method, which is a method that
accepts arguments (called matchers), and sees if the matchers
match the request. If the match is successful, the match method
yields to the block (called a match block), and the request
handling ends when the match block exits. If the match was not
successful, the match method does not yield to the match
block, and execution continues.
The r.on
method is the simplest
match method. It only checks that the matchers match
the request, and does not do any additional checks.
Let's take a look at what it produced. The page body is the exact
string the match block returned. When Roda yields to a
match block, and the match block returns a string,
that string is used as the body of the response. We can also see that
the response's status code is 200
and the
response's content type is text/html
.
require "lucid_http"
GET "/hello"
# => "Hello Roda!"
body # => "200 OK"
status # => "text/html" content_type
If the block returns nil
or false
,
class App < Roda
do |r|
route .on "hello" do
rnil
end
end
end
Roda interprets it as an unhandled route, and returns an
empty body, with a 404
response status
code.
require "lucid_http"
GET "/hello"
# => ""
body # => "404 Not Found" status
If we return something that Roda does not know how to handle, such as
an Integer
class App < Roda
do |r|
route .on "hello" do
r1
end
end
end
Roda raises an Roda::RodaError
exception, which most webservers will treat as an internal server error,
with some body prepared by the webserver:
require "lucid_http"
GET "/hello"
# => "Roda::RodaError: unsupported block result: 1"
error # => "500 Internal Server Error" status
Roda's default behavior is that only strings, nil
, and false
are
supported return values of match blocks. Roda ships
with plugins that support additional return values for match
blocks, and we will discuss those later.
The story doesn't end there. Let's go back to our previous example. If we look closely, we see that the actual code that generates the content is wrapped inside a block.
Let's put a debugging statement inside the route
block and another one inside the hello
block,
require "roda"
class App < Roda
do |r|
route p "ROUTE block"
.on "hello" do
r
p "HELLO block"
"Hello Lucid!"
end
end
end
When we browse to the root path of the application (http://localhost:9292/), we can see
that the string corresponding to the route
block was printed.
When we browse to http://localhost:9292/hello, both strings are printed.
"ROUTE block"
127.0.0.1 - - [07/Apr/2023:11:02:05 -0300] "GET / HTTP/1.1" 404 - 0.0009
"ROUTE block"
"HELLO block"
127.0.0.1 - - [07/Apr/2023:11:02:16 -0300] "GET /hello/ HTTP/1.1" 200 12 0.0006
This demonstrates one of the core principles of Roda's design. In many web frameworks, the routing definitions are executed when the program starts, and compiled into some kind of internal data structure. Roda is different, in that the route block is executed every time a request is received.
This is a really powerful idea. For one thing, it makes Roda's routing really easy to understand. We don't have to mentally map the route definition to some kind of internal representation. What we see is what we get: if we can read Ruby code, we can tell what each step of the route will do.
Some users might be worried that Roda's design could lead to slower routing of requests as the route definitions grow in size. After all, Roda is executing the route block for every single request. However, as branches not taken are skipped, and routes inside skipped branches are not considered, Roda's design generally results in very fast routing! In fact, when it comes to routing, Roda is the fastest Ruby web framework with significant production usage.
From a Big-O perspective,
Roda's default routing design requires an O(N)
lookup at each branch of the routing tree
(including the root), with N
being the
number of possible branches at that level. However, in most applications
this results in roughly O(log(N))
routing
for an application, where N
is the total
number of routes in the application. We'll see later that Roda
ships with a plugin that allows for O(1)
routing at each branch of the routing tree, allowing for O(N)
routing performance, where N
is the number of segments in the request
(regardless of the total number of routes in the application).
As we'll see in future sections, the incoming request works its way through the Roda routing definition like a caterpillar crawling from a tree's trunk out to the very tip of a twig. Only the parts of the routing code that match the request are ever run.
We can see it happen by adding a second route with debugging statements.
require "roda"
class App < Roda
do |r|
route p "ROUTE block"
.on "hello" do
rp "HELLO block"
"hello"
end
.on "goodbye" do
rp "GOODBYE block"
"goodbye"
end
end
end
If we now browse to http://localhost:9292/goodbye,
we might expect both hello
and goodbye
strings to appear in the output, but
they don't. Only the route that matched was executed, even though that
the definition of the hello
route came
first.
"ROUTE block"
"GOODBYE block"
127.0.0.1 - - [07/Apr/2023:11:04:56 -0300] "GET /goodbye HTTP/1.1" 200 7 0.0009
In fact, this tree-style execution is why Roda is called a "Routing Tree Web Toolkit".
Another way to think about this is as a lazy evaluation of the routes. A route (match block) doesn't get executed unless it's actually needed.
There is another advantage to this tree-ish style of routing. In most other frameworks, if we need to perform some setup for handling a request, we must wait until after routing has been completed. With Roda, setup for handling a group of routes can happen while we are routing. This eliminates the need for "filter" or "hook" mechanisms to perform actions before or after every one of a group of routes.
What do I mean with that? Well, say we have users in our system and both routes either say hello or goodbye to them, but for that to happen, we first need to find the user somehow. We can retrieve it from a database, from the session or any other mechanism we can think of. Let's simulate this by just assigning a local variable. Then we insert it into the output string. Now we need to do the same for the second route.
require "roda"
class App < Roda
do |r|
route .on "hello" do
r= "Roda"
name "Hello, #{name}!"
end
.on "goodbye" do
r= "Roda"
name "Goodbye, #{name}!"
end
end
end
If we kept adding sub-routes that need to access this user, we'd need
to keep fetching it, right? Well, actually no. Here's when the shared
scope nature of code blocks in Ruby pays off in this structure. Instead
of retrieving the user each time we need it, we can actually retrieve it
once inside the route
block (or inside any
match block) and it will be available in all of the nested
routes.
require "roda"
class App < Roda
do |r|
route = "Roda"
name
.on "hello" do
r"Hello, #{name}!"
end
.on "goodbye" do
r"Goodbye, #{name}!"
end
end
end
Now we can browse to http://localhost:9292/hello and we get the expected output. We get similar output if we go to http://localhost:9292/goodbye.
require "lucid_http"
GET "/hello" # => "Hello, Roda!"
GET "/goodbye" # => "Goodbye, Roda!"
Roda's routing advantages are due to the fact that Roda stores the routes in a block, and not in a data structure. However, this design requires a trade-off, which is that it limits the ability to do route introspection, such as printing a list of possible routes. There are workarounds that allow for route introspection, but they require additional work to setup.
r.on
and r.is
In the previous section, we got our first taste of routing in Roda. Now let's explore the different match methods that Roda provides us out of the box.
In order to do it, we'll begin with a simple example Roda
blogging app (very original, right?). We want a /posts
route that returns the list of all of our
posts. We create this route using the r.on
match method. We add a variable to hold our data repository
which will just be a hash with all of our posts. From the block, we
return a string representing our post list and we just join them using
pipe characters for simplicity.
class App < Roda
do |r|
route .on "posts" do
r= {
post_list 1 => "Post[1]",
2 => "Post[2]",
3 => "Post[3]",
4 => "Post[4]",
5 => "Post[5]",
}
.values.join(" | ")
post_listend
end
end
If we now browse to http://localhost:9292/posts, we see our posts list.
If we browse to http://localhost:9292/posts/ or http://localhost:9292/posts/whatever, we see the same list. That's something we probably don't want in a real application, and we'll go over how to fix this in a little bit.
require "lucid_http"
GET "/posts"
body# => "Post[1] | Post[2] | Post[3] | Post[4] | Post[5]"
GET "/posts/"
body# => "Post[1] | Post[2] | Post[3] | Post[4] | Post[5]"
GET "/posts/whatever"
body# => "Post[1] | Post[2] | Post[3] | Post[4] | Post[5]"
r.on
tries to match each given
matcher ("posts"
in this case) to
the request. As the remaining path at the point for routing for
all three request starts with /posts
, the
match is successful.
Let's see how routing works in more complex situations, by assuming
we want the http://localhost:9292/posts/1
to return the post with id 1. To do so, we nest a call to r.is
inside the r.on "posts"
match block. r.is
is a match method similar to r.on
, but in addition to the matching done by
r.on
, it will also require that the path
has been completely consumed after handling the
matchers, in order for the match to be considered successful
(we'll explain what consumed means in a little bit).
In other words, r.on
is a
non-terminal match method, and r.is
is a terminal match method. A
non-terminal match method doesn't require the remaining
path to be completely consumed in order to successfully
match. A terminal match method requires the remaining
path to be completely consumed to successfully match.
So, if we add a sub-route that matches specifically the path /posts/1
, we can have it return the first
post.
class App < Roda
do |r|
route .on "posts" do
r= {
post_list 1 => "Post[1]",
2 => "Post[2]",
3 => "Post[3]",
4 => "Post[4]",
5 => "Post[5]",
}
.is "1" do
r[1]
post_listend
.values.map { |post| post }.join(" | ")
post_listend
end
end
And this would work as expected for the first post.
require "lucid_http"
GET "/posts/1"
# => "Post[1]" body
But this approach doesn't scale. In order to make it more flexible,
we can pass the Integer
matcher
to the r.is
match method.
The Integer
matcher operates
similarly to the String
matcher,
but instead of matching any path segment, it only matches segments that
consist solely of decimal characters (0-9). Like the String
matcher, on a successful match,
Roda will yield the matching path segment to the match
block. However, the Integer
matcher will convert the matching path segment to an Integer
before yielding it.
class App < Roda
do |r|
route .on "posts" do
r= {
post_list 1 => "Post[1]",
2 => "Post[2]",
3 => "Post[3]",
4 => "Post[4]",
5 => "Post[5]",
}
.is Integer do |id|
r[id]
post_listend
.values.map { |post| post }.join(" | ")
post_listend
end
end
When we try it out, we see only the post we requested. Note that if
we request a post that doesn't exist, post_list[id]
will be nil
, resulting in a 404
response. This is a desired behavior, so
that a request for a post that does not exist will result in a not found
response.
require "lucid_http"
GET "/posts/1"
# => "Post[1]"
body # => "200 OK"
status
GET "/posts/5"
# => "Post[5]"
body # => "200 OK"
status
GET "/posts/6"
# => ""
body # => "404 Not Found" status
Adding this r.is
call did not affect
other routes. The handling of /posts
,
/posts/
, and /posts/whatever
remain the same. However, it's
not generally a good idea to allow arbitrary routes to a resource. We
should probably change it so that /posts
returns all posts, but /posts/
and /posts/whatever
return 404
responses. We can make that change by
wrapping the returning of all posts in an r.is
block.
class App < Roda
do |r|
route .on "posts" do
r= {
post_list 1 => "Post[1]",
2 => "Post[2]",
3 => "Post[3]",
4 => "Post[4]",
5 => "Post[5]",
}
.is Integer do |id|
r[id]
post_listend
.is do
r.values.map { |post| post }.join(" | ")
post_listend
end
end
end
We can then check that this has the desired effect.
require "lucid_http"
GET "/posts"
body# => "Post[1] | Post[2] | Post[3] | Post[4] | Post[5]"
GET "/posts/"
# => ""
body # => "404 Not Found"
status
GET "/posts/whatever"
# => ""
body # => "404 Not Found" status
Going back to our example, it may seem kind of strange, as we are
calling r.is
with no matchers.
However, this will become natural as we use Roda. We can think
of matchers as filters. Roda's default behavior is
that a match method will match, unless there is a
matcher or other requirement causing it not to match. If there
are no matchers, then r.on
always
matches, and r.is
only matches if the
remaining path has already been completely consumed.
We've mentioned that the path can be consumed during the normal process of a request.
Now, what do we mean by consumed when talking about the
request path? Lets take a look at the path during a request-response
cycle. We'll add a couple of debugging statements at the beginning of the
route, just after r.on "posts"
, r.is Integer
and r.is
.
class App < Roda
do |r|
route p [0, r.path]
.on "posts" do
rp [1, r.path]
.is Integer do |id|
rp [2, r.path]
""
end
.is do
rp [3, r.path]
""
end
end
end
end
When we look at the output produced, we note that it doesn't change during one request cycle.
[0, "/"]
127.0.0.1 - - [07/Apr/2023:18:58:07 -0300] "GET / HTTP/1.1" 404 - 0.0012
[0, "/posts"]
[1, "/posts"]
[3, "/posts"]
127.0.0.1 - - [07/Apr/2023:18:58:13 -0300] "GET /posts HTTP/1.1" 200 - 0.0013
[0, "/posts/1"]
[1, "/posts/1"]
[2, "/posts/1"]
127.0.0.1 - - [07/Apr/2023:18:58:19 -0300] "GET /posts/1 HTTP/1.1" 200 - 0.0007
When matchers match against the path, they consume
the part of the path that they match against. But Roda doesn't
change the path of a request while routing (r.path
and r.path_info
remain constant).
To keep track of the path consumption, Roda stores the part
of the path that is yet to be consumed, calling it the remaining
path (available at r.remaining_path
).
Using this information and the the requests's path, Roda can
determine the part of the path that has already been routed, called the
matched path (available at r.matched_path
). As matchers
successfully match, they consume that part of the remaining
path (assuming they are matching against the path). The
remaining path gets smaller during routing, and if it is empty,
the remaining path has been fully consumed.
It's easier to understand how this works by showing the effect that matching has on the remaining path and the matched path. So let's output the remaining path and the matched path in different sections of the routing tree.
class App < Roda
do |r|
route p [0, r.matched_path, r.remaining_path]
.on "posts" do
rp [1, r.matched_path, r.remaining_path]
.is Integer do |id|
rp [2, r.matched_path, r.remaining_path]
""
end
.is do
rp [3, r.matched_path, r.remaining_path]
""
end
end
end
end
Here are the results for requests for /
, /posts
, and
/posts/1
, showing how the remaining
path is consumed by the matchers, and what the matched
path is at each step.
[0, "", "/"]
127.0.0.1 - - [19/Sep/2016:19:46:26 -0300] "GET / HTTP/1.1" 404 - 0.0016
[0, "", "/posts"]
[1, "/posts", ""]
[3, "/posts", ""]
127.0.0.1 - - [19/Sep/2016:19:46:36 -0300] "GET /posts HTTP/1.1" 200 47 0.0016
[0, "", "/posts/1"]
[1, "/posts", "/1"]
[2, "/posts/1", ""]
127.0.0.1 - - [19/Sep/2016:19:46:46 -0300] "GET /posts/1 HTTP/1.1" 200 7 0.0016
While most of the calls to match methods in previous
examples have used a single matcher, be aware that these
methods accept an arbitrary number of matchers. If we wanted to
show all the posts that were posted on some date, we could
capture the year, month and day, create a Date
object and pass it to a Post.posts_for_date
method.
# ...
.on "posts", "date", Integer, Integer, Integer do |year, month, day|
r= Date.new(year, month, day)
date = Post.posts_for_date(date)
posts
# ...
end
#...
The word capture here is tied to consume. In
Roda, when matchers consume a segment of the
path, they may also capture it and yield it to the match
block. In the above example, "posts"
,
"date"
, and Integer
are all matchers, and all of
them consume a segment of the path. However, the "posts"
and "date"
matchers do not capture the segments they
consume, but the Integer
matcher does. As there are three Integer
matchers that consume
a path segment, three arguments are yielded to the match
block.
Let's expand the earlier example, and focus on handling /posts/1/show
and /posts/1/show/detail
routes. They both display
the post, but the detail route includes information on the last accessed
time. As we need to handle routes under /posts/1
, we cannot use r.is Integer
, we need to switch to r.on Integer
, because we no longer want a
terminal match at this point in the routing tree.
Inside the r.on Integer
match
block, we find the post and store it in a local variable. Then we
call r.on "show"
. Inside r.on "show"
, we have two r.is
calls, one for the case where the
remaining path has been fully consumed, and another
for where the remaining path is "/detail"
.
require "roda"
class App < Roda
do |r|
route .on "posts" do
r# ...
.on Integer do |id|
r= post_list[id]
post
.on "show" do
r.is do
r"Showing #{post}"
end
.is "detail" do
r"Showing #{post} | Last access: #{Time.now.strftime("%H:%M:%S")}"
end
end
end
# ...
end
end
end
As a quick reminder, r.on
is used for
handling path branches in the routing tree, where there are multiple
paths to handle inside the branch. r.is
is
used for handling path leafs in the routing tree, where the
remaining path has been fully consumed by the routing
tree.
r.on
and r.is
are probably the most common match
methods in Roda. However, an HTTP request is not solely a request
for a path, it is a request for a path using a specific request method,
and the response should depend upon the request method used. We'll look
at how to handle request methods in the next section.
r.get
and r.post
Now that we know how to route requests for various paths, let's
discuss how to handle various request methods. For browsers, there are
only two request methods that we need to worry about, GET
and POST
. In
general, GET
is used for idempotent
requests such as navigating to a page, and POST
is used for forms that may modify
state.
In general, successfully handling an request should require routing
the full request path (consuming the entire remaining
path), as well as be specific to the request method. Roda
by default includes two match methods for handling requests for
specific request methods, r.get
for
handling GET
requests, and r.post
for handling POST
requests.
Both r.get
and r.post
have the same behavior other than the
request method they match against. If not passed any matchers,
both r.get
and r.post
operate as non-terminal match
methods. However, if they are passed any arguments, both r.get
and r.post
operate as terminal match methods. Why the difference in
behavior depending on whether arguments are passed? Well, in general
use, Roda's behavior, while seemingly inconsistent, does
exactly what we want.
In general, passing no arguments is used to check the request method
after the path has been fully routed using r.is
, in which case there is no reason to do a
duplicate check for a terminal match.
require "roda"
class App < Roda
do |r|
route .on "posts" do
r.is Integer do |id|
r.get do
r# Handle GET /posts/$ID
end
.post do
r# Handle POST /posts/$ID
end
end
end
end
end
Alternatively, if the request method is being checked before the path, to branch first by request method and then by path, we would not want a terminal match.
require "roda"
class App < Roda
do |r|
route .get do
r.on "posts" do
r.is Integer do |id|
r# Handle GET /posts/$ID
end
end
end
.post do
r.on "posts" do
r.is Integer do |id|
r# Handle POST /posts/$ID
end
end
end
end
end
GET
), and few routes use another request method.
However, when we pass matchers to r.get
and r.post
,
that is usually an indication that the remaining path to route
is only handled by a specific request method.
require "roda"
class App < Roda
do |r|
route .on "posts" do
r.on Integer do |id|
r.get "show" do
r# Handle GET /posts/$ID/show
end
.post "update" do
r# Handle POST /posts/$ID/update
end
end
end
end
end
If r.get
and r.post
did not do a terminal match if passed a
matcher, then we would need to wrap all such calls with r.is
require "roda"
class App < Roda
do |r|
route .on "posts" do
r.on Integer do |id|
r.get "show" do
r.is do
r# Handle GET /posts/$ID/show
end
end
.post "update" do
r.is do
r# Handle POST /posts/$ID/update
end
end
end
end
end
end
As shown above, that would just lead to redundant code. By making
r.get
and r.post
operate as terminal match
methods if passed any matchers, Roda increases
usability at the expense of consistency.
What if we want a r.get
or r.post
to use a terminal match? We would pass a
matcher that is always matches, which is true
.
require "roda"
class App < Roda
do |r|
route .on "posts" do
r.on Integer do |id|
r.get true do
r# Handle GET /posts/$ID
end
.is "manage" do
r.get do
r# Handle GET /posts/$ID/manage
end
.post do
r# Handle POST /posts/$ID/manage
end
end
end
end
end
end
So far, we've just discussed the GET
and POST
request methods. What about the
other request methods? In order to keep Roda small, only the
methods that browsers support (GET
and
POST
) are included by default. However,
Roda ships with an all_verbs
plugin that adds other request methods such as r.head
, r.put
,
r.patch
, and r.delete
for handling the other HTTP request
methods. The behavior for these methods is the same as r.get
and r.post
,
other than the request method they use as a filter.
require "roda"
class App < Roda
:all_verbs
plugin
do |r|
route .on "posts" do
r.is Integer do |id|
r.head do
r# Handle HEAD /posts/$ID
end
.get do
r# Handle GET /posts/$ID
end
.post do
r# Handle POST /posts/$ID
end
.put do
r# Handle PUT /posts/$ID
end
.patch do
r# Handle PATCH /posts/$ID
end
.delete do
r# Handle DELETE /posts/$ID
end
end
end
end
end
Additionally, if we want to treat HEAD
requests the same as GET
requests, except
omit the response body, we can use the head
plugin:
require "roda"
class App < Roda
:head
plugin
do |r|
route .on "posts" do
r.is Integer do |id|
r.get do
r# Handle HEAD /posts/$ID (response body will be empty)
# Handle GET /posts/$ID
end
.post do
r# Handle POST /posts/$ID
end
end
end
end
end
When hosting a public website, using the head
plugin is recommended, unless the
application is handling HEAD
requests
separately. Otherwise, web crawlers that use HEAD
will probably think the related pages no
longer exist, since a HEAD
request for
them would result in a 404
response.
r.root
So far, we've been looking at how to route requests for various paths and request methods. What about requests that are for the root path of a website?
If we take a look at the previous section, we can figure out one of
the ways Roda allows us to define it: using the r.get
match method, with an empty
string matcher (which match GET /
requests).
For now we'll just return a dummy string.
class App < Roda
do |r|
route .get "" do
r"Root Path"
end
.get "posts" do
r= (0..5).map {|i| "Post #{i+1}"}
posts .join(" | ")
postsend
end
end
When requesting the page, we get that string as the request body.
require "lucid_http"
GET "/"
# => "http://localhost:9292/"
path # => "Root Path" body
Now, this is a route that we'll end up writing for every single app
we work on, and the current syntax is not very pretty. Luckily,
Roda gives us a convenience match method for this
particular route called, r.root
.
Let's change this example to use it.
class App < Roda
do |r|
route .root do
r"Root Path"
end
# ...
end
end
If we make the same request again, the r.root
match method will match and the
result will be the same.
require "lucid_http"
GET "/"
# => "http://localhost:9292/"
path # => "Root Path" body
As mentioned above, r.root
is the same
as r.get ""
, not the same as r.is ""
. By design, it only matches GET
requests. If we would like to handle
requests for other HTTP request methods at the root path, we will have
to stick to r.is ""
. One advantage of
using r.root
is that it expresses our
intention more clearly.
Now, say that we want to be able to fetch a post using the /posts/:id
path. We need to add a route for
that. Notice that we changed the match method using the "posts"
matcher from r.get
to r.on
because now it's not handling a terminal route anymore. Inside r.on "posts"
, we have two routes, one that
displays all posts (for GET /posts/
, note
the trailing slash) and one that only displays the requested post (e.g.
for GET /posts/1
).
require "roda"
class App < Roda
do |r|
route # ...
.on "posts" do
r= (0..5).map {|i| "Post #{i}"}
posts
.get "" do
r.join(" | ")
postsend
.get Integer do |id|
r[id]
postsend
end
end
end
We can then check that everything works as expected.
require "lucid_http"
GET "/posts/"
# => "Post 1 | Post 2 | Post 3 | Post 4 | Post 5"
GET "/posts/1"
# => "Post 1"
Now, as explained earlier, passing an empty string to the r.get
match method is the same as using
r.root
. Let's make this change,
.on "posts" do
r= (0..5).map {|i| "Post #{i}"}
posts
.root do
r.join(" | ")
postsend
end
and see if it works here.
require "lucid_http"
GET "/posts/"
# => "Post 1 | Post 2 | Post 3 | Post 4 | Post 5"
Yes, that worked, even though we're not at the root of our application. Why is that? This has to do with the way matchers work in Roda. As it turns out, all of the match methods that ship with Roda and match on the remaining path, so any part of the path that has already been consumed will not be considered when matching.
OK, the r.root
match method
works here but, does it make sense to use the root abstraction in a
sub-route? While it's a matter of taste, in general it only makes sense
if we want to support routes with trailing slash, and only handle GET
requests for those routes. For the root
route, a trailing slash is forced and GET
is often the only request method used, but for all other routes, we
would have to go out of our way to design a path structure for our
application that used trailing slashes. In general, it's probably better
to avoid that. So instead of /posts/
and
/posts/1/
, we would use /posts
and /posts/1
. In that case, we would not use r.root
. Instead, we would use r.get true
.
In the previous sections, we looked at all of the matchers and match methods that are available by default with Roda. In this section, we'll write a simple custom match method.
As we've seen, the matchers and match methods that are available by default focus on matching on the remaining path or on the request method. While those are the primary parts of the request used for matching, there are cases when we may want to match on other parts of the request, or maybe on aspects unrelated to the request (such as the time or date).
Let's say we want to write a match method that expects a hash argument, and only matches if each key in the argument has a submitted parameter with a matching value.
require 'roda'
class App < Roda
do |r|
route .with_params "secret"=>"Um9kYQ==\n" do
rend
end
end
This code won't work because the r.with_params
match method doesn't
exist yet. Where should we define the r.with_params
method?, Well, in a previous
section, we learned that the r
variable is
an instance of a App::RodaRequest
, so we
can just add it to that class directly. However, we probably don't know
how to write the with_params
method
yet.
require 'roda'
class App < Roda
class RodaRequest
def with_params(hash, &block)
#
end
end
do |r|
route .with_params "secret"=>"Um9kYQ==\n" do
rend
end
end
First, we should decide how we want to write this method. Since the argument we passed in is a hash of expected parameters, and not a matcher, we wouldn't want to pass it to another method that expects a matcher. It's probably best to check whether the hash matches the expected parameters. If not, we don't have to do anything. If it does match, then we want to treat it as a match block.
It's probably helpful to know that the submitted parameters are
available by calling the params
method.
This method actually comes from Rack::Request
(remember that App::RodaRequest
descends from Rack::Request
). So a simple way to check if the
parameters match is to iterate the supplied hash, and return from the
method unless the value matches the value of submitted parameter.
def with_params(hash, &block)
.each do |key, value|
hashreturn unless params[key] == value
end
#
end
This only handles the case where the match fails. How do we handle a
successful match? In this case, if after the hash.each
call, we are still executing the
method, then we want to treat the given block as a match block.
This can be handled by passing the block to on
with no arguments (remember that on
is r.on
,
because r
is an instance of App::RodaRequest
).
def with_params(hash, &block)
.each do |key, value|
hashreturn unless params[key] == value
end
&block)
on(end
on
will treat the block as a match
block, passing control to it, and after the block executes, the
response will be returned.
Let's start with an example similar to one used in an earlier section.
require "roda"
class App < Roda
do |r|
route # ...
.on "posts" do
r= (0..5).map {|i| "Post #{i}"}
posts
.get true do
r.join(" | ")
postsend
.get Integer do |id|
r[id]
postsend
end
end
end
We'll make a small modification. We want the /posts/:id
route to render a string showing the
post name and the access time.
class App < Roda
do |r|
route # ...
.on "posts" do
r# ...
.get Integer do |id|
r= posts[id]
post = Time.now.strftime("%H:%M")
access_time
"Post: #{post} | Accessing at #{access_time}"
end
end
end
end
Now, when we access the post with an id of 2
, we get more information.
require "lucid_http"
GET "/posts/2"
# => "Post: Post 2 | Accessing at 09:53" body
In our attempt to add a feature, we've introduced a bug. What would happen if we looked up a post that doesn't exist? Let's try it out.
require "lucid_http"
GET "/posts/12"
# => "Post: | Accessing at 09:55" body
Oops, we're rendering an empty post name, which is far from desirable.
To fix this, we can add a conditional to handle the case where the post doesn't exist.
.get Integer do |id|
rif post = posts[id]
= Time.now.strftime("%H:%M")
access_time "Post: #{post} | Accessing at #{access_time}"
end
end
If the post exists, the if
expression
returns the last value, which is the string with the post and the access
time. If the post doesn't exists, then the if
expression returns nil
, and Roda will use a 404
response with an empty body.
require "lucid_http"
GET "/posts/12"
# => "404 Not Found" status
Or alternatively, we can skip the rest of the block using next
, just like in any Ruby block. next
is usually used to skip the current
iteration of the block and move to the next iteration, but as each route
block is only executed at most once, it has the equivalent behavior of
an early block return.
.get Integer do |id|
rnext unless post = posts[id]
= Time.now.strftime("%H:%M")
access_time "Post: #{post} | Accessing at #{access_time}"
end
As no argument is given to next
, it is
the equivalent of the block returning nil
,
so a request for a post that doesn't exist will result in a 404
response (the same as in the example above
using if
). However, we can provide an
argument to next
, which is the equivalent
of forcing an early return of the block using the argument.
.get Integer do |id|
rnext "No matching post" unless post = posts[id]
= Time.now.strftime("%H:%M")
access_time "Post: #{post} | Accessing at #{access_time}"
end
This does work, but because the block returned a string, it is treated as a successful response.
require "lucid_http"
GET "/posts/12"
# => "No matching post"
body # => "200 OK" status
To include a body but use a 404
response code, we need to set the response status code manually before
using next
.
.get Integer do |id|
runless post = posts[id]
.status = 404
responsenext "No matching post"
end
= Time.now.strftime("%H:%M")
access_time "Post: #{post} | Accessing at #{access_time}"
end
Then we can check that the expected body and status code are used.
require "lucid_http"
GET "/posts/12"
# => "No matching post"
body # => "404 Not Found" status
Due to Roda's design, we have full control over how routing works, which allows multiple ways to reduce duplication via approaches that are similar to metaprogramming.
Consider the following routing tree. Assume that the view
here returns a response body string based
on the argument given (view
is added by
the render
plugin, which we'll be
discussing later). This has a fair amount of duplication here, with each
of these r.get
lines looking similar.
do |r|
route .get("about") { view("about") }
r.get("contact_us") { view("contact_us") }
r.get("license") { view("license") }
rend
We can remove this duplication similar to how we remove other duplication in Ruby, by moving the repetitive code into a loop.
do |r|
route %w[about contact_us license].each do |route_name|
.get(route_name) { view(route_name) }
rend
end
Let's think about what's going on here. We created a bunch of routes
in one go at runtime. It might look like magic, or even
very advanced metaprogramming, but it isn't. It's just a each
call on an array. This is the kind of power
that Roda gives us. Note that I put emphasis on the words
at runtime. As with every route in Roda, that
loop won't get triggered at all if it's not on a branch we reach in our
routing tree.
We'll see later in the section on array matchers that there is a built-in way to simplify the loop above.
So far, we've discussed all of the match methods that Roda includes by default. However, we've only covered a few of the matchers that Roda includes by default. Let's first briefly discuss the matchers we have already been exposed to, followed by some new matchers.
The string matcher is the most common matcher. It matches against the next segment in the remaining path.
do |r|
route .get "posts" do
r# GET /posts
end
end
We can handle multiple segments in the same string if we include a slash.
do |r|
route .get "posts/today" do
r# GET /posts/today
end
end
Including a slash to handle multiple segments is basically a shortcut for using separate string matchers.
do |r|
route .get "posts", "today" do
r# GET /posts/today
end
end
When a string matcher is used, the segment it matches is consumed, including the preceding slash, but it is not captured (it will not yield arguments to the match block).
String matchers will match only complete segments, they are not simple prefix matches.
do |r|
route .on "posts" do
r# Requests for /posts and any path starting with /posts/
# Would not match /posts-today
end
end
A string matcher will not match if remaining path does not start with a slash.
Roda only supports two class matchers by default, both of
which we've seen previously, String
and
Integer
.
As a reminder, the String
matcher consumes and captures the next
non-empty segment in the remaining path. It will not match if
the remaining path does not start with a slash, or if the next
segment is empty.
do |r|
route .on "posts" do
r.on String do |seg|
r"0 #{seg} #{r.remaining_path}"
end
end
.on String do |seg|
r"1 #{seg} #{r.remaining_path}"
end
end
Here are some examples using different request paths.
require "lucid_http"
GET "/posts"
# => "404 Not Found"
status
GET "/posts/"
# => "404 Not Found"
status
GET "/posts/new"
# => "0 new "
body
GET "/posts/new/"
# => "0 new /"
body
GET "/posts/new/recent"
# => "0 new /recent"
body
GET "/topics"
# => "1 topics "
body
GET "/topics/"
# => "1 topics /"
body
GET "/topics/new"
# => "1 topics /new" body
The Integer
matcher
consumes and captures the next segment in the
remaining path if the segment only includes decimal characters
(0-9). When capturing, it converts the segment to an integer, so the
block argument it yields will be an integer.
do |r|
route .on Integer do |seg|
r"#{seg.inspect} #{r.remaining_path}"
end
end
Here are some examples using different request paths.
require "lucid_http"
GET "/"
# => "404 Not Found"
status
GET "/posts"
# => "404 Not Found"
status
GET "/1a"
# => "404 Not Found"
status
GET "/1"
# => "1 "
body
GET "/2/"
# => "2 /"
body
GET "/3/b"
# => "3 /b" body
While Roda only includes the String
and
Integer
class matchers by
default, it also ships with a class_matchers
plugin which allows using any
class as a class matcher. After loading the plugin, we need to
call class_matcher
with the class we want
to register, a regexp for the segment it should match, and a block that
accepts the regexp's captures and returns an array of captures to yield
to the match block on a successful match (which can be empty to
not yield anything).
Date, /(\d\d\d\d)-(\d\d)-(\d\d)/) do |y, m, d|
class_matcher([Date.new(y.to_i, m.to_i, d.to_i)]
end
do |r|
route .on Date do |date|
r.strftime('%m/%d/%Y')
dateend
end
This will take a date in year-month-day format and return the date in month/day/year format.
require "lucid_http"
GET "/2020-04-23"
# => "04/23/2020" body
We've already seen one of the boolean matchers, true
, usually used to force a terminal match
when using r.get
or r.post
. There are two other boolean
matchers, false
and nil
. As we might expect, similar to use in Ruby
conditionals, true
always matches, and
false
and nil
never match.
While there are reasons to use a literal true
in order to force a terminal match when
using r.get
or r.post
, there is no reason to use a literal
false
or nil
. However, they are supported so we can call
methods or access local variables that may return one of the values:
def allow?
.ip == '127.0.0.1'
requestend
def allowed_prefix
"let-me-in" if allow?
end
do |r|
route .on allowed_prefix do
r"Allowed #{r.remaining_path}"
end
.on allow? do
r"Also Allowed #{r.remaining_path}"
end
end
If the request comes from IP address 127.0.0.1
, then we will get the following
results:
require "lucid_http"
GET "/"
# => "Also Allowed /"
body
GET "/posts"
# => "Also Allowed /posts"
body
GET "/let-me-in"
# => "Allowed "
body
GET "/let-me-in/please"
# => "Allowed /please" body
If the request does not come from IP address 127.0.0.1
, then all requests will result in a
404
response, since allowed_prefix
will return nil
and allow?
will return false
.
Roda supports the use of regexps as matchers. Regexp matchers must match complete segments, and they consume the segments they match, and capture whatever the regexp captures (which could be nothing if the regexps do not have any captures).
do |r|
route .on /posts/ do
r# Same as "posts" string matcher
end
.on /posts/i do
r# Similar to a case insensitive string matcher
end
.on /(posts|topics)/ do |seg|
r# Match either of the two segments and yield the matched segment
end
.on /posts(?:\.html)/ do
r# Match with or without .html extension
end
.on /(\d\d\d\d)-(\d\d)-(\d\d)/ do |year, month, day|
r# Handle multiple captures (arguments yielded are strings)
end
end
Like string matchers, regexp matchers will not match if remaining path does not start with a slash.
Array matchers must contain other matchers, and will match if any of the matchers it contains match. Each member of the array is tried in order, and as soon as any member matches, the array matcher matches. If the member of the array that matches captures, then the captures are respected.
do |r|
route .get "posts", [Integer, true] do |id|
r# GET /posts/1 matches as:
# * the Integer matcher matches
# * the remaining path is fully consumed
# * id is 1
#
# GET /posts matches as
# * the true matcher matches
# * the remaining path is fully consumed
# * no argument is yielded (id is nil)
#
# GET /posts/new does not match as
# * the true matcher matches
# * but the remaining path is not fully consumed
end
.on [/members/, /topics/] do
r# Match either of the regexp matchers
end
end
One small inconsistency for better usability is that if the member matcher is a string, that string is yielded. This is an alternative approach to using a capturing regexp for multiple possible segments.
do |r|
route .on ['posts', 'topics'] do |seg|
r# Match either of the two segments and yield the matched segment
end
end
The behavior of a hash matcher depends on the keys of the
matcher. Each key should be a symbol registered as a hash
matcher. By default, there are two supported symbols, :all
and :method
.
:all
The :all
hash matcher should
have a value that is an Enumerable, and has the same behavior as the
arguments given to r.on
, r.is
, r.get
, and
r.post
, in that all members of the value
must match for the :all
hash
matcher to match. It is exposed as a separate matcher as it can be
combined with the array matcher.
do |r|
route .get ['post', {all: ['posts', Integer]}] do |id|
r# GET /post matches as
# * the first array member matches
# * the remaining path is fully consumed
# * no argument is yielded (id is nil)
#
# GET /posts/1 matches as
# * the first array member does not match
# * the second array member is then tried
# * the second array member is a :all hash matcher
# * all members of the :all hash matcher match
# * the remaining path is fully consumed
# * id is 1
#
# GET /posts/new does not match as
# * the first array member does not match
# * the second array member is then tried
# * the second array member is a :all hash matcher
# * the second member of the :all hash matcher does not match
end
end
:method
The :method
hash matcher
matches the HTTP request method. It can be used when we want to do a
non-terminal match while providing other arguments to r.on
.
do |r|
route .on "posts", method: :post do
r# POST requests for /posts or starting with /posts/
end
end
Additionally, we can provide an array to match any of the given request methods:
do |r|
route .on "posts", method: ['put', 'patch'] do
r# PUT or PATCH requests for /posts or starting with /posts/
end
end
As shown above, we can use symbols or strings, and the values are not case sensitive (they are always converted to uppercase).
While Roda only includes the :all
and
:method
hash matchers by default,
it also ships with a hash_matcher
plugin
that allows for setting up our own custom hash matchers.
In the earlier section on setting up custom match methods, we used this example.
require 'roda'
class App < Roda
do |r|
route .with_params "secret"=>"Um9kYQ==\n" do
rend
end
end
Let's change this to use a custom hash matcher instead of a
custom match method. For simplicity, instead of checking for
arbitrary parameters, we'll create a custom hash_matcher that
only handles a parameter named secret
.
require 'roda'
class App < Roda
do |r|
route .on(secret: "Um9kYQ==\n") do
rend
end
end
As before, this code doesn't work because we haven't added the secret
hash matcher. Let's do that now.
After loading the plugin, we need to call the hash_matcher
method with the related hash key
symbol, and a block that should return nil
or false
to not match, and any other value
to match.
require 'roda'
class App < Roda
:hash_matcher
plugin
:secret) do |v|
hash_matcher(['secret'] == v
paramsend
do |r|
route .on(secret: "Um9kYQ==\n") do
rend
end
end
The hash_matcher
plugin does not handle
capturing. However, we can manually add captures by appending
them to the request's captures
. For
example, if we wanted to yield the value of the key
parameter if the secret
parameter matches, we can do this:
require 'roda'
class App < Roda
:hash_matcher
plugin
:secret) do |v|
hash_matcher(if params['secret'] == v
<< params['key']
captures end
end
do |r|
route .on(secret: "Um9kYQ==\n") do |key|
rend
end
end
Roda ships with multiple plugins that add their own hash
matchers. path_matchers
includes :prefix
, :suffix
,
and :extension
hash matchers.
header_matchers
includes :header
, :host
,
:user_agent
, and :accept
hash matchers. param_matchers
includes :param
, :param!
,
:params
, and :params!
hash matchers.
Symbol matchers are identical to the String
class matcher. They are a
historical form not recommended in new Roda applications, as the String
class matcher is more intuitive
and less redundant.
do |r|
route .on :segment do |seg|
r# same as r.on String do |seg|
end
end
While it is recommended to use the String
class matcher instead of a
symbol matcher by default, Roda ships with a symbol_matchers
plugin that allows different
symbols to match different segments. Let's say we have many routes that
accept a username, and our application only allows usernames that are
alphanumeric and between 6 and 20 characters. We can use a custom symbol
matcher that we will be sure will only match if the username format is
valid.
require 'roda'
class App < Roda
:symbol_matchers
plugin
:username, /([a-z0-9]{6,20})/
symbol_matcher
do |r|
route .on :username do |username|
rend
end
end
The symbol_matchers
plugin also
includes some default symbol matchers, such as :d
for decimal segments (similar to the Integer
class matcher), :w
for alphanumeric segments, and :rest
for the rest of the remaining
path. All of these consume and capture the
matching segment (or rest of the remaining path in the case of
:rest
).
Roda allows us to use Ruby procs as matchers. The
proc will be called, and if it returns nil
or false
, the matcher will not
match. If it returns any other value, it will match. We can use proc
matchers as a substitute for using conditionals.
.get Integer do |id|
r= posts[id]
post .on(proc { post }) do
r= Time.now.strftime("%H:%M")
access_time
"Post: #{post} | Accessing at #{access_time}"
end
end
The code still works as it did in the section describing conditionals.
require "lucid_http"
GET "/posts/2"
# => "Post: Post 2 | Accessing at 10:09"
body
GET "/posts/10"
# => "404 Not Found" status
This approach makes the code more complex and doesn't add value, only complexity. In general, we would only want to use proc matchers if we have a proc from an external source that could return an arbitrary value, and we want to use it for matching.
By default, if we use any other value as a matcher, Roda
will raise a Roda::RodaError
. This is to
prevent undefined behavior when an unexpected matcher type is used.
However, if you want to support other types of matchers, you
can use the custom_matchers
plugin. This
plugin adds support for using arbitrary objects as matchers, as
long as the Roda application has been configured to accept
them.
For example, if you want the application to accept Set
instances as matchers, and have
them match any value in the set (similar to how the array matcher works,
but without the special behavior for string values), you can load the
custom_matchers
plugin and call the custom_match
class method to register a matcher
for Set
instances, using a block to
determine how these Set
instances should
be matched. Then you can use Set
instances
as matchers. Any captures added by the block you pass to the
custom_matcher
method are yielded to the
match block that you passed when calling the match
method with a Set
matcher.
require 'set'
class App < Roda
:custom_matchers
plugin
Set) do |matcher|
custom_matcher(.any?{|v| match(v)}
matcherend
= Set.new([/(a)(\d+)/, /(b)(\w+)/, /(c)(\h+)/])
set
do |r|
route .on set do |prefix, id|
rcase prefix
when 'a'
# ...
when 'b'
# ...
when 'c'
# ...
end
end
end
end
RodaRequest
methodsr.redirect
Say we actually want to render the post list when we browse to the
root path. We could definitely copy and paste the code we have on the
posts
routing block. However, we don't
like code duplication, and try to avoid it when possible.
class App < Roda
do |r|
route .root do
r= (0..5).map {|i| "Post #{i}"}
posts .join(" | ")
postsend
.get "posts" do
r= (0..5).map {|i| "Post #{i}"}
posts .join(" | ")
postsend
end
end
What we can do instead is redirect to that path. We do so by calling
the conveniently named r.redirect
method.
class App < Roda
do |r|
route .root do
r.redirect "/posts/"
rend
.get "posts" do
r= (0..5).map {|i| "Post #{i}"}
posts .join(" | ")
postsend
end
end
Let's try this out. We request the root route from our browser. Immediately, the browser forwards to the post list URL.
The only difference from the client's point of view is that now,
instead of returning a 200
status, we're
returning a 302
, corresponding to a
redirect.
require "lucid_http"
GET "/"
# => "http://localhost:9292/"
path # => ""
body # => "302 Found" status
If we follow the redirect, we see that we're rendering the desired list.
require "lucid_http"
GET "/", follower: :follow
# => "http://localhost:9292/"
path # => "Post 1 | Post 2 | Post 3 | Post 4 | Post 5"
body .to_s # => "200 OK" status
In general, if we want to redirect to a different path, we would give
a separate path to r.redirect
. However, if
we follow a URL design such that a GET
request for a path shows a form to submit to the same path via POST
, and after a POST
we want to perform a GET
request to the same page to see the current
state of the page after the update, Roda allows us to omit the
path.
class App < Roda
do |r|
route .is "posts", Integer do |id|
r@post = Post[id]
.get do
r@post.inspect
end
.post do
r@post.update(updated_at: Time.now)
.redirect
rend
end
end
end
By default, r.redirect
will use a 302
status code. We can specify an explicit
status code when redirecting by using a second argument.
class App < Roda
do |r|
route .root do
r.redirect "/posts/", 303
rend
.get "posts" do
r= (0..5).map {|i| "Post #{i}"}
posts .join(" | ")
postsend
end
end
If we want to change the default status when redirecting to 303
, we can use the status_303
plugin.
r.halt
Roda allows returning a response at any point during
routing. This is necessary because of Roda's design, so that
whenever a match block exits, the response is returned. To stop
(or halt) processing a request, we can call r.halt
at any point in our routing tree. By
default, we would call r.halt
with no
arguments, which will use the existing response. So we can set the
status, headers, or body of the response, and then call r.halt
to halt request processing and return the
response.
do |r|
route .get "posts" do
rif r.params['forbid']
.status = 403
response.headers['My-Header'] = 'header value'
response.write 'response body'
response.halt
rend
# not reached if forbid parameter submitted
end
end
While r.halt
uses the request's current
response by default, we can pass a rack response to r.halt
to have it use the given response instead
of the request's current response. A rack response is an array with 3
elements, status code (integer), headers (hash), body (array of
strings).
do |r|
route .get "posts" do
rif r.params['forbid']
.halt [
r403,
{
'Content-Type'=>'text/html',
'Content-Length'=>'13',
'My-Header'=>'header value',
},
['response body']
]
end
# not reached if forbid parameter submitted
end
end
The default behavior of r.halt
is to
only support using the current response (no argument) or the given rack
response (1 argument that is an array of 3 elements). Roda also
ships with a halt
plugin that expands
r.halt
to partially modify the current
response before returning it.
If we load the halt
plugin, we can call
r.halt
with a integer to change the
response status code before returning.
.halt 403 r
Or call r.halt
with a string to update
the response body before returning.
.halt 'response body' r
Or call r.halt
with 2 arguments to
change the response status code and update the response body before
returning.
.halt 403, 'response body' r
Or call r.halt
with 3 arguments to
change the response status code, update the response headers, and update
the response body before returning.
.halt(403, {'My-Header'=>'header value'}, 'response body') r
The difference between calling r.halt
with 3 arguments and with a single rack response (array of 3 elements)
is that the rack response will be returned directly, where using 3
arguments will update the request's current response's headers and
response body before returning the request's current response.
r.run
Roda has support for directly calling other rack applications at any point during routing. This allows for taking any rack application (including another Roda application) and mounting it inside in the Roda application, generally under a subpath.
Let's say we have already developed an administrative front end to
our application as a separate rack application. We want to mount that
application under the /admin
route of the
current application. We would setup a branch for /admin
, and any requests for that branch would
be sent to the admin application, and the admin application's response
would be returned as the response.
do |r|
route .on "admin" do
r.run AdminApp
rend
# rest of application
end
RodaResponse
Earlier, we discussed that if we have a Roda application
(subclass of Roda
) named App
, then Roda will automatically setup
App::RodaRequest
to be the class of App
's requests. Likewise, Roda will
automatically setup App::RodaResponse
to
be the class of App
's responses. Just as
App::RodaRequest
is a subclass of Roda::RodaRequest
, App::RodaResponse
is a subclass of Roda::RodaResponse
. The reason for creating the
response subclass is the same as the reason for creating the request
subclass, so that plugins can offer custom response behavior.
A Roda::RodaResponse
instance is
simpler than a Roda::RodaRequest
instance.
There are a lot fewer methods added. A Roda::RodaResponse
instance has accessors for
the status
(response status code integer),
headers
(response headers), and body
(response body). In the section on r.halt
, we saw that we can set the status for a
response and set a header in the response. We can also replace the
response body, keeping in mind that the response body must be a valid
rack response body (an object that response to each
and yields strings).
do |r|
route .status = 403
response.headers['My-Header'] = "header value"
response.body = ["response body"]
responseend
Roda::RodaResponse
has a few helper
methods. It supports getting and setting the headers using the array
reference operator:
do |r|
route ['Other-Header'] = response['My-Header']
responseend
As shown in the r.halt
section, it
supports writing to the body. However, if the body is written to
manually, Roda will ignore the result of blocks and will use the already
written body when returning a response.
do |r|
route .write 'response body'
response'ignored'
end
Roda::RodaResponse
also supports a
redirect
method for setting the location
to redirect and an optional status (302
by
default). Note that we don't generally call redirect
directly on the response. It is usually
called on the request, where it has the same behavior (as it calls redirect
on the response) but also halts request
processing after.
do |r|
route .is 'old-path'
r.redirect '/new-path' # 302 status used
responseend
.is 'other-old-path'
r.redirect '/other-new-path', 303
responseend
end
route
block
scopeNow that we've talked about the request and response, we can discuss
the other major object we'll be doing with in Roda, which is
the scope of the route
block. The route
block is executed in the context of a new
instance of the Roda app class.
class App < Roda
do |r|
route self.class # App
end
end
So for the Roda application given above (App
), the route block scope is an instance of
App
. By design, the Roda
class (and therefore, the App
class) has few public instance methods, so
that the scope of the route block will not be polluted. There are some
internal methods prefixed with _roda_
, but
other than those, there are only a few methods added:
request
is the request object
(instance of App::RodaRequest
).response
is the response object
(instance of App::RodaResponse
).opts
is the class options (we'll
discuss this in the next section).env
is the rack environment hash (same
as request.env
).session
is the current session (same
as request.session
).Roda
classNow that we know the basics of what happens at the instance level,
let's discuss the Roda
class itself.
app
, the
rack applicationAs we've seen in earlier examples, Roda
can operate as a rack application. This is necessary so that we can use
run Roda
(or run App
if our Roda
subclass is App
) in config.ru
.
However, while Roda
can operate as a rack
application, what actually happens is that Roda
creates a rack application internally, and
then if it is called as a rack application, it passes the request
environment to the actual rack application. It is faster to skip this
step, and run the underlying rack application directly. We can access
the underlying rack application using app
.
So the config.ru
file should be changed
to:
require "./app"
App.app run
There is no difference in behavior if we leave off the .app
, but adding the .app
will speed our application up slightly.
freeze
, to prevent unexpected modificationRoda applications are recommended to be frozen in production
and when testing, so that if something accidentally tries to change the
Roda application in an unexpected way, it will fail. For
development, we should also freeze the Roda application unless
we expect to be modifying it. Certain code reloading libraries depend on
modifying the class. However, rerun
does
not, so if we are using rerun
in
development, we can freeze the application in all cases. Often the
freezing is done in the config.ru
file.
require "./app"
App.freeze.app run
If we want to freeze the application except during development, we
can check the RACK_ENV
environment
variable.
require "./app"
unless ENV["RACK_ENV"] == "development"
App.freeze
end
App.app run
opts
, the class and plugin optionsInstead of storing state in multiple instance variables,
Roda stores all the class level state in a single hash, which
we can access via opts
. Plugins that need
to handle state generally store their state in opts
as well.
There are a few options we can consider setting when creating a Roda application, as they effect the behavior of Roda itself or multiple plugins that ship with Roda.
:root
sets the root path to the
application in the file system. It is used for setting default paths for
various parts of the application. It defaults to the current working
directory of the process, so if our Roda application is being
run from a different directory, we should definitely set this.:freeze_middleware
freezes each
middleware in use when building the rack application. We should only use
this option if we are sure all middleware in use will work correctly
when frozen.:add_script_name
will prepend the
SCRIPT_NAME
from the request environment
when constructing absolute links and URLs. This should be set if we are
running our Roda application from a subpath instead of from the
root path.plugin
, to
load pluginsAs we've shown earlier, plugin
is used
to load plugins into the Roda application. Some plugins do not
accept arguments:
class App < Roda
:h
plugin :flash
plugin end
Many plugins can be loaded without arguments, but will accept an options hash for arguments:
class App < Roda
:render
plugin :render, escape: true
plugin end
Few plugins require arguments:
class App < Roda
:request_aref, :raise
plugin :match_affix, "", /(?:\/\z|(?=\/|\z))/
plugin end
We can pass a block when loading a plugin. In general, we should only use this if the plugin expects to be passed a block, as otherwise the block will be ignored (the default behavior in Ruby):
class App < Roda
:not_found do
plugin "File Not Found"
end
:error_handler do |e|
plugin "Internal Server Error"
end
end
route
, to set the route blockAs earlier examples have shown, route
sets the route block to use. What we haven't discussed directly yet is
that the route
block is also treated as a
match block. Just like any match block, if the
response body has not been written to, and the return value of the route
block is a string, it is used as the
response body. So if we want to use the same response to all requests,
we don't need to use a separate r.on
call,
we can just have our route block return the value.
class App < Roda
do |r|
route "Response body for all requests"
end
end
Internally, for performance reasons, Roda uses the block we
pass to route
and creates an instance
method from it, which is called when the rack application is called. If
we want to access the route
block for some
reason, we can use route_block
.
While we can load middleware in config.ru
via use
,
Roda also supports use
to load middleware.
This can be useful if the Roda application depends on the
middleware in some way, and it can be used by multiple or arbitrary
config.ru
files.
require 'logger'
class App < Roda
Rack::CommonLogger, Logger.new($stdout)
use end
By default, middleware are inherited by subclasses, but we can turn
this off by setting inherit_middleware
to
false. Additionally, if we want to clear the middleware stack, we can
use clear_middleware!
. For more advanced
middleware handling, such as removing particular middleware or inserting
middleware before or after other middleware, we can use the middleware_stack
plugin.
Be aware that rack middleware work differently from plugins. Each rack middleware we use has a performance cost, as each rack middleware wraps the application. So if we have 3 rack middleware loaded in the application, the first middleware executes, then the second, then the third, before finally being dispatched to the application. In cases where both rack a middleware and Roda plugin can handle the same need, it is usually better for performance to use the Roda plugin.
So far, we've learned how to take a client's request and handle routing for it. What if we wanted to pass data along with the request? Roda allows us to receive data from a client in several ways. The first one we'll explore is one we've already seen: passing information in the request path.
Variable data in the request path often serves a dual role: we want to route requests based on the structure of the path, but we also want to capture parts of the path for later use.
Let's use an example where we are matching on a request with 3 segments. The first segment is posts, the second is some numeric ID, and the third is some string that represents an action. In the match block, we'll return the inspect values of ID and the action to see what we got.
class App < Roda
do |r|
route .get "posts", Integer, String do |id, action|
r"#{id.inspect} - #{action.inspect}"
end
end
end
If we try to access the route, we see that it extracts the ID segment as an integer, and the action segment as a string:
require "lucid_http"
GET "/posts/1/show"
# => "1 - \"show\""
body
GET "/posts/2/update"
# => "2 - \"update\"" body
In the above example, we have three segments, handled by a single
r.get
statement. However, in many cases,
it is preferable to use a separate match block for each
segment, as we may have other routes at each level of the tree.
Let's add a routing API for a group of models. First, we have a route that will match a model name and pass it to the block. We only want to match an allowed model name, as allowing any name could result in a name being passed in that we do not expect. We should consider all forms of input into our application as a possible method of attack, and limit the allowed input whenever possible. We'll limit the model names using an array, though in general for a large number of models we would probably want a more efficient approach. If we remember from the section about array matchers, when the member that matches is a string, that string is both captured. We'll use that to find which string was submitted.
One thing to note about the array of models is that we defined it as
a local variable in the App
class, and
reference it inside the route
block. We
could have inlined it in the call to r.on
,
or used a local variable inside the route
block, but that would cause additional work for every request. We could
also have used a constant instead of a local variable. That would work
fine, but in general a local variable is a simpler approach, and we
should only use a constant if we need to.
Inside the match block, we will take the model's name and
find the appropriate model class. This uses Object.const_get
, which takes a string and
returns the class for the string. Object.const_get
is something that should only
be called with trusted input, which is why we have made sure to limit
the class names that are allowed. Notice that as soon as we know the
model name, we were able to use it. We don't have to wait until the path
is fully routed in order to get a reference to the model. This allows us
to use this model in any of the nested routes, and that's what we
usually want.
class App < Roda
= ["account", "post"]
models
do |r|
route .on models do |model_name|
r= Object.const_get(model_name.capitalize)
model_class
# ...
end
end
end
Then we have the index
route, that will
match GET
requests for /post/index
, and will return the list of
posts.
class App < Roda
= ["account", "post"]
models
do |r|
route .on models do |model_name|
r= Object.const_get(model_name.capitalize)
model_class
.get "index" do
r.all.join(" | ")
model_classend
end
end
end
If we got to here, but the remaining path wasn't /index
, or the request method wasn't GET
, we skip that block, and try to match to the
model ID. If the next segment is numeric, we can assume that is the
model ID. That means that we're interested in doing something with a
particular instance of the previously selected model, so the next
logical step would be to access that particular instance.
class App < Roda
= ["account", "post"]
models
do |r|
route .on models do |model_name|
r= Object.const_get(model_name.capitalize)
model_class
.get "index" do
r.all.join(" | ")
model_classend
.on Integer do |id|
r= model_class[id]
model
# ...
end
end
end
end
Finally, once we have the appropriate object on our hands, if it is a
GET
request for the show
action, we can either display the model
information. displ. If it is a POST
request for the update
action, we can
update the model instance (in this case we'll just return that we are
updating it without actually doing so).
class App < Roda
= ["account", "post"]
models
do |r|
route .on models do |model_name|
r= Object.const_get(model_name.capitalize)
model_class
.get "index" do
r.all.join(" | ")
model_classend
.on Integer do |id|
r= model_class[id]
model
.get "show" do
r.to_s
modelend
.post "update" do
r"Updating #{model}"
end
end
end
end
end
This is a contrived example, but it illustrates how to go about designing a routing tree when we are routing by path. If possible, we should include request data in the path when we need it for routing.
We can also pass information to a request using the query string. The
query string is a string that goes at the end of the URL, after a
question mark, and it consist of a series of key=value
pairs separated by ampersands.
Here's an example: we set the post
parameter to have a value of 42
and the
action
parameter to have a value of show
http://localhost:9292?post=42&action=show
This kind of approach is commonly used to pass data when submitting a
form using the GET
request method. One of
the more common uses is to pass search terms to a search feature. Let's
assume we have an array of articles and we want to perform a search
based on a q
parameter passed through the
query string.
We add a route to match GET
requests
for the /search
path, and then we're ready
to perform our search. Now we can take a look at the r.query_string
instance method on the request
just by returning it as the response body (the query_string
method comes from Rack::Request
).
class App < Roda
do |r|
route .get "search" do
r.query_string
rend
end
end
When we browse to http://localhost:9292/search?q=article,
we see that r.query_string
returns the
actual string.
require "lucid_http"
GET "/search?q=article"
# => "q=article" body
We could parse it by hand and extract each key and value, but there
is a helper method that already does that, r.params
(which also comes from Rack::Request
)
class App < Roda
do |r|
route .on "search" do
r.params.inspect
rend
end
end
When we browse to http://localhost:9292/search,
passing q
as an argument to the query
string, we see the parsed parameters as a hash on the terminal. If we
add another attribute to the query string, for example, we see the new
hash with both keys and their respective values.
require "lucid_http"
GET "/search?q=article"
# => "{\"q\"=>\"article\"}"
body
GET "/search?q=article&category=video"
# => "{\"q\"=>\"article\", \"category\"=>\"video\"}" body
Now, we can actually populate our route. We search through the
articles for the ones that include the value of the q
attribute on the request. Finally, we join
them in order to return a string.
class App < Roda
ARTICLES = [
"This is an article",
"This is another article",
"This is a post",
"And this is whatever you want it to be",
]
do |r|
route .on "search" do
rARTICLES.filter do |article|
.include?(r.params["q"])
articleend.join(" | ")
end
end
end
When we search for the article
keyword,
we get all the articles that include the text article
.
require "lucid_http"
GET "/search?q=article"
# => "This is an article | This is another article" body
Former Rails developers might be wondering if we can fetch
parameters using symbols, and the answer is no. The r.params
method returns a Ruby hash, and hashes
differentiate between symbols and strings. This is another example of
how Roda is non-intrusive. It mostly uses Ruby core classes and
does not modify their behaviors to suit its needs. So if we know Ruby
and how Ruby's core classes work, we should find Roda easy to
understand.
Unfortunately, there is a small problem with the above example. What
happens if we go to the search page directly, without specifying the
q
parameter? Well, if we try it, we'll see
that it raises a TypeError
, because r.params["q"]
is nil
, and String#include?
doesn't accept nil
as an argument. There is a similar issues if
the q
parameter is parsed as an array or
hash (both are possible).
There are a couple of approaches to fix this. One is to just convert
r.params["q"]
to a string. This has the
advantage that it handles all input without errors. However, it will
result in going to the search page without specifying the q
parameter as showing all articles, since nil
converts to the empty string, and every
string includes the empty string.
class App < Roda
do |r|
route .on "search" do
rARTICLES.filter do |article|
.include?(r.params["q"].to_s)
articleend.join(" | ")
end
end
end
An alternative approach would be using a standard Ruby conditional, such as a case statement.
class App < Roda
do |r|
route .on "search" do
rcase q = r.params["q"]
when String
ARTICLES.filter do |article|
.include?(q)
articleend.join(" | ")
else
"Invalid q parameter"
end
end
end
end
Another way of handling data submitted in the request is when
submitting a form via the POST
method.
When browsers submit forms via POST
,
instead of including data in the query string, they include the data in
the request body.
We'll add a small example for this one. We want to route a POST
request to http://localhost:9292/articles
and accept a content
parameter with the
article to create.
We get a 404
status because this route
doesn't exist yet.
require "lucid_http"
POST "/articles", form: {content: Time.now.strftime("%H:%M:%S") }
# => ""
body # => "404 Not Found" status
To handle this route, we'll use r.post
with the "articles"
matcher.
Inside the match block, we need to we extract the data from the
request. We can do it the same exact same way we did for the search
feature, by using the r.params
method.
r.params
merges the parameters from the
query string with the parameters from the request body, which is why it
works in both cases.
If the route matches, we can add the content parameter submitted in the request as a new article. We can then return a string showing the last article (which should the one we just added unless there was another request between the execution of the two lines), and a count of all the existing ones.
.post "articles" do
rARTICLES << r.params["content"]
"Latest: #{ARTICLES.last} | Count: #{ARTICLES.count}"
end
Now, when we try it, we see our new articles being appended.
require "lucid_http"
POST "/articles", form: {content: Time.now.strftime("%H:%M:%S")}
# => "Latest: 12:13:38 | Count: 5"
sleep 2
POST "/articles", form: {content: Time.now.strftime("%H:%M:%S")}
# => "Latest: 12:13:40 | Count: 6"
This example has the same issues as the previous example, in that
submitting a form with no content
parameter would result in unexpected behavior (in this case, nil
added to the array). We would want to make
sure that r.params["content"]
is a string
before adding it as an article.
We mentioned earlier that r.params
combines the query string parameters and the request body parameters.
While there is not a general reason to access them separately, if we
would like to do so, we can use r.GET
for
the query string parameters and r.POST
for
the request body parameters (both of these methods come from Rack::Request
).
Roda lies on rack's parsing of the body, and as such it only
handles parsing of request content types that rack itself supports,
including application/x-www-form-urlencoded
and multipart/form-data
. HTTP supports other content
types for requests, and a request content type that is commonly used is
application/json
, for JSON requests.
Roda can handle these requests if we use the json_parser
plugin.
The final way to access request data is as part of the request
headers. The built-in way to access the request headers is through the
request environment hash (env
). The
request environment hash includes all headers, but the rack
specification requires that the header keys be converted to uppercase,
have dashes converted to underscores, and have HTTP_
prepended (except for exceptions such as
Content-Type
and Content-Length
). So if we want to access the
My-Foo
header, we would use env["HTTP_MY_FOO"]
.
If we don't want to remember all of the conversion rules, we can use
the request_headers
plugin, which allows
us to use r.headers['My-Foo']
.
Roda has a philosophy of being un-opinionated out of the box. If we followed along up to this point, we already know probably 95% of core Roda.
However, Roda is much more than just core Roda. In terms of lines of code, core Roda less than 15% the size of Roda. The remaining 85% of Roda is implemented via plugins. Some plugins are used by most applications. Other plugins are used in some types of applications (such as APIs) but not in others. Other plugins have specialized uses and will not be needed by most applications. This section will cover all of the Roda plugins that are commonly used in applications, as well as many of the plugins that have application-specific usage. It will not cover most of the plugins that have specialized uses.
To find the list of plugins, we can go to the Roda site at http://roda.jeremyevans.net/ and click on the Docs link. Here we can find a list of plugins. Clicking on any of the plugins will take us to a RDoc-generated page with detailed information on that plugin.
We'll start our discussion of plugins with plugins focused on rendering, or generating response bodies for requests.
public
We'll start our review of rendering plugins with the public
plugin. On the top of its documentation
page, we see a title, followed by a discussion of the plugin itself.
This discussion usually contains enough information for basic plugin
use. However, if we need more information, after this section there is a
section for classes and modules. That will have a link to each of the
plugin's modules, so we can see what methods it adds to the scope,
request, and response (or their classes). After that section it lists
the constants the plugin defines, which can generally be ignored.
Following that is a section on public class methods, which will document
the plugin's configure
method if it has
one. The configure
method is called when
the plugin is loaded, to configure the Roda application based
on the arguments we passed when loading the plugin.
If we want to see how any plugin is implemented, either out of
curiosity or in order to debug an issue, we can find the source code for
any of them in Roda's GitHub
repository, in the lib/roda/plugins/<plugin name>.rb
file.
Let's try the public
plugin out, so we
can see it in action. We have an app without any routes.
class App < Roda
do |r|
route end
end
With this Roda application, any request we make will result
in a 404
status. We'll try http://localhost:9292/dave.html
and confirm it doesn't currently exist.
require "lucid_http"
GET "/dave.html"
# => "404 Not Found" status
Now, we will see how behavior changes when we load and use the public
plugin. As mentioned earlier in the
section on core Roda, to add a plugin, we need to invoke the
plugin
class method inside our app class
and pass the plugin name (:public
in this
case) as a symbol.
Notice that we didn't have to add any new gems to our project, since
this is one of plugins that ships with Roda, and the public
plugin does not have any external
dependencies other than rack
(which
Roda itself depends on). We also didn't need to add a require
statement. Roda automatically
requires the plugin file based on the name of the plugin.
The public
plugin adds an r.public
method. When we call the r.public
method inside the route
block, it will serve requests for static
files inside a specific directory if they exist.
class App < Roda
:public
plugin
do |r|
route .public
rend
end
By default, the public
plugin will
serve files from the directory <app root>/public
(where <app root>
is the applications :root
option or the current working directory).
Let's create the public
directory and add
some files to it.
Dir.mkdir("public")
Dir.chdir("public")
%w[chris dave matt pete].each_with_index do |doc_name, i|
= i + 9
doc_num File.write("#{doc_name}.html", <<CONTENT)
<h2>My name is #{doc_name.capitalize} <h2>
<h3>and I'm ##{doc_num}</h3>
CONTENT
end
end
Now that we have added files to the public
directory, we retry http://localhost:9292/dave.html,
and we see that now we get a rendered page and a 200
status.
require "lucid_http"
GET "/dave.html"
# => "<h2>My name is Dave <h2>\n<h3>and I'm #10</h3>\n"
body # => "200 OK" status
If we want to serve files from the static
directory instead of the public
directory, we need to set the :root
option when loading the plugin.
class App < Roda
:public, root: "static"
plugin
do |r|
route .public
rend
end
Now, when the app is restarted, the configuration for the plugin
changed. Instead of looking into the public
directory, it will look into static
. When we make this change, the
application will return a 404
status
because the directory doesn't exist yet.
require "lucid_http"
GET "/dave.html"
# => ""
body # => "404 Not Found" status
When we rename the public
directory to
static
,
File.rename('public', 'static')
and retry, it works again.
require "lucid_http"
GET "/dave.html"
# => "<h2>My name is Dave <h2>\n<h3>and I'm #10</h3>\n"
body # => "200 OK" status
One nice feature of using the public
plugin is that we can serve files from a subpath. So if we wanted to
serve files from the static
directory, but
only want to check the static
directory
for files if the path starts with /static/
, we can call r.public
inside an r.on "static"
block.
class App < Roda
:public, root: "static"
plugin
do |r|
route .on "static" do
r.public
rend
end
end
If we try the same request, it'll return a 404
status because the directory doesn't exist
yet. However, if we prefix the request with /static
, it will return the content.
require "lucid_http"
GET "/dave.html"
# => ""
body # => "404 Not Found"
status
GET "/static/dave.html"
# => "<h2>My name is Dave <h2>\n<h3>and I'm #10</h3>\n"
body # => "200 OK" status
Another nice feature of the public
plugin is that it has built-in support for serving files already
compressed with gzip or brotli if the request indicates that gzip or
brotli transfer encoding is supported. For file types that are not
already compressed or encrypted, this generally can increase performance
by decreasing the amount of bytes needed to transfer the file. Because
it works on files that have already been compressed with gzip or brotli,
and doesn't compress or decompress files at runtime, this doesn't
require additional processing work.
Let's create a gzipped version of the file, and remove the original version of the file.
require 'zlib'
Zlib::GzipWriter.open('static/dave.html.gz') do |gz|
.write(File.read('static/dave.html'))
gzend
File.delete('static/dave.html')
Then we change the plugin options to use the :gzip
option.
class App < Roda
:public, root: "static", gzip: true
plugin
do |r|
route .on "static" do
r.public
rend
end
end
Then we can check that requests for the file still work correctly, even though only the gzipped version exists.
require "lucid_http"
GET "/static/dave.html"
# => "<h2>My name is Dave <h2>\n<h3>and I'm #10</h3>\n"
body # => "200 OK" status
Note that in a production setting, we'll want to keep both the compressed and uncompressed file, so that we can still serve files to clients that do not accept gzip encoding.
multi_public
The public
plugin is great if you keep
all static files your application serves in a single directory. However,
there may be reasons you would like to store different types of files in
different directories. For example, maybe you want to make sure that
certain files are only available to admins, while other files are
available to everyone. Roda supports multiple separate
directories for static files using the multi_public
plugin. This plugin is loaded
similar to the public
plugin, except that
you pass in a hash of directory information. The keys of the hash you
use in calls to the r.multi_public
method,
and the values of the hash are paths to the directory to serve.
Let's create an admin_static
directory,
and add a dave.html
file to it.
Dir.mkdir("admin_static")
File.write("admin_static/dave.html", <<CONTENT)
<h2>My admin name is Evad<h2>
CONTENT
end
end
Then we can modify our Roda app to use the multi_public
plugin, serving the existing static
directory under the /files
path for all requests, and serving the
new admin_static
directory under the /admin/files
path for admin requests. For
simplicity in this example, we'll consider all requests as admin
requests.
class App < Roda
:multi_public,
plugin everyone: 'static',
admin: 'admin_static'
do |r|
route .on "files" do
r.multi_public(:everyone)
rend
if admin?
.on "admin", "files" do
r.multi_public(:admin)
rend
end
end
def admin?
true
end
end
Then we can check that requests for both types of files can work correctly.
require "lucid_http"
GET "/files/dave.html"
# => "<h2>My name is Dave <h2>\n<h3>and I'm #10</h3>\n"
body # => "200 OK"
status
GET "/admin/files/dave.html"
# => "<h2>My admin name is Evad<h2>\n"
body # => "200 OK" status
So far, we've had Roda return short strings as response bodies. However, that's not a very realistic approach. Most applications are either going to return full HTML pages, or HTML fragments, or JSON. Core Roda doesn't offer good support for those cases, mostly because many applications are either returning HTML or JSON and not both. Core Roda tries to only offer a minimal set of capabilities that are needed by most web applications, with plugins to handle more advanced needs.
In most production web applications, we will not be preparing the response bodies in the routing tree itself. Most applications will handle the creation of response bodies using dedicated methods. In this section we'll show to move from creating response bodies in the routing tree to using a separate method that will use a template file to create the response body. This process is of taking a template and create a response body from it is often called rendering.
Let's assume we need to write the list of tasks in a to-do list application. We'll start by returning a string with the list of tasks one after the other, separated by a new line.
require "roda"
require "./models"
class App < Roda
do |r|
route .root do
rTask.all.map(&:title).join("\n")
end
end
end
When we print it on the command line, we get what we expected.
require "lucid_http"
GET "/"
puts body
# Play Battletoads
# Learn how to force-push
# Find radioactive spider
# Rescue April
# Add red setting to sonic screwdriver
# Fix lightsaber
# Shine claws
# Saw cape
# Buy Blue paint
# Repaint TARDIS
However, this is going to look ugly in the browser, because the
returned Content-Type
will be text/html
(Roda's default), but we are returning
plain text. So the newlines will be converted into spaces.
If we want something that looks nice in the browser, we want to output something in HTML format. We can use a unordered list with a checkbox telling us whether the task is done or not.
One approach to doing this is to create an empty string, then append to it for each item, and return the string as the response body. This works, but is fairly ugly and will make maintenance more difficult.
do |r|
route .root do
r= String.new
result << "<ul>"
result Task.all.each do |task|
<< "<li class=\"#{task.done? ? :done : :todo}\">"
result << " <input type=\"checkbox\"#{" checked" if task.done?}>"
result << " #{task.title}"
result << "</li>"
result end
<< "</ul>"
result
resultend
end
We could extract this logic into a separate method, or add some
helpers, but ultimately building responses by explicitly appending to a
string is going to be more verbose than most developers desire.
Roda handles this issue similarly to many other frameworks, by
supporting templates using the render
plugin.
render
Using the render
plugin, we can remove
the messy code that uses explicit string appending, and replace it with
a call to the render
method, passing a
template name.
To get started using the render
plugin,
we first need to install tilt
, the gem
that the render
plugin uses for rendering
templates. So we need to add tilt
to the
Gemfile
, then run bundle install
.
"https://rubygems.org"
source
"roda"
gem "puma"
gem "tilt" gem
After installing the gem, we can use the render
plugin. We'll load it into our
application, then try rendering a template.
class App < Roda
:render
plugin
do |r|
route .root do
r"tasks"
render end
end
end
Then we try it out to see what we get. Since we haven't created the
template file, we get an error telling us that our template does not
exist. The error message is useful, because now we know where the plugin
is looking for the template file. As the file extension indicates, the
default template language that the plugin expects is erb
(embedded Ruby), and it expects files to be
in the views
directory.
require "lucid_http"
GET "/"
error# => "Errno::ENOENT: No such file or directory @ " \
# "rb_sysopen - /home/lucid/code/my_app/views/tasks.erb"
So we create the views
directory and
views/tasks.erb
file. In the views/tasks.erb
file, we can add the template
code. We'll also return a full HTML page instead of just an HTML
fragment.
We add a title to the HTML file, then, for each task in the list, we
use a list item. The item class switches on the state of the li
element between done
and todo
.
Inside the list item we see the expected checkbox. It will be checked if
the task is done.
<html>
<head>
<title>To-Do or not To-Do</title>
</head>
<body>
<h1>To-Do or not To-Do</h1>
<h2>Tasks</h2>
<ul>
<% @tasks.each do |task| %>
<li class="<%= task.done? ? :done : :todo %>">
<input type="checkbox"<%= " checked" if task.done? %>>
<%= task.title %>
</li>
<% end %>
</ul>
</body>
</html>
When we try this again, we get a NoMethodError
. This is expected, because we
haven't set the @tasks
instance variable
yet.
require "lucid_http"
GET "/"
error# => "NoMethodError: undefined method `each' " \
# "for nil:NilClass for #<App:0x00000001bd6ba8>"
We can set the @tasks
instance variable
before rendering the template file (also called a view), and that will
fix the issue.
class App < Roda
:render
plugin
do |r|
route .root do
r@tasks = Task.all
"tasks"
render end
end
end
When we visit the page, we see our plain looking list.
Setting instance variables in the routing tree allows the view to access them because the view is executed in the same scope as the routing tree. By setting instance variables in the routing tree, you do not need to pass them explicitly to the view.
There's a second way for the view to access data set in the routing tree, and that's an explicit approach of passing local variables when rendering. We can also mix the two approaches, setting some instance variables and passing other data explicitly as local variables.
We can pass local variables to the template by passing the :locals
option to the render
method with a hash, where the keys are
symbols of the names of the local variables we want to define, and each
value in the hash is the value of that local variable in the view.
class App < Roda
:render
plugin
do |r|
route .root do
r"tasks", locals: { tasks: Task.all }
render end
end
end
We would then need to change @tasks
in
the template to tasks
, since we are now
passing it as a local variable instead of an instance variable.
<html>
<head>
<title>To-Do or not To-Do</title>
</head>
<body>
<h1>To-Do or not To-Do</h1>
<h2>Tasks</h2>
<ul>
<% tasks.each do |task| %>
<li class="<%= task.done? ? :done : :todo %>">
<input type="checkbox"<%= " checked" if task.done? %>>
<%= task.title %>
</li>
<% end %>
</ul>
</body>
</html>
In general, using instance variables instead of passing local variables explicitly is recommended as it offers better performance and requires less verbose code. However, some developers prefer the explicitness of passing data to the view using local variables. Most of the rest of the book will use instance variables, though a later section will describe one case when using local variables is recommended.
Now, we could add some styling to our list to make it a bit more
colorful. We'll start with the simple way of using an inline style
tag inside the head
element. We'll also remove the list item
bullets, which don't look great considering we are also using
checkboxes.
<html>
<head>
<title>To-Do or not To-Do</title>
<style>
ul { list-style: none; }
ul .todo { color: red;}
ul .done { color: green;}</style>
</head>
<body>
<h1>To-Do or not To-Do</h1>
<ul>
<% @tasks.each do |task| %>
<li class="<%= task.done? ? :done : :todo %>">
<input type="checkbox"<%= " checked" if task.done? %>>
<%= task.title %>
</li>
<% end %>
</ul>
</body>
</html>
When we reload, we can see our style applied.
Now that the list is slightly prettier, we move to our next feature: showing details for a particular task. So we add the new route, set the instance variable we plan to use, and then render the new view.
do |r|
route .root do
r@tasks = Task.all
"tasks"
render end
.get "tasks", Integer do |id|
rnext unless @task = Task[id]
"task"
render end
end
We can then add the views/task.erb
file. In this file, we need to show the task title, whether it was done
or not, and its due date. We'll copy the erb
code from the previous template and modify
it.
<html>
<head>
<title>To-Do or not To-Do</title>
<style>
ul { list-style: none; }
ul .todo { color: red;}
ul .done { color: green;}</style>
</head>
<body>
<h1>To-Do or not To-Do</h1>
<h2><%= @task.title %></h2>
<% if @task.done? %>
<span class="done">[DONE]</span>
<% else %>
<span class="todo">[TODO]</span>
<% end %>
<h3>Due Date: <%= @task.due.strftime("%v") %></h3>
</body>
</html>
We try this out and we see the new template rendered. Here's an example of a task that has been completed.
Here's an example of a task we have not completed yet.
We now have our two different templates, but something doesn't feel right about this code. We are using the same HTML structure, CSS style, and title, and we're duplicating it for every new page we add.
If we wanted to change anything on our basic layout, say the color of
the done
tasks to a different shade of
green, we'd need to change it in every view.
We can fix this issue and remove the duplication issue by replacing
the render
method with a call to view
.
do |r|
route .root do
r@tasks = Task.all
"tasks"
view end
.get "tasks", Integer do |id|
rnext unless @task = Task[id]
"task"
view end
end
When we load any of the two pages we currently have, we see that
view
is not finding a file called layout.erb
in the same path as the previous
templates.
require "lucid_http"
GET "/"
error# => "Errno::ENOENT: No such file or directory @ " \
# "rb_sysopen - /home/lucid/code/my_app/views/layout.erb"
We want to move the shared content from both of the other views into
the layout view, and in the layout view where the page-specific content
goes, we will call yield
.
<html>
<head>
<title>To-Do or not To-Do</title>
<style>
ul { list-style: none; }
ul .todo { color: red;}
ul .done { color: green;}</style>
</head>
<body>
<h1>To-Do or not To-Do</h1>
<%= yield %>
</body>
</html>
We can then update the tasks.erb
view.
<h2>Tasks</h2>
<ul>
<% tasks.each do |task| %>
<li class="<%= task.done? ? :done : :todo %>">
<input type="checkbox"<%= " checked" if task.done? %>>
<%= task.title %>
</li>
<% end %>
</ul>
We can also update the task.erb
view.
<h2><%= @task.title %></h2>
<% if @task.done? %>
<span class="done">[DONE]</span>
<% else %>
<span class="todo">[TODO]</span>
<% end %>
<h3>Due Date: <%= @task.due.strftime("%v") %></h3>
We can check and make sure everything works correctly again.
One thing to be aware of when using layouts with the view
method is that the page-specific view is
rendered before the layout view. This is helpful as it allows us to set
instance variables in the page-specific view, and access them in the
layout view. This is commonly used to set the HTML page title on a
per-page basis. Let's make that change first to the layout, to use a
custom page title from the @page_title
instance variable, with the previous page title as a fallback.
<html>
<head>
<title><%= @page_title || "To-Do or not To-Do" %></title>
<style>
ul { list-style: none; }
ul .todo { color: red;}
ul .done { color: green;}</style>
</head>
<body>
<h1>To-Do or not To-Do</h1>
<%= yield %>
</body>
</html>
We can then update the tasks.erb
view
to set the @page_title
.
<% @page_title = "All Tasks" %>
<h2>Tasks</h2>
<ul>
<% tasks.each do |task| %>
<li class="<%= task.done? ? :done : :todo %>">
<input type="checkbox"<%= " checked" if task.done? %>>
<%= task.title %>
</li>
<% end %>
</ul>
We can also update the task.erb
view to
set the @page_title
.
<% @page_title = "Task: #{@task.title}" %>
<h2><%= @task.title %></h2>
<% if @task.done? %>
<span class="done">[DONE]</span>
<% else %>
<span class="todo">[TODO]</span>
<% end %>
<h3>Due Date: <%= @task.due.strftime("%v") %></h3>
content_for
In some cases, there can be multiple places where the layout uses content provided by the page-specific view, instead of just a single place. While we can pass content from the page-specific view to the layout by setting an instance variable, in some cases that results in ugly code. This is particularly true of cases where large blocks of template code that are page-specific are designed to be displayed outside of the main content in the layout.
As an example, let's say we wanted to add a footer to our pages. We
can render a separate template for the footer, unless the page-specific
view has overridden the footer. Let's change our layout to support this.
We'll assume we have a footer.erb
view for
the default footer. We can use render("footer")
in the layout to render the
footer view.
<html>
<head>
<title><%= @page_title || "To-Do or not To-Do" %></title>
<style>
ul { list-style: none; }
ul .todo { color: red;}
ul .done { color: green;}</style>
</head>
<body>
<h1>To-Do or not To-Do</h1>
<%= yield %>
<div class="footer">
<%= render("footer") %>
</div>
</body>
</html>
We'll use a simple footer.erb
view.
This is the footer.
This works and adds the footer to all pages. However, let's say we
wanted the footer on the page that displays all tasks to instead show
the total number of tasks. We need to store the content for the footer
in the tasks.erb
view, and then modify the
layout to use it. To do this, we'll use the content_for
plugin, which allows storing the
content in one template, and displaying it in another template. Let's
first enable the plugin.
class App < Roda
:render
plugin :content_for
plugin
do |r|
route .root do
r@tasks = Task.all
"tasks"
view end
.get "tasks", Integer do |id|
rnext unless @task = Task[id]
"task"
view end
end
end
Next, let's update the tasks.erb
view
to store the custom footer showing how many total tasks there are. In
this view, we'll call the content_for
method with a block, and the result of the block is the content to
store.
<% @page_title = "All Tasks" %>
<h2>Tasks</h2>
<ul>
<% tasks.each do |task| %>
<li class="<%= task.done? ? :done : :todo %>">
<input type="checkbox"<%= " checked" if task.done? %>>
<%= task.title %>
</li>
<% end %>
</ul>
<% content_for(:footer) do %>
<%= tasks.length %> tasks total.
There are <% end %>
Finally, let's update the layout.erb
file to use the custom footer if one is given, and fall back to the
default footer if not. In the layout, we'll call content_for
without a block, which will return
the stored content, or nil
if no content
was stored. So if the page-specific view set the content for the footer,
it will be used. If it did not set the content for the footer, the
default of rendering the footer template will be used.
<html>
<head>
<title><%= @page_title || "To-Do or not To-Do" %></title>
<style>
ul { list-style: none; }
ul .todo { color: red;}
ul .done { color: green;}</style>
</head>
<body>
<h1>To-Do or not To-Do</h1>
<%= yield %>
<div class="footer">
<%= content_for(:footer) || render("footer") %>
</div>
</body>
</html>
capture_erb
If you want to capture content from an erb template, without the
desire to access it elsewhere later using the content_for
method, then you can directly use
the capture_erb
plugin (the content_for
plugin uses capture_erb
internally):
<% footer_content = capture_erb do %>
<%= tasks.length %> tasks total.
There are <% end %>
inject_erb
Roda uses ERB syntax, which means that the following template code does not work:
<%= some_method do %>
content<% end %>
This is invalid ERB syntax, though it is supported by Rails (Rails attempts to parse the code inside the tag using a regexp, and changes behavior based on the results of the regexp parsing). With Roda, you have to use valid ERB syntax, such as the following template code:
<% some_method do %>
content<% end %>
However, this means that some_method
in
the above template cannot update the template output, other than by
yielding to the block passed to it. This makes it hard to write template
methods that accept blocks that want to wrap the blocks and inject
content before and after the blocks. Thankfully, Roda has an
inject_erb
plugin to allow for this. With
the inject_erb
plugin loaded, you can call
the inject_erb
method to inject content
into the template currently being rendered. For example, if some_method
should include <form>
tags around the content, you could
define it similar to the following:
def some_method
'<form>'
inject_erb yield
'</form>'
inject_erb end
With the above approach, some_method
injects <form>
into the template
output, then yields to the block, then injects </form>
into the template output after the
block returns.
What if you want to modify the content inside the block passed to the
method before injecting it into the template? To do that, you can
combine the inject_erb
plugin with the
capture_erb
plugin discussed above. You
capture the content of the block, and then can modify it before
injecting it. For example, if you want some_method
to capitalize the content inside the
block, in addition it to wrapping it with <form>
tags:
def some_method(&block)
'<form>'
inject_erb &block).capitalize
inject_erb capture_erb('</form>'
inject_erb end
One issue with the current templates is that they do not correctly escape content before displaying it on the page. If the content can come from sources beyond our control, this could open up a vulnerability called cross-site scripting (XSS). To prevent this type of vulnerability, we need to escape output before using it.
Early in the book, we learned about the h
plugin, and how it can be used to escape
content before displaying it. We could use that here, with explicit
calls to h
every time we want to escape
output. For example, we could switch the task.erb
file to use the h
plugin.
<% @page_title = "Task: #{h @task.title}" %>
<h2><%=h @task.title %></h2>
<% if @task.done? %>
<span class="done">[DONE]</span>
<% else %>
<span class="todo">[TODO]</span>
<% end %>
<h3>Due Date: <%= @task.due.strftime("%v") %></h3>
Unfortunately, this approach is fairly error-prone, as in a large
application it is likely that we will miss a place that needs to be
escaped (notice that h
was used in two
places in the above template). Roda has a better approach.
Instead of only escaping stuff we know is bad, we can escape everything
except what we know is good (by good, we mean we know the output is
already escaped HTML).
We need to enable this support in the render
plugin using the :escape
option. Be aware that this automatic
escaping support requires the erubi
gem,
so we need to add that to our Gemfile
and
then run bundle install
.
"https://rubygems.org"
source
"roda"
gem "puma"
gem "tilt"
gem "erubi" gem
After installing the erubi
gem, we can
use the render
plugin's :escape
option.
class App < Roda
:render, escape: true
plugin
# ...
end
This approach allows us to remove the manual escaping in the task.erb
view. The @task.title
in the h2
tag will be automatically escaped. This will
also escape the @task.due.strftime("%v")
output in the h3
tag, even though that
doesn't need escaping.
<% @page_title = "Task: #{@task.title}" %>
<h2><%= @task.title %></h2>
<% if @task.done? %>
<span class="done">[DONE]</span>
<% else %>
<span class="todo">[TODO]</span>
<% end %>
<h3>Due Date: <%= @task.due.strftime("%v") %></h3>
In the layout view, we only need to make a single change, and that is
to mark that the yield
call returns
already escaped output. We can do that by adding an extra equal sign to
the output tag.
<html>
<head>
<title><%= @page_title || "To-Do or not To-Do" %></title>
<style>
ul { list-style: none; }
ul .todo { color: red;}
ul .done { color: green;}</style>
</head>
<body>
<h1>To-Do or not To-Do</h1>
<%== yield %>
</body>
</html>
This will not escape the output of the page-specific template (as
that should already be escaped), but it will add escaping of the title
tag. When using automatic escaping, we
just need to remember to use the double equals output tag instead of the
single equals if we want to output a value that has already been
escaped.
The render
plugin includes more
advanced configuration options, beyond the :escape
option already shown. We can learn more
about the options on the
render
plugin documentation page.
Using a single views
folder with all
template files inside of it works fine for small applications, but
larger applications are probably going to want to organize their views
using subdirectories. We'll look at how to do that when applied to the
previous example. Let's create a views/tasks
subdirectory and move the views into
it.
Dir.mkdir('views/tasks')
File.rename('views/tasks.erb', 'views/tasks/index.erb')
File.rename('views/task.erb', 'views/tasks/task.erb')
Then update our application to render a template from the subdirectory, by prepending the subdirectory to the view name.
do |r|
route .root do
r@tasks = Task.all
"tasks/index"
view end
.get "tasks", Integer do |id|
rnext unless @task = Task[id]
"tasks/task"
view end
end
view_options
The above example works, but results in duplication of the
subdirectory name for the related routes. Let's fix that using the view_options
plugin. First, let's use a more
realistic example. In general, we are only going to use view
subdirectories when we have multiple branches in the routing tree. It's
fairly common to use a view subdirectory per branch. So we'll expand our
example to have a branch for tasks and a branch for posts.
class App < Roda
:render, escape: true
plugin
do |r|
route .on "tasks" do
r.get true do
r@tasks = Task.all
"tasks/index"
view end
.get Integer do |id|
rnext unless @task = Task[id]
"tasks/task"
view end
end
.on "posts" do
r.get true do
r@posts = Post.all
"posts/index"
view end
.get Integer do |id|
rnext unless @post = Post[id]
"posts/post"
view end
end
end
end
As the previous example shows, this duplicates the view subdirectory
name in every route. Let's fix this using the view_options
plugin. It includes a set_view_subdir
method for setting the view
subdirectory. When using this, we need to set the :layout
option when loading the render plugin to
include a slash. This way the views/layout.erb
file will be used for the
layout, instead of looking for the layout in each view subdirectory.
class App < Roda
:render, escape: true, layout: './layout'
plugin :view_options
plugin
do |r|
route .on "tasks" do
r"tasks"
set_view_subdir
.get true do
r@tasks = Task.all
"index"
view end
.get Integer do |id|
rnext unless @task = Task[id]
"task"
view end
end
.on "posts" do
r"posts"
set_view_subdir
.get true do
r@posts = Post.all
"index"
view end
.get Integer do |id|
rnext unless @post = Post[id]
"post"
view end
end
end
end
The behavior remains the same, but now we don't need to specify the
view subdirectory explicitly in every route. The view_options
plugin also has the ability to
change any view or layout options on a per-branch basis. This can be
used if templates in one of the subdirectories use a different enging
than the templates used by default.
In the section on layout, we learned about separating the layout view from the page-specific view to reduce duplication. However, on many complex applications, we'll run into the same duplication issue in other areas, where we want to reuse code without duplicating it in multiple separate views. In this section, we'll learn how to split up a template into partials.
We have a To-Do app where we show the tasks list on the root
route.
We noticed that when the list grows too long, it starts to get annoying having to scroll up and down to locate the undone tasks. To solve this, we could sort the list by putting the undone tasks first. However, we don't want to loose the actual ordering of the list.
What we decide to do is to show a list containing only the undone
tasks. In order to do that, we create the /todo
route. To keep the example simple, we'll
go back to a single views folder instead of using view subdirectories.
So we'll use a todo
view, and set an @tasks
instance variable that the view will be
able to access.
do |r|
route .root do
r@tasks = Task.all
"index"
view end
.get "todo" do
r@tasks = Task.todo
"todo"
view end
end
Then we need to create the views/todo.erb
template file. Since this is just
the first pass through this implementation and we already have a
stylized version of our tasks, we decide to use the same code for our
list items. We can always come back and simplify this code later.
<h2>Undone Tasks</h2>
<ul>
<% @tasks.each do |task| %>
<li class="<%= task.done? ? :done : :todo %>">
<input type="checkbox"<%= " checked" if task.done? %>>
<%= task.title %>
</li>
<% end %>
</ul>
We can check and see that this works.
Now that it works, we can come back to our code. To simplify the
views, we decide to extract the duplicated code into it's own template
file. We create a views/task.erb
view, and
copy code for handling a specific task to it.
<li class="<%= task.done? ? :done : :todo %>">
<input type="checkbox"<%= " checked" if task.done? %>>
<%= task.title %>
</li>
Now, we need a way to render this view for each of the tasks.
Luckily, we already learned how to do this, though it might not be
obvious. We just need to reuse the render
method for rendering the template. We use render
instead of view
because we don't want to include the layout
when rendering the views/task.erb
view.
We are going to pass the task to the view as a local variable in this case, because we are calling the view in a loop, and it is already a local variable passed to the block. This is a case where it is recommended to use a local variable and not an instance variable when rendering.
<h2>Undone Tasks</h2>
<ul>
<% tasks.each do |task| %>
<%== render("task", locals: { task: task }) %>
<% end %>
</ul>
We can try it and confirm that it still works.
render_each
Rendering the same template for each object in an enumerable of
objects is actually a common need in web applications, and Roda
offers a render_each
plugin to support
that need. We can load that plugin into our application.
class App < Roda
:render, escape: true
plugin :render_each
plugin
# ...
end
Then we can update our views/task
view
to use it. This is significantly simpler than manually iterating over
each task and rendering a template for it. It is also better for
performance due to optimizations in the render_each
plugin.
<h2>Undone Tasks</h2>
<ul>
<%== render_each(tasks, "task") %>
</ul>
As the template filename is task.erb
,
the render_each
method will automatically
set a local variable of task
for the
current task being rendered. We can choose a different local variable
name using the :local
option.
partials
If we look at the views/tasks
directory
for the previous example, we can see that tasks.erb
and todo.erb
are a meant to render a whole page on
our site. They are what we usually call views in most web
frameworks. On the other hand, task.erb
is
meant just to render a portion of the page, and possibly is called
multiple times per request. In web development, this kind of file is
commonly known as a partial (short for partial template).
Ruby on Rails provides a convention to make the view/partial
difference at the file name level. If the file is meant to be a partial,
we prepend an underscore to the filename. The partials
plugin allows for similar behavior as
Rails. Here's an example of using it. First, we need to rename
the partial to prepend the filename with an underscore.
File.rename('views/task.erb', 'views/_task.erb')
Then we need to update our application to use the partials
plugin.
class App < Roda
:render, escape: true
plugin :partials
plugin
# ...
end
Then we can update the views/todo.erb
view to use the partials
plugin. We can
either use the long way of manual iteration and calling the partial
method.
<h2>Undone Tasks</h2>
<ul>
<% tasks.each do |task| %>
<%== partial("task", locals: { task: task }) %>
<% end %>
</ul>
Or the shortcut of using each_partial
.
Both cases result in the same code.
<h2>Undone Tasks</h2>
<ul>
<%== each_partial(tasks, "task") %>
</ul>
Both partial
and each_partial
just add an underscore to the
template filename. They do not change other behavior, so they are only
useful if we want to use a convention that prefixes filenames for
partials with an underscore.
symbol_views
Roda's default behavior is that match blocks should
return either a string for the response body, or nil
or false
.
However, that's only the default behavior, the behavior can be modified
in a plugin. In the next couple sections, we'll learn how to use a
couple of those plugins.
Let's take an earlier example discussing tasks, without the view subdirectories. So our app code looks like this.
class App < Roda
:render, escape: true
plugin
do |r|
route .root do
r@tasks = Task.all
"index"
view end
.get "todo" do
r@tasks = Task.todo
"todo"
view end
end
end
There is some duplication here, in that we are calling view
method multiple times. It's actually fairly
common in a large routing tree that most of the r.get
calls will end with a call to view
. To reduce duplication and make things
prettier (and less explicit), we are going to use the symbol_views
plugin. The symbol_views
plugin allows match blocks
to return symbols, and if a match block returns a symbol, the
symbol is passed to the view
method and
the output is used as the response body. So the above app code can be
changed to:
class App < Roda
:render, escape: true
plugin :symbol_views
plugin
do |r|
route .root do
r@tasks = Task.all
:index
end
.get "todo" do
r@tasks = Task.todo
:todo
end
end
end
It's possible to use symbol_views
with
view subdirectories, but it looks a bit less pretty:
class App < Roda
:render, escape: true
plugin :symbol_views
plugin
do |r|
route .root do
r@tasks = Task.all
"tasks/index"
:end
.get "todo" do
r@tasks = Task.todo
"tasks/todo"
:end
end
end
However, most applications using view subdirectories are generally
going to use the view_options
plugin as
discussed earlier, so it is rare to need this syntax.
json
symbol_views
isn't the only plugin that
adds support for additional types to be returned by match
blocks. symbol_views
helps reduce
duplication in applications that return HTML. Roda offers a
similar way to reduce duplication in applications that return JSON,
using the json
plugin.
We're going to use an example application that shows movie showtimes.
We'll assume the movies
local variable is
defined that is an array of hashes, with each hash representing a movie
showtime. We'll use two routes. One is /movies
route for listing our movies. Another is
a /movies/<slug>
route for
displaying the title, times and description for a specific movie.
class App < Roda
= [
movies {
slug: "infinity-war",
title: "Avengers Infinity War",
times: ["15:30", "18:40", "21:45"],
description: "The Avengers fight Thanos."
},
{
slug: "the-usual-suspects",
title: "The Usual Suspects",
times: ["11:10", "15:45"],
description: "A random police lineup leads to something deadly."
},
{
slug: "the-matrix",
title: "The Matrix",
times: ["17:15", "22:10"],
description: "Computer hacker finds he lives in a simulation."
},
]
do |r|
route .on "movies" do
r.get true do
r.map do |movie|
movies"#{movie[:title]}: /movies/#{movie[:slug]}"
end.join("\n")
end
.get String do |slug|
r= movies.find { |m| m[:slug] == slug }
movie
<<~EOF
#{movie[:title]}
Times: [ #{movie[:times].join(" ")} ]
Description: #{movie[:description]}
EOF
end
end
end
end
Unfortunately, a manager comes along and decides that our simple app should now be powered by the frontend javascript framework du jour. So our Roda application must switch to returning JSON that will be consumed by the frontend.
We first convert the /movies
route to
return JSON. We aren't insane enough to construct the JSON by hand, so
we'll convert our movies
array into the
appropriate format, and then call to_json
on the result. We also need to set the Content-Type
header of the response to let the
client know that we're returning JSON.
.get true do
r['Content-Type'] = 'application/json'
response
.map do |movie|
movies{title: movie[:title], url: "/movies/#{movie[:slug]}"}
end.to_json
end
Now, when we take a look at the end result, we see our JSON response.
require "lucid_http"
require "json"
GET "/movies", formatter: :json
# => [{"title"=>"Avengers Infinity War", "url"=>"/movies/infinity-war"},
# {"title"=>"The Usual Suspects", "url"=>"/movies/the-usual-suspects"},
# {"title"=>"The Matrix", "url"=>"/movies/the-matrix"}]
Now we move on to the /movies/<slug>
route. We first need to
find the correct movie. If we can't find the movie, we use next
to return a 404
response. If we can find the movie, we set
the Content-Type
, then create a new hash
with the movie information, and convert that hash to JSON using to_json
.
Here's the full routing tree for the application.
do |r|
route .on "movies" do
r.get true do
r['Content-Type'] = 'application/json'
response
.map do |movie|
movies{title: movie[:title], url: "/movies/#{movie[:slug]}"}
end.to_json
end
.get String do |slug|
rnext unless movie = movies.find { |m| m[:slug] == slug }
['Content-Type'] = 'application/json'
response
{
title: movie[:title],
times: movie[:times],
description: movie[:description]
}.to_json
end
end
end
We can then check that the /movies/<slug>
route works.
require "lucid_http"
require "json"
GET "/movies/infinity-war", formatter: :json
# => {"title"=>"Avengers Infinity War",
# "times"=>["15:30", "18:40", "21:45"],
# "description"=>
# "The Avengers fight Thanos."}
This approach has two cases that result in duplication. First, we
need to set the Content-Type
header so the
frontend will handle the response as JSON. We only want to set the Content-Type
header if we are sure we are
returning JSON, not in cases where we are returning an empty 404
response. Second, we need to call to_json
on the hash or array that we want to use
as the JSON response body.
Roda's json
plugin is designed
to remove the duplication in both of these cases. We first need to load
the plugin. After loading it, match blocks can return an array
or a hash, and the array or hash will be converted to JSON and the JSON
output will be returned as the response. The json
plugin handles setting the Content-Type
header, so we don't need to do that
in every route.
In this case, our r.get true
route
returns an array, and our r.get String
route returns a hash. Both are converted to JSON.
class App < Roda
:json
plugin
= [
movies # ...
]
do |r|
route .on "movies" do
r.get true do
r.map do |movie|
movies{title: movie[:title], url: "/movies/#{movie[:slug]}"}
end
end
.get String do |slug|
rnext unless movie = movies.find { |m| m[:slug] == slug }
{
title: movie[:title],
times: movie[:times],
description: movie[:description]
}
end
end
end
end
We can check the output again to make sure everything works.
require "lucid_http"
require "json"
GET "/movies", formatter: :json
# => [{"title"=>"Avengers Infinity War", "url"=>"/movies/infinity-war"},
# {"title"=>"The Usual Suspects", "url"=>"/movies/the-usual-suspects"},
# {"title"=>"The Matrix", "url"=>"/movies/the-matrix"}]
GET "/movies/infinity-war", formatter: :json
# => {"title"=>"Avengers Infinity War",
# "times"=>["15:30", "18:40", "21:45"],
# "description"=>
# "The Avengers fight Thanos."}
Hopefully the symbol_views
and json
plugins have shown how Roda's
default behavior can be extended by plugins to make certain uses cases
simpler.
So far, our discussion of rendering has focused on rendering views to generate response bodies for requests. We'll now extend the discussion to rendering both the CSS stylesheet and the javascript files used by the views. The layout in our examples inlined the CSS. In general, we are not going to want to do that. We would want to use an separate stylesheet, and if we use javascript, we'll also want to separate the javascript file.
The simplest way to handle stylesheet and javascript assets is to use
static assets. In general, static asset files would go under the public
folder, and would be served directly by
the content delivery network, front-end web-server, or by the public
plugin. In development, we'll probably
want to use the public
plugin, so that's
what we'll use in this example.
Let's create a directory structure and empty files for the static assets.
Dir.mkdir("public/css")
Dir.mkdir("public/js")
File.write("public/css/app.css", "")
File.write("public/js/app.js", "")
We'll then open up the public/css/app.css
file and add the CSS code
from the layout, formatting it to make it a little nicer.
ul {list-style: none;
}.todo {
ul color: red;
}.done {
ul color: green;
}
We'll then open up the public/js/app.js
file and add some javascript code that changes the class of the item
when the checkbox is modified.
function() {
(Array.from(document.getElementsByTagName("input")).
forEach(function(element) {
.onchange = function() {
element.parentNode.classList.toggle("done");
element.parentNode.classList.toggle("todo");
element
};
}); })()
We need to update our routing tree to serve files under the public
directory.
class App < Roda
:render, escape: true
plugin :symbol_views
plugin :public
plugin
do |r|
route .public
r
.root do
r@tasks = Task.all
:index
end
.get "todo" do
r@tasks = Task.todo
:todo
end
end
end
We can then edit our views/layout.erb
file to link to the CSS and JS files.
<html>
<head>
<title><%= @page_title || "To-Do or not To-Do" %></title>
<link rel="stylesheet" href="/css/app.css" />
</head>
<body>
<h1>To-Do or not To-Do</h1>
<%== yield %>
<script type="text/javascript" src="/js/app.js"></script>
</body>
</html>
This works and the page displays the same as it did previously when using the inline stylesheet. It functions slightly nicer as clicking on a checkbox modifies the style of the related item.
We can get fairly far with using static assets. If we are sufficiently stubborn, we can handle even a large website using static assets. However, the static asset approach has some issues:
It lacks support for compiling assets. We are stuck using CSS and javascript directly, and do not have the ability to use languages that compile to CSS or javascript and make web development easier. For javascript, this probably isn't a major issue, but for any site with even moderate-complex styling needs, using SCSS instead of CSS is a huge win.
It lacks support for combining assets. If we use multiple CSS or javascript files in order to organize our code, browsers need to make separate requests for the assets. Combining the assets can improve the performance as it results in less bandwidth and lower latency.
It lacks support for compressing assets. In general for performance we'll want to serve minified assets in production to reduce the bandwidth used and thus the time it takes for a page to fully load. However, we probably don't want to develop on a minified version of the assets as that complicates debugging.
Thankfully, Roda ships with an assets
plugin that handles all of these concerns.
assets
The assets
plugin handles compiling
assets, as well as combining and compressing assets in production.
Roda tries to make setting up assets as easy as possible. By
default, Roda expects assets will be stored in an assets
directory, with assets/css
storing CSS files (or files that
compile to CSS) and assets/js
storing
javascript files (or files that compile to javascript). Let's first
create the assets
directory tree and then
move our static assets to them.
Dir.mkdir("assets")
Dir.mkdir("assets/css")
Dir.mkdir("assets/js")
File.rename("public/css/app.css", "assets/css/app.css")
File.rename("public/js/app.js", "assets/js/app.js")
Then we can update our routing tree to serve the assets. Note how the
only changes are replacing the public
plugin with the assets
plugin, and using
r.assets
instead of r.public
in the routing true.
class App < Roda
:render, escape: true
plugin :symbol_views
plugin :assets, css: ["app.css"], js: ["app.js"]
plugin
do |r|
route .assets
r
.root do
r@tasks = Task.all
:index
end
.get "todo" do
r@tasks = Task.todo
:todo
end
end
end
We can then edit our views/layout.erb
file to call the assets
methods instead of
hardcoding the links. The assets
method
returns an already escaped HTML tag (or multiple tags), so just as with
yield
, we use the double equals when
outputing to avoid double escaping the output. If we want to use the
assets plugin for both CSS and javascript, we'll want to call the assets
method twice, once for each type of
asset.
<html>
<head>
<title><%= @page_title || "To-Do or not To-Do" %></title>
<%== assets(:css) %>
</head>
<body>
<h1>To-Do or not To-Do</h1>
<%== yield %>
<%== assets(:js) %>
</body>
</html>
This works and the page displays the same as it did before the
changes to use the assets
plugin.
With the previous example, the assets plugin is serving the static
file directly without doing any processing on it. It's likely slower
than the static asset approach that used the public
plugin, and doesn't offer an advantage.
Let's actually start using the assets
plugin features so we can see what the advantages are.
First, we are going to take the assets.css
file and change it to assets.scss
. This changes it from a CSS file to
an SCSS file.
File.rename("public/css/app.css", "assets/css/app.scss")
Then we can update the assets
plugin
options we used to reflect the file renaming.
:assets, css: ["app.scss"], js: ["app.js"] plugin
Converting the SCSS format to CSS format requires the sassc
gem (or the older and now deprecated sass
gem), so let's add the sassc
gem to our Gemfile
, then run bundle install
.
"https://rubygems.org"
source
"roda"
gem "puma"
gem "tilt"
gem "erubi"
gem "sassc" gem
After installing the sassc
gem, we can
check and the page displays the same as it did before we renamed the
file. That may appear to be a little weird, because the file format did
change. The reason it works is that valid CSS code is automatically
valid SCSS code, it just doesn't use any of the SCSS features. Let's
start using an SCSS feature to show what SCSS can do. We'll edit the
assets/css/app.scss
file, and modify it to
use SCSS format. This is invalid CSS, as CSS doesn't support nested
style tags like this. However, SCSS does support such tags.
ul {
& {list-style: none;
}.todo {
color: red;
}.done {
color: green;
} }
We can check again and see that the page still looks the same. If we
make a request for the /assets/css/app.scss
file, we can see that our
SCSS file has been compiled into CSS, and the CSS content has been
returned as the body, with the correct Content-Type
.
require "lucid_http"
GET "/assets/css/app.scss"
# => 200 OK
status # => "text/css; charset=UTF-8"
content_type
body# >> ul {
# >> list-style: none; }
# >> ul .todo {
# >> color: red; }
# >> ul .done {
# >> color: green; }
SCSS doesn't support just style tag nesting, but also supports variables, mixins, inheritance, and mathematical operators. Any CSS file of moderate size can probably be simplified by switching it to SCSS and using SCSS features to reduce the amount of duplication in it.
In addition to supporting compilation for CSS, the assets
plugin also supports compilation for
javascript. For both CSS and javascript, the assets
plugin can use any template engine
supported by tilt
, the gem that powers the
render
plugin. For CSS, this includes the
sass
and less
formats in addition to scss
. For javascript, this can include coffee
(CoffeeScript), ts
(TypeScript), babel
(Babel Transpiler), and rb
(Opal, allowing us to write our frontend code
in Ruby).
In the previous section, we discussed one improvement that the assets
plugin offers, compiling a different
format to CSS or javascript. Now we will look at another advantage the
assets
plugin offers, which is combining
multiple CSS and/or javascript files.
We'll update our example to use multiple CSS and multiple javascript
assets. For the CSS, we'll assume we are including a modified version of
the Bootstrap CSS library as our base, in addition to the app.scss
file we used earlier. For the
javascript, we'll assume we are adding a TypeScript file that will
handle dynamic page actions on one of the task pages. Note that we are
using multiple different formats for both CSS and javascript files.
:assets,
plugin css: ["bootstrap.css", "app.scss"],
js: ["app.js", "tasks.ts"]
If we view the page content, we'll see how the assets(:css)
and assets(:js)
calls result in multiple link
and script
tags, respectively. We can infer from this that the assets
plugin does not combine assets by
default.
<html>
<head>
<title>To-Do or not To-Do</title>
<link rel="stylesheet" href="/assets/css/bootstrap.css" />
<link rel="stylesheet" href="/assets/css/app.scss" />
</head>
<body>
<h1>To-Do or not To-Do</h1>
# ...<script type="text/javascript" src="/assets/js/app.js"></script>
<script type="text/javascript" src="/assets/js/tasks.ts"></script>
</body>
</html>
Let's turn on the asset compilation to see how things have changed.
After loading the assets
plugin, we'll
call the compile_assets
method.
:assets,
plugin css: ["bootstrap.css", "app.scss"],
js: ["app.js", "tasks.ts"]
compile_assets
When we call compile_assets
,
Roda will convert all of the CSS assets to CSS and will
concatenate them together. Roda will also convert all of the
javascript assets to javascript and will concatenate them together.
After making this change, if we request the page again, we can the
content has changed, and now a single link
and script
tag are present. Note that in
this example, the string in the middle was truncated to make it fit on
one line.
<html>
<head>
<title>To-Do or not To-Do</title>
<link rel="stylesheet"
integrity="sha256-5uh7i+PDFHCJmpP9ef8B8TTTS8x7A2jeM/IvQH9Togs="
href="/assets/app.e6e87b8be3c31470899a93fd79ff01f134d.css" />
</head>
<body>
<h1>To-Do or not To-Do</h1>
# ...<script type="text/javascript"
integrity="sha256-M1Y2mm2BRge3II4oUCyNKCQDthqZ3YTCQlfGTKP7bW8="
src="/assets/app.3356369a6d814607b7208e28502c8d282403.js">
</script>
</body>
</html>
In the link
tag href
attribute and in the script
tag src
attribute, we see that the paths used have a basic format of /assets/app.*.css
and /assets/app.*.js
. What does the string in the
middle of the path represent? The assets
plugin uses the SHA-256 hash (in hex encoding) of the content of the
asset as part of the combined asset filename. This ensures that if there
is a change in any of the assets, a new filename will be used, and HTTP
clients will not use an old cached filename.
The integrity
attribute in both tags is
the same SHA-256 hash, but in base64 encoding. The integrity
attribute allows us to host the
compiled asset files on a content delivery network or other server that
we do not control, and ensures that the files will only be loaded if
they have not been modified.
The compile_assets
call is great for
production. However, we wouldn't want to use it in development, since
once we call compile_assets
, the assets
plugin will only serve the combined asset
files, and will not pick up changes to the source asset files. We could
skip the compiling of assets in development mode fairly easily by using
a conditional.
:assets,
plugin css: ["bootstrap.css", "app.scss"],
js: ["app.js", "tasks.ts"]
unless ENV["RACK_ENV"] == "development" compile_assets
However, there are cases where compile_assets
needs to be called before the
application starts. One reason for this is if the application runs on a
read-only filesystem. The assets
plugin
can handle this using a process called asset precompilation. With asset
precompilation, the assets are compiled before the application starts,
and the metadata related to the compiled assets is stored in a JSON
file. When the application starts, it checks for the existence of the
JSON file. If the JSON file exists, the assets
plugin assumes the assets have already
been combined and that the precompiled asset metadata is valid. If the
JSON file doesn't exist, the assets
plugin
operates in the default mode, using a separate link
and script
tag per asset file.
To use asset precompilation, we need to include the :precompiled
option when loading the assets
plugin, with the value being the filename
containing the precompiled asset metadata. The convention for this file
is to use compiled_assets.json
in the same
directory as the application. Additionally, we should remove any calls
to the compile_assets
method.
:assets,
plugin css: ["bootstrap.css", "app.scss"],
js: ["app.js", "tasks.ts"],
precompiled: File.expand_path('../compiled_assets.json', __FILE__)
To precompile the assets, it is common to use a rake task. This rake task can be used on Heroku to precompile the assets when building the application.
:assets do
namespace "Precompile the assets"
desc :precompile do
task require './app'
App.compile_assets
end
end
When calling compile_assets
, if the
:precompiled
option was given to the assets
plugin, the metadata related to the
compiled assets will be written in JSON format to that file, so that the
next time the application starts, the assets
plugin will operate in compiled mode.
The last major feature of the assets
plugin is the ability to compress assets after combining them. There's
actually two separate types of compression. The first type is often
called minimization. This requires custom gems such as yuicompressor
, which will inspect the generated
CSS and javascript code, and make it smaller while still behaving the
same. If we have yuicompressor
installed,
the assets
plugin will automatically pick
it up and use it to compress the assets.
The other type of compression used is gzip compression, which happens
after any asset minimization. This makes a gzip compressed version of
the asset available. For requests for the asset that accept gzip
encoding, the gzip version will be transferred directly, which can save
substantial amounts of bandwidth. We can enable this using the :gzip
option to the assets
plugin.
:assets,
plugin css: ["bootstrap.css", "app.scss"],
js: ["app.js", "tasks.ts"],
precompiled: File.expand_path('../compiled_assets.json', __FILE__),
gzip: true
In the previous section, we discussed many plugins that Roda includes that handle rendering response bodies. In this section, we'll discuss some plugins that extend Roda's routing capabilities.
hash_branches
Most applications start out small. If we are lucky (from a maintenance perspective), our application will stay small. However, there may be cases where the application needs to grow substantially. We may have started out with 5 routes, but after adding features, our application now has hundreds of routes.
With core Roda, all routes must be in the same block, and
blocks cannot span files in Ruby, so that means that all routes must be
in a single file. That's not a practical approach for an application
with a large number of routes. Roda recognizes that, and has
included plugins for managing large numbers of routes since its initial
release. In this section, we'll go over the use of the plugin
recommended for handling large routing trees, which is called hash_branches
.
Let's assume our application that originally handled only tasks has
now expanded to handle blog posts, and that we've also added an online
store. There are now many routes in each section, and it is becoming
hard to manage all routes inside the app.rb
file.
class App < Roda
do |r|
route .on "tasks" do
r# task routes
end
.on "posts" do
r# post routes
end
.on "store" do
r# store routes
end
end
end
We decide we want to split this up, and have separate files for the
routing branches for tasks
, posts
, and store
.
We'll add a routes
directory and create
separate files for the three routing branches.
Dir.mkdir("routes")
File.write("routes/tasks.rb", "")
File.write("routes/posts.rb", "")
File.write("routes/store.rb", "")
Then we'll open the routes/tasks.rb
file and move the tasks routing branch into it, using hash_branch
. We pass the hash_branch
the segment for the branch and a
block to handle requests for that branch. Each of the blocks you pass to
hash_branch
acts as its own routing tree
(similar to the Roda route
method):
class App
"tasks" do |r|
hash_branch # /tasks routes
end
end
Similarly, we'll open the routes/posts.rb
file and move the posts
routing branch into it.
class App
"posts" do |r|
hash_branch # /posts routes
end
end
Then we'll open the routes/store.rb
file and move the store
routing branch
into it.
class App
"store" do |r|
hash_branch # /store routes
end
end
Finally, we'll modify our app.rb
file
to use the hash_branches
plugin. We'll
also need to require the files containing the routes. We'll use a string
that will also include subdirectories of the routes
directory, even though we are not using
subdirectories yet. Then we can remove the separate routing branches,
and replace them with a single call to r.hash_branches
.
class App < Roda
:hash_branches
plugin
Dir["routes/**/*.rb"].each do |route_file|
require_relative route_file
end
do |r|
route .hash_branches
rend
end
With this routing tree, all requests that come in will call r.hash_branches
. r.hash_branches
will look at the first segment
in the request. If the request starts with /tasks
, it will call the hash_branch "tasks"
block, which we specified in
the routes/tasks.rb
file. If the request
starts with /posts
, it will call the hash_branch "posts"
block. If the request starts
with /store
, it will call the hash_branch "store"
block. If the request starts
with something else, it won't call any block and will return nil,
resulting in the expected 404
response.
The reason the plugin is named hash_branches
is that the per-branch route
blocks that we specify with the hash_branch
method are stored in a hash table.
When the request comes in, the r.hash_branches
call can take the first segment
in the remaining path, look for a matching entry in the hash
table, and then dispatch to the value of the entry if the entry exists.
In addition to allowing for better code organization, this approach is
also much faster if we have a large number of routing branches.
hash_branch
namespacesLet's say our application keeps growing, and now our store is very
popular. The routes/store.rb
file is now
getting as large as the app.rb
file was
before we split it up:
class App
"store" do |r|
hash_branch .on "items" do
r# routes for viewing items
end
.on "cart" do
r# routes for managing shopping cart
end
.on "checkout" do
r# routes for checking out
end
end
end
Thankfully, we can use the same approach for splitting the routing
branch for store
as we used originally for
splitting up the root routing tree. We add a routes/store
directory and create separate files
for the three routing branches under the store branch.
Dir.mkdir("routes/store")
File.write("routes/store/items.rb", "")
File.write("routes/store/cart.rb", "")
File.write("routes/store/checkout.rb", "")
Then we can move the routes for viewing items into routes/store/items.rb
. We again call hash_branch
, but this time we provide an
additional first argument, the "/store"
namespace. The reason we are using this namespace is that it is the
already routed path at this point in the routing tree. This is the
recommended approach if the already routed path does not contain any
placeholders. If the already routed path contains placeholders, the
recommendation is to use a symbol as the namespace.
class App
"/store", "items") do |r|
hash_branch(# routes for viewing items
end
end
We can move the routes for managing the shopping cart into routes/store/cart.rb
.
class App
"/store", "cart") do |r|
hash_branch(# routes for managing shopping cart
end
end
We can move the routes for checking out into routes/store/checkout.rb
.
class App
"/store", "checkout") do |r|
hash_branch(# routes for checking out
end
end
Then we can update the routing branch in the routes/store.rb
file to use r.hash_branches
. We don't provide any arguments
to r.hash_branches
, but it still correctly
delegates to the three routing branches for in the "/store"
namespace. The reason for this is that
r.hash_branches
takes an optional
argument, the namespace in which to look for routes. The default value
for the argument is the matched path (r.matched_path
). When r.hash_branches
is called in the app.rb
file, the matched path is the
empty string ""
, which is the default
namespace that hash_branches
uses if we do
not provide an argument. That's why it dispatches to the hash_branch "store"
block. When r.hash_branches
is called inside the hash_branch "store"
block, the matched
path is "/store"
, which is why the
call the dispatches to the one of the routing branches in the "/store"
namespace.
class App
"store" do |r|
hash_branch .hash_branches
rend
end
With the hash_branches
plugin, we can
split the root routing tree or any routing branch into separate routing
branches, which can be stored in separate files. This allows for easy
route organization on sites with hundreds or thousands of routes, with
very little routing overhead.
hash_branches
with placeholdersIn addition to the store routing branch getting large, the tasks routing branch is now also getting large. However, the tasks routing branch is a little different from the store routing branch, in that most of the routes occur after finding the related task. Here is what the tasks routing branch looks like.
class App
"tasks" do |r|
hash_branch .get true do
r# page showing all tasks
end
.on Integer do |id|
rnext unless @task = Task[id]
.is do
r.get do
r# page for editing task
end
.post do
r# action for updating task information
end
end
.on "dependencies" do
r# routes for managing task dependencies
end
.on "related" do
r# routes for managing related tasks
end
end
end
end
Let's say the sub-branches for dependencies
and related
are large and we want to move them into
their own routing files. First, we'll prepare the directory structure
and create the empty files.
Dir.mkdir("routes/tasks")
File.write("routes/tasks/dependencies.rb", "")
File.write("routes/tasks/related.rb", "")
We'll first edit the routes/tasks/dependencies.rb
file. This will
look somewhat different from the previous routing branch files. We'll
still call hash_branch
, but we can't use
the already routed path as the namespace. The already routed path could
be /tasks/1
, /tasks/24601
, or similar. However, because it is
not static, it is not possible to use it as a namespace. In this case,
it is recommended to pick an appropriate symbol as the namespace, in
this case :task
.
We can then move the routes for task dependencies into routes/tasks/dependencies.rb
.
class App
:task, "dependencies") do |r|
hash_branch(# routes for managing task dependencies
end
end
Likewise, we can then move the routes for related tasks into routes/tasks/related.rb
.
class App
:task, "related") do |r|
hash_branch(# routes for managing related tasks
end
end
Finally, we can edit the routes/tasks.rb
file to use these new routing
branches, by replacing them with a call to r.hash_branches(:task)
.
class App
"tasks" do |r|
hash_branch .get true do
r# page showing all tasks
end
.on Integer do |id|
rnext unless @task = Task[id]
.is do
r.get do
r# page for editing task
end
.post do
r# action for updating task information
end
end
.hash_branches(:task)
rend
end
end
type_routing
It's easiest from a maintenance perspective if our web application only has to return one type of data. For example, we may have a standard web application, where response bodies are HTML, or we may have a JSON API, where response bodies are JSON. However, in some cases, web applications need to vary the type of information they are returning per request. For example, we can use the same application to serve HTML response bodies to clients requesting HTML, and JSON response bodies to clients who request JSON.
We can handle multiple response types with core Roda, though
it may not be as pretty as we would like it to be. We can use a regular
expression, and make the file extension optional. We can then use a case
statement based on the file type requested. If the .html
extension is used, or no extension is
given, we can return an HTML response body using the render
plugin. If the .json
extension is used, we can return an array
of hashes, which the json
plugin will
convert into a JSON response body.
require "roda"
class App < Roda
:render, escape: true
plugin :json
plugin
do |r|
route .get /tasks(.html|.json)?/ do |type|
r@tasks = Task.all
case type
when nil, '.html'
"tasks")
view(when '.json'
@tasks.map do |task|
{id: task.id, name: task.title}
end
end
end
end
end
That approach works just fine, and for a single route is probably the best approach. However, if we need to do this for many routes, using regexps and case statements for every route where we want to vary the response body type is a lot of extra work. It would be better if there were a simpler way.
Thankfully, there is a simpler way. Roda includes a type_routing
plugin that offers a nicer way to
do this. We don't need to use regexps to handle the file extension, as
the type_routing
plugin makes routing
ignore the extension. So we can use a simpler string matcher
instead of a regexp matcher. The type_routing
plugin adds r.html
and r.json
match methods. r.html
will only
yield to the block if the request uses an .html
extension, or if the request does not use
an extension. r.json
will only yield to
the block if the request uses a .json
extension.
require "roda"
class App < Roda
:render, escape: true
plugin :json
plugin :type_routing
plugin
do |r|
route .get "tasks" do |type|
r@tasks = Task.all
.html do
r"tasks")
view(end
.json do
r@tasks.map do |task|
{id: task.id, name: task.title}
end
end
end
end
end
By default, if no extension is given, the type_routing
plugin will analyze the request's
Accept
header, and will choose the first
type in the Accept
header that the plugin
knows it is able to handle. If the Accept
header is not present or does not contain any types that the type_routing
plugin knows it is able to handle,
then the type_routing
plugin will assume
the requested format is HTML.
While only r.html
and r.json
are shown in the above example, the type_routing
plugin also supports r.xml
for XML format by default. It also
supports the use of user-specified formats. Additionally, we can
configure it to determine the requested type using only the extension
(and not the Accept
header), or using only
the Accept
header (and not the extension).
See the plugin documentation for more details.
not_found
By default, when we don't handle a route in Roda,
Roda will return an empty 404
response. In some browsers, that results in a blank white page, though
most popular browsers handle it by showing a browser-specific 404
page.
In the typical case, we want to customize what the 404
page looks like, and for that Roda offers a
not_found
plugin. We pass this plugin a
match block, and for empty 404
responses, such as when we do not handle a route in the routing tree,
the block is called. As with any match block, the return value
of the block is used as the response body. In this example, we want all
responses that are not handled to use the application's normal layout,
and render the views/404.erb
file.
require "roda"
class App < Roda
:render, escape: true
plugin
:not_found do
plugin '404')
view(end
end
The example above omits the route
block. That is allowed with Roda, it is the same as having an
empty route
block, resulting in every
request returning a 404
response.
status_handler
The not_found
plugin is actually just a
specific use of the status_handler
plugin.
The status_handler
plugin allows us to
define match blocks for specific status codes, and an empty
response with that status code results in calling the block. The not_found
plugin just sets up a handler for the
404
status code.
For example, let's say we want to show the same page for all
responses where access has been forbidden. This uses the 403
status code. We can use the status_handler
plugin and set up a handler for
the 403
code. As in the not_found
plugin example, we can render the
views/403.erb
file to handle all 403
responses. In the routing tree, we can check the IP address of the
request, and if it doesn't include 127.0.0.1
, we can set the status to 403
, then use next
to skip further processing (we could also use r.halt
).
require "roda"
class App < Roda
:render, escape: true
plugin
:status_handler
plugin
403) do
status_handler('403')
view(end
do |r|
route unless r.ip =~ /127.0.0.1/
.status = 403
responsenext
end
# rest of app
end
end
empty_root
Say we have an application with a route that matches exactly
/is
, and returns a trivial string.
require "roda"
class App < Roda
do |r|
route .is "is" do
r"IS"
end
end
end
Let's try this out. We go to http://localhost:9292/is and
everything works as expected. However, if we add a slash at the end, we
get a 404
status.
require "lucid_http"
GET "/is" # => "IS"
GET "/is/" # => "ERROR: 404 Not Found"
What if we use r.on
instead of r.is
?
class App < Roda
do |r|
route .s "is" do
r"IS"
end
.on "on" do
r"ON"
end
end
end
Well, that just works. Even with the trailing slash, or actually any trailing information in the path.
require "lucid_http"
GET "/is" # => "IS"
GET "/is/" # => "STATUS: 404 Not Found"
GET "/is/anything" # => "STATUS: 404 Not Found"
GET "/on" # => "ON"
GET "/on/" # => "ON"
GET "/on/anything" # => "ON"
It's generally a bad idea to handle arbitrary paths as references the
same path. In general, it's also a bad idea to treat /on
and /on/
as
the same path. They are separate paths and should not be treated as the
same path. One issue with treating them as the same path is that
relative paths work differently in such a resource, as /on
uses a relative base of /
and /on/
uses a
relative base of /on/
.
However, if we do want to treat these routes the same, we would need
to use separate route blocks for them, one where the remaining
path is empty (r.get true
), and one
where the remaining path is /
(r.root
).
class App < Roda
do |r|
route .is "is" do
r"IS"
end
.on "on" do
r.get true do
r"ON ROOT"
end
.root do
r"ON ROOT"
end
end
end
end
This duplicates the code to the route, which is also a bad idea. One way we can eliminate the duplication is a single route that matches both paths.
class App < Roda
do |r|
route .is "is" do
r"IS"
end
.on "on" do
r.get ["", true] do
r"ON ROOT"
end
end
end
end
The above works, but looks a little strange. It first tests if the
next segment in the path is /
. If so, it
consumes it. If not, it goes to the next matcher, true
, which always matches and doesn't consume.
In both the empty remaining path case and the /
remaining path case, the
remaining path is fully consumed after the matchers have been
processed.
The empty_root
plugin allows us to
simplify the r.get ["", true]
call, by
making r.root
match both the /
remaining path and the empty
remaining path.
class App < Roda
:empty_root
plugin
do |r|
route .is "is" do
r"IS"
end
.on "on" do
r.root do
r"ON ROOT"
end
end
end
end
Remember that it's probably a antipattern to use empty_root
. It should only be used if we want to
treat two distinct paths (trailing slash and no trailing slash) as the
same path.
slash_path_empty
The slash_path_empty
plugin is very
similar to the empty_root
plugin, and a
bad idea to use for the same reason. The difference between the two
plugins is that empty_root
only affects
the r.root
method, allowing empty paths in
addition to the /
path, while slash_path_empty
treats a
remaining_path of /
as empty
after processing the matchers in the r.is
, r.get
, and
r.post
match methods.
So another way of handling the previous example is to switch from
empty_root
to slash_path_empty
, and then use r.get "on"
. The slash_path_empty
plugin will then make r.get "on"
match requests for both /on
and /on/
.
class App < Roda
:slash_path_empty
plugin
do |r|
route .is "is" do
r"IS"
end
.get "on" do
r"ON ROOT"
end
end
end
Roda has a strong focus on security. However, it doesn't enable all security features by default because security features necessary in some applications, such as CSRF for a site using HTML forms, don't make sense in other applications such as JSON APIs. Just like most of Roda's other features, most of Roda's security features are implemented via plugins.
In the section on rendering, we discussed the :escape
option of the render
plugin, for preventing cross site
scripting. In this section, we'll look at the most important
security-related plugins that Roda includes.
route_csrf
We have Backpack application with a form that allows us to add items to an imaginary backpack.
Today we're preparing our backpack for a plane trip to Gotham. We pack the plane tickets, a book for reading, our cellphone, and our wallet.
While we are doing this, someone manages to sneak in a knife in our backpack using just a simple ruby script.
require "http"
= HTTP.post "http://localhost:9292/add",
response form: {item: "KNIFE"}
response# => #<HTTP::Response/1.1 302 Found {"Location"=>"/form", "Content-Type"=>"text/html", "Content-Length"=>"0"}>
Then we add our headphones. However, when the page reloads, we find out that an item that we never put into our backpack, that pesky knife, that will trigger all the alarms on the airport.
This kind of sneaky request represents a great safety threat for our
app. This security issue is called Cross Site Request Forgery
or CSRF for short. Roda has a plugin that prevents
this issue, called the route_csrf
plugin.
To prevent CSRF issues, we load the route_csrf
plugin into our application, then
have our routing tree call check_csrf!
. If
the request is a POST
request, the check_csrf!
call will check that the request was
the submission of a form that the site generated.
CSRF protection requires the support for sessions, so we'll load the
sessions
plugin for that (the sessions
plugin will be discussed in the later
section). In general, if we are not using sessions, we don't need to
worry about CSRF.
class App < Roda
:render, escape: true
plugin :sessions, secret: "some_long_secret"
plugin :route_csrf
plugin
do |r|
route
check_csrf!
# Rest of application
end
end
Now, how does this prevent an attacker to add a knife to our
backpack? Well, once we add the route_csrf
plugin and make sure that the routing tree calls check_csrf!
, Roda will check this
token's presence for every POST
request.
If a POST
request is received that doesn't
include the token, or the token isn't valid, the check_csrf!
call will raise an exception (by
default).
So if the attacker submits the same request to add an item, he will
now get Roda::RodaPlugins::RouteCsrf::InvalidToken
exception, meaning that the CSRF token didn't match the expected
value.
require "http"
= HTTP.post "http://localhost:9292/add",
response form: { item: "KNIFE" }
.body.to_s
response# => "Roda::RodaPlugins::RouteCsrf::InvalidToken: " \
# "encoded token is not a string"
# ...
If we try to add an item to the backpack, we get the same error in
our browser. This is because the check_csrf!
method cannot verify that the
request was a submission of one of the site's forms.
In order to make sure that the check_csrf!
call knows that the request was a
submission of one of the site's forms, we need to make sure that all
forms on the site submit a parameter that identifies the request as
valid. This parameter is called the CSRF token. We can generate the HTML
tag for this parameter using the csrf_tag
method. We pass this method the path that the form will submit to. The
csrf_tag
method will return the HTML to
use for a hidden input tag, so we need to make sure to use the double
equals when escaping (assuming we are using the render
plugin :escape
option as shown above).
<form action="/add" method="post">
<%== csrf_tag('/add') %>
<div class="input-group">
<input type="text" name="item" class="form-input"/>
<input type="submit" value="Add item!"
class="btn btn-primary input-group-btn"/>
</div>
</form>
If we take a look at the page source, we see the generated hidden field.
require "lucid_http"
GET "/form"
puts body
# >> <html>
# >> <head>
# >> ...
# >> </head>
# >> <body>
# >> ...
# >> <form action="/add" method="post">
# >> <input type="hidden" name="_csrf"
# >> value="aDaSZ18CNWqpIZd1...KBxn2bWmIj8rni0" />
# >> <div class="input-group">
# >> <input type="text" name="item" class="form-input"/>
# >> <input type="submit" value="Add item!"
# >> class="btn btn-primary input-group-btn"/>
# >> </div>
# >> </form>
# >>
# >> <h1> Backpack contents:</h1>
# >> ...
Now our application is protected against Cross Site Request Forgery!
typecast_params
One of the most common security issues in Ruby web applications stem
from unexpected parameter types being submitted by an attacker. For
example, let's say we are trying to implement a search feature in our
application. We can use r.params['field']
to get the value of the submitted parameter named field
, and pass that to our search method.
class App < Roda
:render, escape: true
plugin
do |r|
route .get "task", "search" do
r= r.params['field']
field next unless field
@tasks = Task.where(field: field).all
'tasks')
view(end
end
end
However, before doing that, we should probably consider what the type
could be returned by r.params['field']
.
With Rack's default parameter parsing, it could be nil
if no value was submitted, or a string if
the value was submitted. The above code handles both of those cases.
However, it could also be an array or a hash, which we probably did not
expect. In most cases, we know what type of value we expect to receive,
and it is safest if we only handle that type.
The typecast_params
plugin allows for
making sure that submitted parameters are of the expected type, or
handling conversions to the expected type. So if we expect the field
parameter to be submitted as a string, we
can use the typecast_params
plugin to
enforce that. Using typecast_params.str('field')
, we ensure that if
the field
parameter is submitted as an
array or a hash, that an error is raised.
class App < Roda
:render, escape: true
plugin :typecast_params
plugin
do |r|
route .get "task", "search" do
r= typecast_params.str('field')
field next unless field
@tasks = Task.where(field: field).all
'tasks')
view(end
end
end
One of the problems with the above examples is if the field
parameter is submitted but it is the empty
string. As the empty string is treated as true and not false in Ruby,
this will result in searching for the empty string. That may be what we
want, but it may not be. As handling the empty string differently than a
nonempty string is a fairly common case, typecast_params
has a shortcut, nonempty_str
. This will return nil
if the parameter is submitted but it is
empty, treating an empty parameter the same as not submitting a
parameter at all.
class App < Roda
:render, escape: true
plugin :typecast_params
plugin
do |r|
route .get "task", "search" do
r= typecast_params.nonempty_str('field')
field next unless field
@tasks = Task.where(field: field).all
'tasks')
view(end
end
end
If we have a lot of fields, manually checking each parameter value to
see if it is nil
is tedious. Assuming we
are using the HTML required
attribute on
our inputs, browsers shouldn't be submitting empty parameters. The code
is much easier to read and write if we assume things will be submitted
correctly, and raise an exception if they aren't. The typecast_params
plugin allows this style by
having a !
counterpart for all conversion
methods. The !
method will raise an
exception instead of returning nil
.
We can switch to using nonempty_str!
,
and remove the next unless field
call. If
a empty field
is submitted, the code will
raise an exception, which we can handle using an exception handler
(we'll discuss using an exception handler in a later section).
class App < Roda
:render, escape: true
plugin :typecast_params
plugin
do |r|
route .get "task", "search" do
r= typecast_params.nonempty_str!('field')
field
@tasks = Task.where(field: field).all
'tasks')
view(end
end
end
In many cases, we expect submitted parameters to represent integers.
However, by default, Rack's parameter parsing only supports string,
array, and hash parameters, it does not support integer parameters. So
parsing parameters as integers can be a manual process. If we expect the
field
parameter in the above example to be
an integer, then we might manually convert the parameter to an integer
using to_i
. Note that nil.to_i
and "".to_i
are both 0, and we probably only care
about positive integers, so we'll assume the field isn't submitted
unless to_i
returns a positive
integer.
class App < Roda
:render, escape: true
plugin
do |r|
route .get "task", "search" do
r= r.params['field'].to_i
field next unless field > 0
@tasks = Task.where(field: field).all
'tasks')
view(end
end
end
This approach has a couple of issues. It's a little verbose, but more
importantly it also results in a NoMethodError
if field
is submitted as an array or a hash. As
this is also a common need in web applications, typecast_params
has a shortcut to handle this
case, the pos_int
method. Using it, we can
clean up this code.
class App < Roda
:render, escape: true
plugin :typecast_params
plugin
do |r|
route .get "task", "search" do
r= typecast_params.pos_int('field')
field next unless field
@tasks = Task.where(field: field).all
'tasks')
view(end
end
end
HTTP query string parameters do not support nesting, just string keys and values, so why do we have to worry about array and hash parameters? This is because Rack will automatically parse certain key values specially, turning them into nested parameters. For example, Rack will take an HTTP query string such as:
?task[id]=1&task[title]=Study
and turn it into the following parameter hash:
{
"task" => {
"id" => 1,
"title" => "Study"
}
}
Let's say we had a form that used nested parameters, and we were
handling them using r.params
. We may do
something like:
class App < Roda
:render, escape: true
plugin
do |r|
route .is "tasks" do
r.get do
r"tasks"
view end
.post do
r= r.params['task']['title']
title Task.create(title: title)
.redirect
rend
end
end
end
In addition to having the same issues discussed previous, that r.params['task']['title']
could be nil
, or a string, hash, or array, this also can
raise a NoMethodError
, because it assumes
that r.params['task']
is a hash, when it
could be nil
or a string or array. typecast_params
can also handle this issue. If
we expect the task
parameter to be a hash
and the task['title']
parameter to be a
nonempty str, we can use a similar approach as shown previously.
We use typecast_params['task']
, which
will return an object similar to the object that typecast_params
returns, but scoped to the
nested parameter task
. We then call the
nonempty_str!
method on that object to get
the value for the task['title']
parameter,
or raise an error if there was no parameter submitted, the parameter is
not in the correct format, or the parameter is empty.
class App < Roda
:render, escape: true
plugin
do |r|
route .is "tasks" do
r.get do
r"tasks"
view end
.post do
r= typecast_params['task'].
title 'title'])
nonempty_str!(Task.create(title: title)
.redirect
rend
end
end
end
typecast_params
has many other features
for handling submitted parameters, such as handling arrays, dates,
boolean values, and uploaded files. We can also easily add support for
application-specific types. It can also transform submitted parameters
into a hash with typecasted values, suitable for passing to internal
methods that expect the correct type to be passed. Please see the typecast_params
plugin documentation for more
details.
default_headers
Roda has a default_headers
plugin that changes 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 will be used by browsers (as opposed to an
API), we may want to specify these headers.
The most important of these headers is Strict-Transport-Security
. We should set this
header if we would like our application to only be reachable on secure
(https://
) connections and not on insecure
(http://
) connections. There are a few
other headers such as X-Content-Type-Options
, X-Frame-Options
, and X-XSS-Protection
that we may want to enable, but
they mostly help older browsers.
Note that the default_headers
plugin
overrides Roda's default headers, and as such we should specify
the Content-Type
header when using it.
This plugin takes a hash of headers to use as the default headers.
class App < Roda
:default_headers,
plugin 'Content-Type'=>'text/html',
'Strict-Transport-Security'=>'max-age=16070400;',
'X-Content-Type-Options'=>'nosniff',
'X-Frame-Options'=>'deny',
'X-XSS-Protection'=>'1; mode=block'
end
content_security_policy
There is one other security-related header that we should probably
use in our applications (if the application is used by browsers), called
Content-Security-Policy
. However, this
header is complex to setup and is much more likely to vary per request,
so Roda offers a content_security_policy
plugin to handle it. The
Content-Security-Policy
header helps
prevent attacks in browsers, such as cross site scripting and
click-jacking. We can use it to specify what servers are allowed to
serve specific types of content on the requested page, where forms on
the page can submit, if the page is allowed to appear inside a frame,
and other features.
Basic configuration of the content_security_policy
plugin happens by
passing a block when loading the plugin. The block is yielded a content
security policy object, and we can call methods on that object to
configure the default policy. By convention, the block argument is named
csp
. The recommended way to use the plugin
is to use csp.default_src :none
at the top
of the block, which specifies that by default, nothing is allowed.
Everything that is needed is then specified separately after that, such
as style_src
for stylesheets, script_src
for javascript, and img_src
for images. Here's an example of a
restrictive default for Content-Security-Policy
.
class App < Roda
:content_security_policy do |csp|
plugin .default_src :none # deny everything by default
csp.style_src :self
csp.script_src :self
csp.connect_src :self
csp.img_src :self
csp.font_src :self
csp.form_action :self
csp.base_uri :none
csp.frame_ancestors :none
csp.block_all_mixed_content
cspend
end
This restrictive policy may work for some pages. However, maybe there
is a section of the site that needs to use some external javascript and
wants to disallow form submissions. The content_security_policy
plugin allows for
specifying changes to the policy on a per-route or per-branch basis. We
can get access to the content security policy object in the routing tree
by calling content_security_policy
, and
call methods on that to either replace existing settings or to append to
existing settings.
.on "special-section" do
r.add_script_src \
content_security_policy"https://external-javascript-site.com"
.form_action :none
content_security_policy
# rest of branch
end
We can also use the block configuration form in routing branches, which is likely to be preferable if we are changing more than one setting.
.on "special-section" do
rdo |csp|
content_security_policy .add_script_src "https://external-javascript-site.com"
csp.form_action :none
cspend
# rest of branch
end
Hopefully this book has shown how using a routing tree for handling web requests makes the request handling code simpler and more understandable, in addition to adding flexibility. It turns out that we can use a similar approach for handling non-web requests, and get much of the same benefits. One of the more common needs in web applications is dealing with email, and Roda includes plugins both for sending email and processing received email.
mailer
Roda's mailer
plugin allows us
to process requests to send email, similar to how core Roda
processes web requests. In most cases, we would use a separate Roda
subclass to handle requests to send email,
since email requests would be generated internally, they wouldn't come
from external sources.
Let's assume we wanted to add emailing to our tasks application, so
after the task is updated, we email the user who created the task. We
also want to email the user who created the task when the task is marked
finished. We'll add a Roda
subclass called
App::Mailer
that uses the mailer
plugin, and it will handle the email
sending.
Be aware that the mailer
plugin
requires the mail
gem, so we need to add
that to our Gemfile
and then run bundle install
.
"https://rubygems.org"
source
"roda"
gem "puma"
gem "tilt"
gem "erubi"
gem "mail" gem
We'll store the App::Mailer
code in
mailer.rb
.
class App::Mailer < Roda
:mailer
plugin
do |r|
route .on "tasks", Integer do |id|
runless @task = Task[id]
no_mail!
"tasks@example.com"
from .user.email
to task
.mail "updated" do
r"Task ##{id} Updated"
subject "Task #{task.name} has been updated!"
end
.mail "finished" do
r"Task ##{id} Finished"
subject "Task #{task.name} has been finished!"
end
end
end
end
Note how the above routing tree for handling requests to send mail is
very similar to the routing tree for handling web requests. The same
r.on
match method is used for
handling branches in the mail routing tree, and the "tasks"
, and Integer
matchers work the same way. The
r.mail
match method is very
similar to the r.get
match
method, in that it requires the remaining path be fully
consumed. Match blocks work similarly, with the return value of
the match block being used as the body of the email to
send.
Since the mail routing tree should only be called internally, it is
expected that all requests to send mail will be successful. If we don't
want to send an email, we need to call no_mail!
. If we don't call no_mail!
and the routing finishes without using
a body for the email, then an exception will be raised. This is why the
above example uses no_mail!
instead of
next
if it cannot find the related Task
.
The from
, to
, and subject
methods set the From
, To
, and Subject
headers for the email to send. As the above example shows, we can call
these methods at any level of the routing tree. In the above example,
the same From
and To
addresses are used for all emails for a
specific task. So the from
and to
methods are called in the r.on "tasks", Integer
branch. The subjects for
the task updated and task finished emails differ, so subject
is called separately in each of those
routes. Both r.mail
blocks return the body
for the email.
One of the advantages of Roda's mailer plugin is that we can
use most of Roda's other plugins with it. In most cases, we are
not going to want to specify email bodies in the routing tree, as they
are often quite large. Just as for web request bodies, we are going to
want to use a separate method to create them, and we can use the same
plugin in both cases, the render
plugin.
We can also use a plugin like symbol_views
to DRY up the rendering code, just like for a routing tree that handles
web requests.
So we could move the task updated mail body template to mailer_views/task_updated.erb
and the task
finished mail body template to mailer_views/task_finished.erb
, then update our
mail routing tree to use those templates.
class App::Mailer < Roda
:render, views: 'mailer_views', layout: nil
plugin :symbol_views
plugin :mailer
plugin
do |r|
route .on "tasks", Integer do |id|
runless @task = Task[id]
no_mail!
"tasks@example.com"
from .user.email
to task
.mail "updated" do
r"Task ##{id} Updated"
subject :task_updated
end
.mail "finished" do
r"Task ##{id} Finished"
subject :task_finished
end
end
end
end
Now that we have a mailer that will handle routes to send mail, how
do we actually use it? In general, we can just require the mailer file
in our app that handles web requests, and use the sendmail
method to send the email. Let's look at
an example web routing tree that handles this. It's basically the same
as a standard Roda routing tree, except for the Mailer.sendmail
calls in the cases where we
would like an email sent.
class App < Roda
require_relative 'mailer'
:typecast_params
plugin :render, escape: true
plugin :symbol_views
plugin
do |r|
route .on "tasks", Integer do |id|
rnext unless @task = Task[id]
.is do
r.get do
r:task
end
.post do
r= typecast_params.nonempty_str('status')
status @task.update(status: status)
Mailer.sendmail("/tasks/#{id}/updated")
.redirect
rend
end
.post "close" do
r@task.update(active: false)
Mailer.sendmail("/tasks/#{id}/finished")
.redirect "/tasks/#{id}"
rend
end
end
end
In this example, we are providing explicit paths when sending mail.
However, if our mail paths are the same as our web paths, we can use
Mailer.sendmail(r.path_info)
, which can be
simpler than constructing the path ourselves.
Using a path to send mail allows the Mailer
object to handle the routing using a
tree, with all of advantages this book has discussed. One other
advantage about using a path to send mail is that it makes it easy to
introduce a job system that will send the email later. This has
advantages at scale, so that the response to the web request does not
have to wait for the email to be sent. Using a single path string to
represent the email to send ensures that there are no serialization
issues when switching to use a job system.
In addition to the from
, to
, and subject
methods, the mailer
plugin also has cc
and bcc
methods
for setting the CC
and BCC
recipients. It also supports adding
attachments to emails, using the add_file
method. The mailer plugin also supports multipart emails, allowing us to
send both a plain text part and an HTML part via the text_part
and html_part
methods. For cases where we need to
pass objects from the web request handler to the mailer that we cannot
serialize into a string, we can provide additional arguments to the
sendmail
call, which results in the
related r.mail
block yielding the
additional arguments. See the mailer
plugin documentation for information on these features.
mail_processor
The mailer
plugin handles sending
email, but what if we want to handle receiving email and taking actions
based on received email? Thankfully, Roda has a plugin for
that, called mail_processor
. Like core
Roda and the mailer
plugin, this
plugin also uses the Roda routing tree to handle mail, but
because received mail doesn't have a path to operate on, routing it and
handling it are a bit different, and use a different set of methods.
With the mail_processor
plugin, we have
routing methods on the request instance, just like in core
Roda. However, the routing methods are named based on the parts
of the email, so the most common ones are r.from
, r.rcpt
,
r.subject
, and r.text
. As mail processing need not return a
result, the mail_processor
plugin uses an
explicit approach for marking a message as being handled, using the
r.handle
method. As a shortcut, all of the
routing method have a handle equivalent that combines handling and
matching in the same method call (e.g. r.handle_subject
).
Let's look at a simple example routing tree for handling a single
type of email. We'll assume we are working on the same application as in
the previous example. In the email to the user that a task is updated,
we inform them that they can close out the task by replying to the email
and including CLOSE
in their reply.
In the emails we sent out, we used tasks@example.com
as the From
address when sending, so replies should be
sent to that address. So we have our mail processor only handle that
email address. We'll also look for a matching subject, which we'll use
to find the ID of the related task. We'll need to check that the task is
active. If the task is active, and the reply includes the word CLOSE,
we'll close the task.
class MailProcessor < Roda
:mail_processor
plugin
do |r|
route .rcpt "tasks@example.com" do
r.subject /Task #(\d+) Updated/ do |task_id|
r"no matching task") unless task = Task[task_id.to_i]
unhandled_mail("task is not active") unless task.active?
unhandled_mail(
.handle_text /\bCLOSE\b/i do
r.update(active: false, closed_by: from)
taskend
end
end
end
end
There are a large variety of ways to get inbound email for
processing. However the inbound email is retrieved, the mail_processor
plugin operates on Mail
instances (these come from the mail
gem). To process an email, we call the
process_mail
method on the application
with the Mail
instance. This calls the
routing tree in order to handle the email.
Here's an example for processing an email that is stored in a file in
the file system. Mail.read
will read the
file and create a Mail
instance, and that
instance will be passed to process_mail
in
order to handle the mail.
MailProcessor.process_mail(
Mail.read('/path/to/message.eml')
)
It is possible to receieve email bodies in web requests. For example,
if the mail message was submitted as a parameter in a web request, we
could have a route in our web routing tree that calls process_mail
.
.post "handle-email" do
rMailProcessor.process_mail(
Mail.new(typecast_params.nonempty_str('mail'))
).status = 204
response''
end
We can also setup the mail_processor
plugin to process all mail in a POP3
or
IMAP
mailbox, using process_mailbox
.
MailProcessor.process_mailbox(
retreiver: Mail::POP3.new
)
For large mail processing routing trees, it's best to split them up
by recipient address. The mail_processor
plugin has built-in support similar to the hash_branches
plugin for this, using the rcpt
class method. In addition to allowing us to
split up mail processing into separate files, this also allows for
faster processing, assuming the recipient addresses are strings.
class MailProcessor < Roda
:mail_processor
plugin
"tasks@example.com" do |r|
rcpt .subject /Task #(\d+) Updated/ do |task_id|
r"no matching task") unless task = Task[task_id.to_i]
unhandled_mail("task is not active") unless task.active?
unhandled_mail(
.handle_text /\bCLOSE\b/i do
r.update(active: false, closed_by: from)
taskend
end
end
end
One general issue with email is that it can be trivially forged. The
mail message headers (e.g. From
and To
) do not have to reflect the email addresses
of the sender and recipient used when transferring the email. In order
to get this information, we need to have the mail server record the SMTP
envelope sender (MAIL FROM
) and recipient
(RCPT TO
) into a separate header in the
email. The rcpt
method that the mail_processor
plugin uses defaults to looking
at the To
and CC
headers, but if we want to change it to look
in a header set by mail server, we can use the mail_recipients
method to do so. If we have the
mail server store the actual recipients of the email in the X-SMTP-To
header, we can change the rcpt
method to respect that header.
class MailProcessor < Roda
:mail_processor
plugin
do
mail_recipients Array(header['X-SMTP-To'].decoded)
end
end
As anyone can spoof both the mail message From
, To
, and
CC
headers, as well as the SMTP envelope
sender and recipients, we cannot trust any received email was sent by
the person it claims to be sent from. If we want to have reasonable
assurance that the email received was a reply to an email the system
sent, it is best to include an HMAC when sending the email, and check
that a valid HMAC exists when receiving the email.
Let's modify App::Mailer
to include an
HMAC when sending the task updated email. We'll assume we are storing
the secret for the HMAC in an environment variable named APP_EMAIL_HMAC_SECRET
. In the mail route for the
task updated email, we take the ID of the task and use this to construct
a string containing an HMAC specific to the task. We'll set that string
to an instance variable named @ref
(short
for reference), and then we'll modify the task_updated.erb
template to include that
instance variable.
require 'openssl'
require 'securerandom'
class App::Mailer < Roda
HMAC_SECRET = ENV.fetch('APP_EMAIL_HMAC_SECRET')
:render, views: 'mailer_views', layout: nil
plugin :symbol_views
plugin :mailer
plugin
def make_ref(type, id)
= "#{type}:#{id}:#{SecureRandom.hex(16)}"
hmac_data = OpenSSL::HMAC.hexdigest(
hmac OpenSSL::Digest::SHA256.new,
HMAC_SECRET,
hmac_data
)"ref:#{hmac_data}:#{hmac}:ref"
end
do |r|
route .on "tasks", Integer do |id|
runless @task = Task[id]
no_mail!
"tasks@example.com"
from .user.email
to task
.mail "updated" do
r@ref = make_ref('task', id)
"Task ##{id} Updated"
subject :task_updated
end
.mail "finished" do
r"Task ##{id} Finished"
subject :task_finished
end
end
end
end
After doing that, we need to modify MailProcessor
to check that the correct
reference is provided. We need to access the APP_EMAIL_HMAC_SECRET
environment variable to
get the HMAC secret, which should have the same value in both processes.
We need to look in the body of the email to find the reference. We check
that the reference includes a valid HMAC, and that the task ID in the
reference matches the task ID in the subject.
class MailProcessor < Roda
HMAC_SECRET = ENV.fetch('APP_EMAIL_HMAC_SECRET')
:mail_processor
plugin
def check_hmac(data, hmac)
OpenSSL::HMAC.hexdigest(
OpenSSL::Digest::SHA256.new,
HMAC_SECRET,
data== hmac
) end
"tasks@example.com" do |r|
rcpt .subject /Task #(\d+) Updated/ do |task_id|
r"no matching task") unless task = Task[task_id.to_i]
unhandled_mail("task is not active") unless task.active?
unhandled_mail(
= /ref:(task:(\d+):\h+):(\h+):ref/
regexp .body(regexp) do |hmac_data, ref_task_id, hmac|
r"bad HMAC") unless check_hmac(hmac_data, hmac)
unhandled_mail(
unless ref_task_id.to_i == task_id.to_i
"task ID mismatch")
unhandled_mail(end
.handle_text /\bCLOSE\b/i do
r.update(active: false, closed_by: from)
taskend
end
end
end
end
Note that this approach provides reasonable security if the email is
sent to only one person. For the task updated email, this is the case as
it is sent to the user related to the task. However, if sending an email
to more than one recipient, we'll want to include the email address or
recipient ID as part of the reference, so we can more determine who
received the email that the reply was generated from. In some cases, we
may want to check that the recipient ID matches the sender of the email
(from
in the above code).
The mail_processing
plugin offers
support for hooks that are called for all handled mail, all unhandled
mail, as well as all mail regardless of whether it was handled. It also
offers support for customizing the extraction of reply text. See the
mail_processor
plugin documentation for
more details on these features.
In this section, we'll discuss plugins that don't fall neatly into one of the other categories.
sessions
HTTP is a stateless protocol, but most applications want to store
state between requests. The common way to persist this state between
requests is to store the state in a browser cookie. Roda ships
with a sessions
plugin that allows for
persisting state between requests using an encrypted browser cookie.
Because it is encrypted, the browser cannot see what the cookie
contains.
We load the sessions
plugin similar to
other plugins. However, unlike most plugins, the sessions
plugin requires that we provide a :secret
option when loading it. The :secret
option must be at least 64 bytes, and in
most cases it should be randomly generated. As it is a secret, it should
never be exposed to the user. It's common to store the secret in an
environment variable. To reduce the chance of the secret leaking
accidentally, we'll delete it from the environment when loading the
plugin.
class App < Roda
:sessions, secret: ENV.delete('APP_SESSION_SECRET')
plugin
# ...
end
If we start the app without setting the APP_SESSION_SECRET
environment variable, or when
the APP_SESSION_SECRET
environment
variable is too short, we'll notice that it doesn't work, resulting in
an exception. This is to prevent us from using an insecure
configuration.
$ rackup
Traceback (most recent call last):
4: from config.ru:3:in `<main>'
3: from config.ru:4:in `<class:App>'
2: from /some/path/lib/roda.rb:361:in `plugin'
1: from /some/path/lib/roda/plugins/sessions.rb:192:in `configure'
/some/path/lib/roda/plugins/sessions.rb:166:in `split_secret': sessions plugin :secret option must be a String (Roda::RodaError)
Let's see how this plugin works. We'll add a route in our app called
/intro/<name>
, which
captures the name
and stores it
in the session. The session is Ruby hash, and when creating the cookie,
it is first serialized to JSON before being encrypted. As JSON does not
support symbols, we shouldn't use symbols for session keys or in session
values.
We'll also add a /hello
route to our
application, showing the name stored in the session if there is one, or
World
if the name was not stored in the
session.
class App < Roda
:sessions, secret: ENV.delete('APP_SESSION_SECRET')
plugin
do |r|
route .get "intro", String do |name|
r.session["name"] = name
r"<h1>It's nice to meet you, #{name}!</h1>"
end
.get "hello" do
r"<h1>Hello #{r.session["name"] || 'World'}!</h1>"
end
end
end
Now let's navigate to http://localhost:9292/intro/Federico.
We see that we've been introduced.
Next, we'll navigate to /hello
. Even
though we didn't pass in a name this time, we're greeted by the name we
provided a moment ago.
From this we can see that our user session has been saved from one
request to the next. If we use a browser inspection tool to examine the
cookies set by this site, we can even see that a roda.session
cookie has been set.
Using an environment variable for the session secret makes it easy to configure our application without setting up any configuration files. This is important when using certain hosting services, such as Heroku. On the other hand, when we're developing locally, it's tedious to have to set the environment variable every time we start up the app.
$ APP_SESSION_SECRET=some_64_or_more_byte_secret rackup
To avoid having to specify the environment variable on the command
line every time we launch the application, we'll use a simple approach
for installation-specific application configuration. We'll have our
config.ru
file load a .env.rb
file it if exists.
require './.env' if File.exist?(".env.rb")
require "./app"
App run
If the .env.rb
file doesn't exist,
there is no change. If the .env.rb
does
exist, Ruby will load it. This approach is more flexible than similar
approaches that only handle setting environment variables, as they allow
for other custom settings, such as using custom Ruby load paths for
testing.
We can create the .env.rb
file and set
the environment variable if it doesn't already exist.
ENV["APP_SESSION_SECRET"] ||= "some_64_or_more_byte_secret"
In order to create the session secret, we can use Ruby's SecureRandom
library to generate random data for
us.
ruby -rsecurerandom -e 'puts SecureRandom.base64(64).inspect'
That will output a Ruby string that we can set as the value of the
APP_SESSION_SECRET
environment variable in
the .env.rb
file.
Remember, this .env.rb
file is just for
local development use, and should not be committed to the project's
source code repository. We'll want to set the APP_SESSION_SECRET
environment variable using
another method or at least use a different .env.rb
file when deploying the application to
production.
The sessions plugin only offers session capability to the
Roda app itself, but it does not offer session support for
middleware that the app uses. If we want middleware to also be able to
use the sessions support, we'll need to load a session middleware.
Roda ships with a middleware that we can use, that is based on
the sessions
plugin, named Roda::RodaSessionMiddleware
. We load this
middleware using use
, just like other
middleware, and it takes the same options that the session
plugin accepts.
require 'roda/session_middleware'
class App < Roda
Roda::RodaSessionMiddleware, secret: ENV.delete('APP_SESSION_SECRET')
use
# ...
end
It is also possible to use external session middleware, or session
middleware that ships with rack, such as Rack::Session::Cookie
.
flash
One common need in web applications that use standard HTML forms is
to show a message after a successful form submission. A typical flow is
to accept a form submission via POST
, and
if the submission is successful, after processing the form, the web
application will redirect to another page, and that page will show a
message that the form was successfully processed.
This type of functionality, where we show messages on the next
requested page in the same session, is often called flash, and
Roda has a flash
plugin that
implements support for it. The flash
plugin depends on support for sessions, so we'll use the sessions
plugin for sessions support.
We'll demonstrate this with a simple application. This application
will respond to a GET
request with a body
of Default
if there is no flash message.
However, for a POST
request, it will
redirect to the same path, and the next GET
request will have a response body of Success
.
class App < Roda
:sessions, secret: ENV.delete('APP_SESSION_SECRET')
plugin :flash
plugin
do |r|
route .get do
r['a'] || 'Default'
flashend
.post do
r['a'] = 'Success'
flash.redirect
rend
end
end
error_handler
We try our best to make sure our applications don't raise exceptions
at runtime, but it's nice to have a plan for how to handle such
exceptions. By default, Roda does not rescue exceptions that
are raised, so if our routing tree raises an exception, Roda
will raise an exception, and behavior at that point is dependent on the
webserver. Most webservers will show a generic 500
error page in that case.
Many sites would prefer that even if their application raises an
exception, that the user still sees a nicely formatted webpage
explaining that an error occurred. To meet that need, Roda has
an error_handler
plugin. The error_handler
plugin allows us to provide a
match block, and the block is called with the exception
instance. Just like any match block, the error_handler
block should return a value that
is used as the body of the response.
It can be tempting to just render a template in the error_handler
block. In this example, we have
our routing tree render the views/index.erb
template, and have our error_handler
render the views/500.erb
template if there is an error
doing that.
class App < Roda
:render, escape: true
plugin
:error_handler do |e|
plugin '500')
view(end
do |r|
route 'index')
view(end
end
This will work in most cases. However, this approach has an problem
if the cause of the exception is due to code in the layout. This is
because the error_handler
code will
attempt to render the layout, which will cause the error_handler
to raise an exception.
Roda does not attempt to handle exceptions raised in the error_handler
blocks, so an error in the layout
would result in an error being raised to the webserver.
Since unhandled exceptions in our applications should be fairly rare
(we hope), it's best to use the simplest possible code in the error_handler
. We can have a public/500.html
file for the error page, and
just return the contents of that file in the error_handler
. This approach is less likely to
cause us problems in the future if there is an error in the layout, but
can also result in the error page becoming out of date with the
layout.
class App < Roda
:render, escape: true
plugin
:error_handler do |e|
plugin File.read('public/500.html')
end
do |r|
route 'index')
view(end
end
path
One common need in web applications is constructing paths and URLs.
Roda includes a path
plugin to
help with path and URL construction.
The path
plugin easily handles static
paths. We call the path
class method with
the name of the path, and a string for the path, and it adds an instance
method that ends in _path
for the path.
With this application, all requests will use /tasks
as the response body.
class App < Roda
:path
plugin
:tasks, "/tasks"
path
do |r|
route
tasks_pathend
end
Static paths are simple, but not that interesting. Instead of calling
the path
method with a string for the
path, we can pass a block that returns the path. This block is called
with any additional arguments the path method is called with. With this
application, all requests will return /tasks/1
as the response body.
class App < Roda
:path
plugin
:tasks do |task|
path "/tasks/#{task.id}"
end
do |r|
route Task[1])
tasks_path(end
end
In the section above describing dynamic paths, there is some
duplication, since the method name and the argument both indicate that
we are trying to get the path for a task. Wouldn't it be great if we
could avoid this duplication? Thankfully, we can. Instead of passing a
symbol for the name of the path, we can pass a class. In the routing
tree, we call the path
method, and it uses
the class of the first argument to determine the type of path, and calls
the appropriate block. So we can change our application to use this
approach, which will still have all requests return /tasks/1
as the response body.
class App < Roda
:path
plugin
Task do |task|
path "/tasks/#{task.id}"
end
do |r|
route Task[1])
path(end
end
The path
method will only create a
method to return a path by default. If we want to also create a method
that returns the full URL, we can use a :url
option to create such a method. Assuming
our application is running at http://example.com
, with this application, all
requests will return http://example.com/tasks/1
as the response
body.
class App < Roda
:path
plugin
:tasks, url: true do |task|
path "/tasks/#{task.id}"
end
do |r|
route
tasks_urlend
end
It's also possible to use the :url_only
option, which will not create a method for the path, and will only
create a method for the full URL.
When using class-based paths, we can also get the full URL by using
the url
instance method instead of the
path
instance method. Assuming our
application is running at http://example.com
, this application will also
have all requests will return http://example.com/tasks/1
as the response
body.
class App < Roda
:path
plugin
Task do |task|
path "/tasks/#{task.id}"
end
do |r|
route Task[1])
url(end
end
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
simpler 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, as well as a setter method to modify the environment. It also
offers development?
, test?
, and production?
methods for checking for the most
common environments. Finally, it offers 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. Here's an example showing all of
the features of the environments
plugin.
class App < Roda
:environments
plugin
# => :development
environment # => true
development? # => false
test? # => false
production?
# Set the environment for the application
self.environment = :test
# => true
test?
do
configure # called, as no environments given
end
:development, :production do
configure # not called, as no environments match
end
:test do
configure # called, as environment given matches current environment
end
end
All of these methods are class methods, so if we want to check the
environment inside the routing tree, we need to use self.class.environment
.
By design, Roda doesn't ship with a project generator.
However, it still has conventions for how Roda applications
should be structured in terms of filesystem layout. Some of these
conventions are implemented as defaults, such as the use of the views
directory in the render
plugin, the assets
directory in the assets
plugin, and the public
directory in the public
plugin. Other conventions are just
recommendations and do not have associated defaults, such as the use of
the routes
directory in the hash_branches
plugin.
Roda's website has a Conventions page describing Roda's conventions, and it is recommended that Roda applications follow these conventions.
In this section, we will explore the conventions for working with small applications. Now, what does the term small application mean in this context? There's no mention about this on the Conventions page, but a reasonable guess would be an application with few routes, maybe a maximum of 10. In other words, an app that can be easily understood in a simple scan. Let's see what a small To-Do app skeleton looks like if it follows these conventions.
First, we have the Rakefile
. It should
contain all the application-related tasks, and the default task should
run all of the application's tests.
If we are using the assets
plugin, we
put all of our CSS and javascript files in the assets
directory.
We use the config.ru
file to let the
webserver know which application to run.
The db.rb
file should contain minimal
code to setup the database connection, without loading any models. This
will be used in cases where access to the database is needed, but we
don't want to load the models, such as when we are running
migrations.
All code for ORM models should be stored in separate files in the
models
directory, with one file per model
class. The models.rb
file should load the
db.rb file
, then configure the model
library, then load all model files in the models
.
If we decide to use an ORM that uses migrations to modify the
database schema, the migration files should be placed in the migrate
directory, with a separate file per
migration.
In the public
directory, we place all
of our static files that will be served directly to users.
All of our testing code should go either in test
or spec
directory, depending on the type of tests we are using.
In the a todo.rb
file, our
Roda application code goes. The convention is to call the file
the same as the application. So, if our app is called Todo
, the file name should be todo.rb
.
All template files including the layout should go in the views
directory.
So the root of our Roda application directory for the Todo
application would look something like
this.
|-- Rakefile
|-- assets/
|-- config.ru
|-- db.rb
|-- models.rb
|-- models/
|-- migrate/
|-- public/
|-- test/
|-- todo.rb
+-- views/
Now let's dig into the todo.rb
file.
Reading from the top, we first require roda
. We want to load the models.rb
file in the same directory, so we use
require_relative
for that. We then define
the Todo
class, which subclasses from
Roda
. Then we define any constants we need
to have in our application.
Next, we add any Rack middleware that we'll use in our application.
In this example, we'll use the Rack::ETag
middleware. It is best to limit the middleware the application uses to
the minimum (ideally none), as each Rack middleware the application uses
has a performance cost.
Then we add all the plugins we'll use in our application. I usually
group them by functionality if there are too many of them. We follow the
plugins by adding the route
block. For
small apps, we'll only use a single route
block with all the routes. After the route
block, we add all of the instance methods to be used in our route
block or inside the any views.
require 'roda'
require_relative 'models'
class Todo < Roda
# Constants
SECRET = ENV.delete('TODO_SECRET')
# Middleware
Rack::ETag
use
# Plugins
:render, escape: true, layout: './layout'
plugin :assets
plugin
:sessions, secret: SECRET
plugin
do |r|
route .on "something" do
r# ...
end
# ...
end
def panel
"<div class='panel'>#{yield}</div>"
end
end
In the config.ru
file, we need to load
the todo.rb
file. Unless we are running in
development mode, we freeze the application to avoid any thread-safety
issues at runtime, and to make sure the application's configuration is
not changed while the application is running. Then we let the webserver
know the Rack application to run, which we can get by calling Todo.app
.
require "./todo"
Todo.freeze unless ENV["RACK_ENV"] == "development"
Todo.app run
By using a config.ru
file, we can load
the application by running the rackup
command without any arguments.
In this section, we'll take a look at the conventions proposed for
larger applications. Once an application grows, maintaining the entire
application's routing tree in a single file becomes difficult. At that
point, it is recommended to split the routing tree into separate routing
files, using the hash_branches
plugin.
If we know in advance that our application will become large, we could create it with this structure in mind. However, that's not necessary, we can start small and only split things up if we need to do so by moving code around.
In general, the convention for a directory structure for a large Roda application is similar to a small Roda application, with the addition of some directories.
If we have a large number of instance methods that we are calling in
the routing tree or in any views, we can move those into files in the
helpers
directory, organized by the type
of method. In general, these files would just reopen the Todo
class and define methods, though we could
define the methods in Ruby modules that are included in the Todo
class.
We'll add a routes
directory for
handling the routing trees for separate branches in the application,
with one file per branch. For very large routing tree branches, we can
use subdirectories in the routes
directory
to handle smaller branches of that branch. We'll add subdirectories to
the views
directory, one for each branch
of the routing tree. We'll then use the hash_branch_view_subdir
plugin, which uses the
hash_branches
plugin to support separate
routing trees per branch, and the view_options
plugin to set an appropriate view
subdirectory for each routing branch.
The test
or spec
directory now should have separate
subdirectories for testing model code and web related code. Each
subdirectory should have separate files for testing separate parts of
the application. In the test/models
or
spec/models
directory, there should be one
file per model with tests for that model. In the test/web
or spec/web
directory, there should be one file per
routing tree branch for testing routes in that branch.
|-- Rakefile
|-- assets/
|-- config.ru
|-- db.rb
|-- helpers/
|-- migrate/
|-- models.rb
|-- models/
|-- public/
|-- routes/
| --- admin.rb
| +-- auth.rb
|-- test/
| --- models/
| +-- web/
|-- todo.rb
+-- views/
--- admin/
+-- auth/
roda-sequel-stack
Roda is a toolkit, not a framework, and there are many different ways Roda can be used. This can be overwhelming for new users, who aren't sure how a basic Roda app should be structured. If you are looking for the equivalent of an application generator for Roda that sets up the initial structure, you can use roda-sequel-stack. To use roda-sequel-stack as the base of your application, clone the repository and run rake:
git clone https://github.com/jeremyevans/roda-sequel-stack.git my_app
cd my_app
rake "setup[MyApp]"
This uses a directory structure that follows Roda's conventions for large applications, and supports:
hash_branch_view_subdir
which
integrates:
hash_branches
for splitting up the
routing treeview_options
for separate view
subdirectories per branchnot_found
and error_handler
pluginsrender
plugin :escape
option for XSS protectionroute_csrf
plugin for CSRF
protectioncontent_security_policy
plugin for
Content-Security-Policy
headerdefault_headers
plugin for other
security headersroda-sequel-stack uses the Sequel library for database access, but that and most other parts of roda-sequel-stack can be easily changed to handle application-specific needs.
lucid_http
gemRecently, I was in the process of writing this book and a course
about Roda. From the very beginning I needed a way to show the
interactions between the user and the server. I didn't like the idea of
relying too heavily in the browser. I really like the idea of showing as
much as I can in plain text, without leaving Emacs
or printing browser images on the
book.
Instead of going out looking for a library or application to help me
with this, I decided to just write a quick dirty snippet using the http.rb
library I learned in episode
428 of RubyTapas
.
Unfortunately, I didn't save the snippet, but it went something like this:
require "http"
= HTTP.get("http://localhost:9292/hello")
res .body.to_s # => "<h1>Hello World!</h1>"
res.status.to_s # => "200 OK" res
Which was too verbose for what I needed.
The HTTP.rb
library is excellent for
making HTTP requests. It's powerful and flexible enough for my needs.
It's aimed at performing the interactions, instead of
showing them in a digestible way.
So I started adding a couple of methods to help me clean the code and
developed a small DSL that evolved into a new library I called lucid_http
.
The Mastering Roda repository contains a appendix_lucid_http_app.ru
file that serves as
an example application for this appendix. We can run this example using
rackup
.
$ cd mastering-roda
$ rackup appendix_lucid_http_app.ru
This will start a webserver, and we'll have the example app up and
running to experiment. Once we have finished playing, we can just hit
Control+c
on the terminal and it will stop
running.
Now let's start exploring the lucid_http
gem. Start by opening an irb
shell in a separate process on the same
system, and loading the lucid_http
library.
$ irb -r lucid_http
Say we want to make a GET
request to
the hello
path.
We call the GET
method, and pass the
required path as an argument.
require "lucid_http"
GET "/hello"
# => "<h1>Hello World!</h1>"
We can find out that the base URL is for requests calling the base_url
method on the LucidHttp
module.
LucidHttp.base_url
# => "http://localhost:9292"
We can change it by assigning the new URL. and from now on, every new interaction will be using the new target URL.
LucidHttp.base_url
# => "http://localhost:9292"
LucidHttp.base_url = "https://fiachetti.gitlab.io/mastering-roda/"
LucidHttp.base_url
# => "https://fiachetti.gitlab.io/mastering-roda/"
Or, if we don't want to set the base_url
manually, we can just set the
TARGET_URL environment variable.
ENV["LUCID_HTTP_BASE_URL"] = "https://fiachetti.gitlab.io/mastering-roda/"
LucidHttp.base_url
# => "http://localhost:9292"
The other thing to notice is that we're preceding the path with a
forward slash. This is intentional. If we remove it, we'd get an error.
By design, lucid_http
takes the target URL
as is. It doesn't modify it at all. By not prepending a slash, we're
appending the hello string to the 9292 port, which
causes the error.
That's because the GET method doesn't automatically follow redirects.
Well, it doesn't unless we tell it to, by passing the follower: :follow
argument.
GET "/over_there", follower: :follow
# => "200 OK"
status # => "You have arrived here due to a redirection." body
Up to here, we've been dealing with "successful" requests. However, what happens when we it doesn't succeed? For example, say we have a route with some kind of error. As expected, we get the correct status code. However, when we render the body, we see a long ugly string with the whole backtrace. This is far from ideal for presentation purposes.
GET "/500"
# => "500 Internal Server Error"
status # => "ArgumentError: wrong number of arguments (given 0, expected 2+)\n\t/home/..." body
For this cases, lucid_http
provides an
error
method that will only return the
first line. This is easier on the eyes.
Rack::ShowExceptions
, which is included in rackup
's default development inenvironment. In a
production environment, the output if a Rack application raises an
exception will depend on the web server in use.
Now, if the request doesn't return a 500
code, lucid_code
will be nice enough to let us
know.
GET "/not_500"
# => "200 OK"
status # => "No error found" error
Now say we have a couple URLs, one of which returns a HTML response
and one of which returns a JSON response. By default, lucid_http
does not handle the JSON
response specially, it shows the raw JSON data.
GET "/hello_world"
# => "You said: hello_world"
GET "/hello_world.json"
# => "{\"content\":\"You said: hello_world\"" + \
# ",\"keyword\":\"hello_world\"" + \
# ",\"timestamp\":\"2016-12-31 15:00:42 -0300\"" + \
# ",\"method\":\"GET\"" + \
# ",\"status\":200}"
Now, that doesn't look easy on the eyes. Let's format it as a hash,
by passing the formatter: :json
argument.
That's better.
GET "/hello_world.json", formatter: :json
# => {"content"=>"You said: hello_world",
# "keyword"=>"hello_world",
# "timestamp"=>"2016-12-31 15:00:42 -0300",
# "method"=>"GET",
# "status"=>200}
lucid_http
also support a number of
other HTTP verbs we can use.
GET "/verb" # => "<GET>"
POST "/verb" # => "<POST>"
PUT "/verb" # => "<PUT>"
PATCH "/verb" # => "<PATCH>"
DELETE "/verb" # => "<DELETE>"
OPTIONS "/verb" # => "<OPTIONS>"
Finally, say we want to send some information to the server via a
POST
request.
We can do it by filling out the query string. Oops, that looks like JSON.
POST "/receive?item=book"
# => "{\"item\":\"book\"}"
However, what if we want to send it as a form? In that case, we just need to add the form as an argument, passing a Hash as the value.
POST "/receive", formatter: :json, form: {
item: "book",
quantity: 1,
price: 50.0,
title: "The complete guide to doing absolutely nothing at all."
}
# => {"item"=>"book",
# "quantity"=>"1",
# "price"=>"50.0",
# "title"=>"The complete guide to doing absolutely nothing at all."}
That was lucid_http
. It's not a full
featured HTTP library, but it provides the basic functionality we may
need when showing off a web app in text only mode.