Efficient routing is fundamental to how well a web application functions, it's the ability to map incoming requests to methods inside your code using patterns that match request URL's.

Luckily for us, Sinatra has a powerful routing mechanism built into it that gives you the flexibility you need for modern day apps & API's.

Here's a simple example in a classic style app:

require 'sinatra'

get '/products' do
  # Display the products page
end

post '/products' do
  # Create a new product
end

put '/products/:id' do
  @product = Product.find_by_id(params[:id])

  if @product
    @product.name = "Macbook Pro"
    @product.save
  else
    halt 404, "Product not found"
  end

  200
end

delete '/products/:id' do
  @product = Product.find_by_id(params[:id])

  if @product
    @product.destroy
  else
    halt 404, "Product not found"
  end

  200
end

Here's the same example as above in modular style:

require 'sinatra/base'

class MyApp < Sinatra::Base
  get '/products' do
    # Display the products page
  end

  post '/products' do
    # Create a new product
  end

  put '/products/:id' do
    @product = Product.find_by_id(params[:id])

    if @product
      @product.name = "Macbook Pro"
      @product.save
    else
      halt 404, "Product not found"
    end

    200
  end

  delete '/products/:id' do
    @product = Product.find_by_id(params[:id])

    if @product
      @product.destroy
    else
      halt 404, "Product not found"
    end
  end

  200
end

As you'll notice, there's no difference in route handling between the module and classic approaches.

Sinatra routes are simply Ruby methods with an associated code block that get called every time a request matches the given route pattern.

The :id part of the route pattern is a named parameter and will be accessible in your code from the params hash: params[:id], but I'll describe these in further detail later in this post.

Important: Routes are matched in the exact order that they are defined in your code, this means that second route in the below example will never get matched because we have the exact same pattern further up in the code.

require 'sinatra'

get '/products/:id' do
  # Display the products page
end

get '/products/12' do
  # Route that won't get fired
end

For the above code to work as you expect it to, you'll need to switch the two routes around so that the route containing the named parameter can act as a kind of 'catch-all' route at the end. The explicit routes should always be added towards the top of your route file or controller. I can guarantee you that this has been the source a many hours of frustration by developers working with Sinatra.

Route method names correspond to HTTP 1.1 request method names to make things easier to remember.

Available Route Methods in Sinatra:

Method General use-case example
get() Performing basic HTTP GET requests like loading pages
post() Used when posting form data
put() Used when updating resources
patch() Similar to post(), but used when updating one field of a resource
delete() Used when deleting resources
options() Determine options available for an associated resource
link() Link relationships between existing resources
unlink() Unlink relationships between existing resources

When building user facing web application, you'll be using get() and post() methods the majority of the time.

The additional request methods become useful when you start creating API's as I'll demonstrate in a later post.

Named Parameters

Suppose you have built a CRM that uses segments to manage the customers you have, what would be the best way to retrieve a list of customers for any given segment?

It's actually quite simple:

get '/customers/:segment_name' do
  @segment = Segment.find_by_name(params[:segment_name])

  if @segment
    @customers = Customer.where("segment_id = ?", @segment.id)
    erb :"customers/by_segment"
  else
    halt 404, "The segment could not be found"
  end
end

In your customers/by_segment.erb view, you could have something like:

<h2>Customers in <%= @segment.name %> segment</h2>

<% @customers.each do |c| %>
  <h5><%= c.name %></h5>
  <p><%= c.email %></p>
<% end %>

You can specify as many named parameters as needed in your route pattern, their corresponding values will be accessible from the params hash.

Wildcard Routing

Wildcard routing is useful when you are matching routes that might have many different contextual values, here's an example:

# Matches: http://mysite.com/products/1/macbook-pro
# Matches: http://mysite.com/products/2/mac-mini
# Matches: http://mysite.com/products/3/dell-laptop
get '/products/:id/*' do
  @product = Product.find_by_id(params[:id])

  if @product
      erb :"product/show"
  else
      halt 404, "Product not found"
  end
end

Even though we never actually need the values matched in the wildcard pattern in the above example, it makes sure we have good looking & SEO friendly URL's to work with.

If we need to use the values matched by the wildcard pattern, we can access them in the following two ways:

Using the params[:splat] Array

A simple example allowing a user to download a file hosted on your server:

# Matches: http://mysite.com/download/portfolio.pdf
# Matches: http://mysite.com/download/sample.psd
# Matches: http://mysite.com/download/project.zip
get '/download/*.*' do
  file = "#{params[:splat].first}.#{params[:splat].last}"
  path = "<path to files directory>/#{file}"

  if File.exists? path
  send_file(
    path, :disposition => 'attachment', : filename => file
  )
  else
      halt 404, "File not found"
  end
end

Using Block Parameters

# Matches: http://mysite.com/download/portfolio.pdf
# Matches: http://mysite.com/download/sample.psd
# Matches: http://mysite.com/download/project.zip
get '/download/*.*' do |filename, ext|
  file = "#{filename}.#{ext}"
  path = "<path to files>/#{file}"

  if File.exists? path
  send_file(
    path, :disposition => 'attachment', : filename => file
  )
  else
      halt 404, "File not found"
  end
end

The params[:splat] array is a regular Ruby Array and it's elements can be accessed as you would with any other array.

If no matches are found, the :splat element won't exist in the params hash - so do check it.

Routing with Regular Expressions

For a bit more power, you can use regular expressions to match routes using the params hash & block method as per wildcard routing, here's how:

# Matches: http://mysite.com/download/project.zip
# Matches: http://mysite.com/download/portfolio.pdf
get %r{/download/([\w]+).(zip|pdf)} do
  file = "#{params[:captures].first}.#{params[:captures].last}"
  path = "<path to files>/#{file}"

  if File.exists? path
  send_file(
    path, :disposition => 'attachment', : filename => file
  )
  else
      halt 404, "File not found"
  end
end

The values for the matches that occur within the Regex Group brackets ([\w]+) and (zip|pdf), will be available in the params[:captures] array. Note that in the above example, only filenames with either a .zip or .pdf extension will match.

Regular expressions can be difficult to understand at times, but are rather fun when you get the hang of it.

I would recommend having a good regular expression cheat sheet and testing tool at hand if you need this kind of routing.

Routes with POST & GET Variables

In addition to the above methods of matching routes, you can add any POST or GET variables onto your URL's and the values will also be available in the params hash.

http://mysite.com/dashboard?view=days&group=users

Translates to:

get '/dashboard' do
  @view = params[:view] #days
  @group = params[:group] #users
end

The same goes for POST variables from web forms:

<form method="POST" action="/dashboard">
  <input type="text" name="view" value="days" />
  <input type="text" name="group" value="users" />
  <input type="submit" value="Submit" />
</form>

Translates to:

get '/dashboard' do
  @view = params[:view] #days
  @group = params[:group] #users
end

It's important to note that the params hash is what you'll use to read user input in your code - similar to Rails.

Routing Conditions

A routing condition ensures that a route is only matched if the provided condition is met. Sinatra has a few built-in conditions you can use, but it's common practice to create your own. They are currently poorly documented, but save you a lot of code if you know how to use them correctly.

Let's start by first defining a condition that checks to see if a user is successfully logged in (common use-case) before they are able to access a page:

app.rb

# Helper
def logged_in?
    !session[:user_id].nil?
end

# Define the condition
set(:auth) do
  condition do
    if !logged_in?
      redirect '/signin'
    else
      @user = User.find_by_id(session[:user_id])
    end
  end
end

get '/dashboard', :auth => true do
  @user = User.find_by_id(session[:user_id])

  if @user
      erb :"dashboard/index"
  else
      halt 401, "Unauthorized"
  end
end

In the above example, we first create a small helper method logged_in? to check if a user_id has been saved into the current session. If yes, this would mean that we have a user successfully signed in.

Next up, we define our custom condition set(:auth) that uses our helper method to determine if a use is logged in. If not, we immediately redirect the request back to the log in page. If the user is logged in, we load the user data into an instance variable that becomes accessible to all routing methods of your application.

When defining the route method, you now add a second parameter to it to indicate that a condition is expected :auth => true - a hash with a key matching the name of our custom defined condition.

If false is returned from the condition block, the accompanying route will not be matched and Sinatra will attempt to match the next route in the queue.

I mentioned earlier that routes will get matched in the order that they are defined and therefore only the first of two identical routes will be executed. This behaviour is not entirely true when using conditions, here's why:

set(:demo) do
  condition do
      false
  end
end

get '/user/settings', :demo => true do
  # This route will never match
end

get '/user/settings' do
  # This route will be matched
end

The custom condition :demo will always return false in our example, meaning that the first route method's condition will never be matched even with a position URL match. Sinatra will skip over it and execute the second route method instead.

You can also pass parameters directly into the conditions and use them in your evaluation block of the condition, let's re-write the above example accordingly using a the :demo condition from above:

set(:demo) do |val|
  condition do
      val
  end
end

get '/user/settings', :demo => false do
  # This route won't be matched
end

get '/user/settings', :demo => true do
  # This route will be matched
end

In the above example, we added a parameter to the conditions block set(:demo) do |val| and that value is being returned directly from the condition. This means that we can directly control whether or not the condition will pass by simply changing the boolean value of :demo when calling the corresponding route method.

Conditions can also accept multiple parameters by using the splat operator, let's take a look in another simple example:

set(:demo) do |*params|
  condition do
    if params.first == params.last
      true # condition passes
    else
      false # condition fails
    end
  end
end

get '/user/settings', :demo => [:param1, :param2, …etc] do
  erb :"user/settings"
end

The important thing to note here is *params with the Ruby splat operator. It is an array of all parameters passed into the condition. You can pass as many parameters into the condition as you need and access them using regular Ruby array accessors.

In the above example, the condition will pass if the first value in the params array matches the last and fails if it does not.

Sinatra also has built in conditions that you can use, have a look at the documentation for an explanation on these.