NexusCS

Sinatra

Ruby
Quick reference for Sinatra, the lightweight Ruby DSL for creating web applications with minimal effort.
featured

Getting started

Hello World

require 'sinatra'

get '/' do
  'Hello world!'
end

Save as app.rb and run:

ruby app.rb
# View at http://localhost:4567

Installation

gem install sinatra
# Gemfile
gem 'sinatra'
gem 'puma'  # Web server

Application Types

Classic Style (single file)

require 'sinatra'

get '/' do
  'Hello'
end

Modular Style (class-based)

require 'sinatra/base'

class MyApp < Sinatra::Base
  get '/' do
    'Hello'
  end

  run! if app_file == $0
end

Running the App

ruby app.rb                    # Run directly
ruby app.rb -p 4000           # Custom port
ruby app.rb -e production     # Production mode
rackup config.ru              # With Rack

Routes

HTTP Methods

get '/' do
  # Handle GET request
end

post '/create' do
  # Handle POST request
end

put '/update/:id' do
  # Handle PUT request
end

patch '/update/:id' do
  # Handle PATCH request
end

delete '/destroy/:id' do
  # Handle DELETE request
end

options '/options' do
  # Handle OPTIONS request
end

link '/link' do
  # Handle LINK request
end

unlink '/unlink' do
  # Handle UNLINK request
end

Route Patterns

get '/hello/:name' do
  "Hello #{params['name']}!"
end

get '/posts/:id' do
  Post.find(params['id'])
end

# Wildcard (splat)
get '/download/*.*' do
  # params['splat'] => ['path/to/file', 'xml']
end

# Named splats
get '/download/:path/*.*' do |path, ext|
  # path => 'path/to/file'
  # ext => 'xml'
end

# Optional parameters
get '/posts/:id/?:format?' do
  # Matches /posts/1 and /posts/1/json
end

Regular Expressions

get %r{/hello/([\w]+)} do
  "Hello #{params['captures'].first}!"
end

get %r{/posts/(\d+)} do |id|
  Post.find(id)
end

# Named captures
get %r{/posts/(?<id>\d+)} do
  Post.find(params['id'])
end

Conditions

get '/user-agent', agent: /Chrome/ do
  "You're using Chrome"
end

get '/admin', host: 'admin.example.com' do
  "Admin area"
end

get '/api', provides: 'json' do
  { status: 'ok' }.to_json
end

# Custom conditions
set(:probability) { |value|
  condition { rand <= value }
}

get '/win', probability: 0.5 do
  "You won!"
end

Request & Response

Request Object

get '/foo' do
  request.body              # Request body
  request.scheme            # "http"
  request.script_name       # "/example"
  request.path_info         # "/foo"
  request.port              # 4567
  request.request_method    # "GET"
  request.query_string      # "foo=bar"
  request.content_length    # Length of body
  request.media_type        # Media type
  request.host              # "example.com"
  request.get?              # true
  request.form_data?        # false
  request.secure?           # false
  request.forwarded?        # true
  request.env               # Raw env hash
  request.xhr?              # AJAX request?
  request.referrer          # Referrer
  request.user_agent        # User agent
  request.ip                # Client IP
  request.url               # Full URL
end

Parameters

# Query parameters
get '/search' do
  params['q']              # Access query param
end

# Named parameters
get '/posts/:id' do
  params['id']             # Route parameter
  params[:id]              # Symbol access
end

# Form parameters (POST)
post '/users' do
  params['name']
  params['email']
end

# Nested parameters
post '/users' do
  params['user']['name']
  params['user']['email']
end

Response

get '/download' do
  status 200               # Set status code
  headers 'X-Custom' => 'Value'
  content_type 'text/xml'
  body '<xml></xml>'       # Set body
end

# Return values
get '/' do
  'String body'            # String
  [200, {}, ['Body']]      # Rack array
  redirect '/other'        # Redirect
  halt 401                 # Stop with status
  pass                     # Pass to next route
end

# Status shortcuts
not_found do
  'Not found'
end

error 500 do
  'Server error'
end

Headers & Content Type

get '/download' do
  content_type 'application/pdf'
  # or
  content_type :pdf

  attachment 'file.pdf'
  # Send file
end

# Set multiple headers
get '/' do
  headers 'X-Custom' => 'Value',
          'X-Other' => 'Another'
  'OK'
end

# Content negotiation
get '/data' do
  case request.accept.first
  when 'application/json'
    content_type :json
    { data: 'value' }.to_json
  when 'application/xml'
    content_type :xml
    '<data>value</data>'
  end
end

Cookies

get '/set' do
  response.set_cookie 'name', 'value'
  # or
  cookies[:name] = 'value'  # Rack::Protection
end

get '/get' do
  request.cookies['name']
  # or
  cookies[:name]
end

# Cookie options
get '/secure' do
  response.set_cookie 'token',
    value: 'secret',
    max_age: 3600,
    path: '/',
    secure: true,
    httponly: true,
    same_site: :strict
end

Templates

ERB Templates

get '/' do
  @title = 'Home'
  @items = ['a', 'b', 'c']
  erb :index
end

views/index.erb:

<h1><%= @title %></h1>
<ul>
  <% @items.each do |item| %>
    <li><%= item %></li>
  <% end %>
</ul>

Inline Templates

get '/' do
  erb :index
end

__END__

@@layout
<html>
  <body><%= yield %></body>
</html>

@@index
<h1>Hello</h1>

Template Engines

# ERB (default)
get '/' do
  erb :index
end

# Haml
get '/haml' do
  haml :index
end

# Slim
get '/slim' do
  slim :index
end

# Builder (XML)
get '/feed' do
  builder :feed
end

# Liquid
get '/liquid' do
  liquid :index
end

# Markdown
get '/markdown' do
  markdown :index
end

Template Options

get '/' do
  erb :index,
    layout: :post,           # Custom layout
    locals: { name: 'Bob' }, # Local variables
    layout_engine: :erb      # Layout engine
end

# Disable layout
get '/partial' do
  erb :widget, layout: false
end

# Inline template
get '/' do
  erb '<h1>Hello <%= name %></h1>',
    locals: { name: 'World' }
end

Layouts

views/layout.erb:

<html>
  <head>
    <title><%= @title || 'App' %></title>
  </head>
  <body>
    <%= yield %>
  </body>
</html>
# Set default layout
set :erb, layout: :custom_layout

# Per-route layout
get '/' do
  @title = 'Home'
  erb :index, layout: :special
end

Filters & Helpers

Before Filters

before do
  @note = 'Hi!'
  request.path_info = '/foo/bar/baz'
end

# Pattern matching
before '/protected/*' do
  authenticate!
end

# Conditions
before agent: /Chrome/ do
  # Only for Chrome
end

After Filters

after do
  puts response.status
end

after '/api/*' do
  response.headers['X-API'] = 'v1'
end

# Modify response
after do
  response.body = response.body.upcase
end

Around Filters

around do |block|
  puts 'before'
  block.call
  puts 'after'
end

around '/api/*' do |block|
  time = Time.now
  block.call
  puts "Request took #{Time.now - time}s"
end

Helpers

helpers do
  def format_date(date)
    date.strftime('%Y-%m-%d')
  end

  def current_user
    @current_user ||= User.find(session[:user_id])
  end

  def admin?
    current_user && current_user.admin?
  end
end

get '/posts/:id' do
  @post = Post.find(params[:id])
  @date = format_date(@post.created_at)
  erb :post
end

Helper Modules

module AuthHelper
  def authenticated?
    !session[:user_id].nil?
  end

  def require_login
    redirect '/login' unless authenticated?
  end
end

helpers AuthHelper

before '/admin/*' do
  require_login
end

Sessions & State

Session Configuration

enable :sessions

# Custom settings
set :sessions,
  expire_after: 2592000,  # 30 days
  secret: 'your_secret_key'

# Rack session
use Rack::Session::Cookie,
  key: 'rack.session',
  path: '/',
  secret: 'your_secret'

Using Sessions

get '/login' do
  session[:user_id] = 123
  redirect '/'
end

get '/dashboard' do
  user_id = session[:user_id]
  halt 401 unless user_id
  "Welcome, user #{user_id}"
end

get '/logout' do
  session.clear
  redirect '/'
end

# Check if key exists
get '/check' do
  session.key?(:user_id)
end

Flash Messages

# Using sinatra-flash gem
require 'sinatra/flash'

post '/login' do
  if valid_credentials?
    flash[:success] = 'Logged in!'
    redirect '/'
  else
    flash[:error] = 'Invalid credentials'
    redirect '/login'
  end
end

views/layout.erb:

<% if flash[:success] %>
  <div class="success"><%= flash[:success] %></div>
<% end %>
<% if flash[:error] %>
  <div class="error"><%= flash[:error] %></div>
<% end %>

Cookies vs Sessions

# Cookies (client-side)
get '/set-cookie' do
  response.set_cookie 'theme', 'dark'
end

# Sessions (server-side)
get '/set-session' do
  session[:theme] = 'dark'
end

# ⚠️ Sessions require enable :sessions
# ⚠️ Cookies visible to client
# ⚠️ Sessions more secure for sensitive data

Error Handling

Error Routes

not_found do
  'Page not found'
end

error do
  'Server error: ' + env['sinatra.error'].message
end

# Specific error codes
error 403 do
  'Access forbidden'
end

error 500..599 do
  'Server error'
end

Custom Errors

error MyCustomError do
  'Custom error occurred'
end

get '/trigger' do
  raise MyCustomError
end

Halt & Pass

get '/admin' do
  halt 401, 'Not authorized' unless admin?
  'Admin panel'
end

get '/items/:id' do
  item = Item.find(params[:id])
  pass if item.nil?  # Try next route
  item.to_json
end

# Alternative route
get '/items/:id' do
  'Item not found'
end

Error Handling in Production

configure :production do
  set :show_exceptions, false
  set :dump_errors, false
  set :raise_errors, false

  error do
    'Application error'
  end

  not_found do
    erb :not_found
  end
end

Configuration

Settings

set :port, 4000
set :bind, '0.0.0.0'
set :environment, :production
set :server, 'puma'

# Disable features
disable :logging
disable :protection

# Enable features
enable :sessions
enable :inline_templates

# Check setting
settings.port               # 4000
settings.environment        # :production
production?                 # true/false
development?                # true/false

Environment Configuration

configure :development do
  set :show_exceptions, true
  enable :logging
end

configure :production do
  set :show_exceptions, false
  disable :logging
end

configure :test, :production do
  disable :sessions
end

Custom Settings

set :api_key, ENV['API_KEY']
set :max_upload_size, 10 * 1024 * 1024

get '/api' do
  api_key = settings.api_key
  # Use api_key
end

Environment Variables

# Load from .env (sinatra/config_file)
require 'sinatra/config_file'

configure do
  config_file 'config.yml'
end

# Access settings
get '/' do
  settings.database_url
end

config.yml:

development:
  database_url: "sqlite://dev.db"

production:
  database_url: "postgres://prod.db"

Middleware & Rack

Using Middleware

require 'rack/protection'

use Rack::Protection
use Rack::Auth::Basic do |username, password|
  username == 'admin' && password == 'secret'
end

use Rack::Static,
  urls: ['/css', '/js'],
  root: 'public'

Built-in Protection

# Rack::Protection enabled by default
set :protection,
  except: [:json_csrf]  # Disable specific

# Disable all
set :protection, false

# Custom protection
set :protection,
  origin_whitelist: ['https://example.com']

Static Files

set :public_folder, 'public'
set :static, true

# Serve from public/
# public/css/style.css -> /css/style.css
# public/js/app.js -> /js/app.js

# Send file
get '/download' do
  send_file 'files/document.pdf',
    filename: 'report.pdf',
    type: 'application/pdf'
end

Rack Config

config.ru:

require './app'

use Rack::CommonLogger
use Rack::ShowExceptions

run Sinatra::Application
# or
run MyApp.new
rackup config.ru
rackup -p 4000
rackup -E production

Testing

Test Setup (RSpec)

# spec/spec_helper.rb
require 'rack/test'
require 'rspec'
require File.expand_path '../../app.rb', __FILE__

ENV['RACK_ENV'] = 'test'

RSpec.configure do |config|
  config.include Rack::Test::Methods
end

def app
  Sinatra::Application
end

Request Tests

# spec/app_spec.rb
describe 'My App' do
  it 'returns homepage' do
    get '/'
    expect(last_response).to be_ok
    expect(last_response.body).to include('Hello')
  end

  it 'creates user' do
    post '/users', name: 'Bob', email: 'bob@example.com'
    expect(last_response.status).to eq(201)
  end

  it 'returns 404' do
    get '/unknown'
    expect(last_response.status).to eq(404)
  end
end

Session & Headers

it 'sets session' do
  get '/login'
  expect(last_request.session[:user_id]).to eq(1)
end

it 'sets custom header' do
  get '/api'
  expect(last_response.headers['X-API-Version']).to eq('1.0')
end

it 'requires authentication' do
  header 'Authorization', 'Bearer token'
  get '/protected'
  expect(last_response).to be_ok
end

Testing Helpers

describe 'Helpers' do
  include MyApp.helpers

  it 'formats date' do
    expect(format_date(Date.today)).to match(/\d{4}-\d{2}-\d{2}/)
  end
end

Advanced Features

Streaming

get '/stream' do
  stream do |out|
    out << "Line 1\n"
    sleep 1
    out << "Line 2\n"
    sleep 1
    out << "Line 3\n"
  end
end

# Keep-alive
get '/events' do
  stream(:keep_open) do |out|
    EventMachine.add_periodic_timer(1) do
      out << "data: #{Time.now}\n\n"
    end
  end
end

WebSockets

require 'sinatra-websocket'

get '/ws' do
  if request.websocket?
    request.websocket do |ws|
      ws.onopen do
        ws.send('Connected!')
      end

      ws.onmessage do |msg|
        ws.send("Echo: #{msg}")
      end

      ws.onclose do
        puts 'Connection closed'
      end
    end
  else
    'WebSocket endpoint'
  end
end

Attachments

get '/download' do
  attachment 'report.pdf'
  send_file 'files/report.pdf'
end

# Inline disposition
get '/view' do
  attachment 'report.pdf', :inline
  send_file 'files/report.pdf'
end

Cache Control

get '/cached' do
  cache_control :public, max_age: 3600
  'This is cached for 1 hour'
end

get '/no-cache' do
  cache_control :no_cache
  'Fresh every time'
end

# Expires header
get '/expires' do
  expires 3600, :public
  'Expires in 1 hour'
end

# ETag
get '/etag' do
  etag 'unique-identifier'
  'Content'
end

Redirects

get '/old' do
  redirect '/new'
end

# With status code
get '/moved' do
  redirect '/new', 301
end

# Back
get '/back' do
  redirect back
end

# External redirect
get '/external' do
  redirect 'https://example.com'
end

Time & Date Helpers

get '/time' do
  time_for(:now)           # Current time
  time_for(:yesterday)     # Yesterday
  time_for(:tomorrow)      # Tomorrow
end

# Last modified
get '/page' do
  last_modified File.mtime('page.html')
  send_file 'page.html'
end

Gotchas

Route Order Matters

# ⚠️ Wrong - :id captures "new"
get '/posts/:id' do
  Post.find(params[:id])
end

get '/posts/new' do
  'New post form'
end

# ✅ Correct - specific routes first
get '/posts/new' do
  'New post form'
end

get '/posts/:id' do
  Post.find(params[:id])
end

Params Are Strings

get '/posts/:id' do
  id = params[:id]         # ⚠️ String "123"
  Post.find(id.to_i)       # ✅ Convert to integer
end

get '/search' do
  page = params[:page].to_i || 1
  per_page = params[:per_page].to_i || 10
  # Search with pagination
end

Block Return Values

get '/' do
  @title = 'Home'          # ⚠️ Returns 'Home', not rendered
  erb :index
end

get '/' do
  @title = 'Home'
  erb :index               # ✅ Last expression is returned
end

get '/' do
  process_data             # ⚠️ Returns process_data result
  erb :index               # This is not executed
end

Template Caching

# Development: Templates reloaded
configure :development do
  set :reload_templates, true
end

# Production: Templates cached
configure :production do
  set :reload_templates, false  # Default in production
end

# ⚠️ Changes to templates in production
# require app restart

Sessions Must Be Enabled

get '/set' do
  session[:user_id] = 123  # ⚠️ Error if sessions disabled
end

# ✅ Enable sessions
enable :sessions

get '/set' do
  session[:user_id] = 123  # Now works
end

Modular vs Classic

# Classic - methods at top level
require 'sinatra'

get '/' do
  'Hello'
end

# Modular - must inherit Sinatra::Base
require 'sinatra/base'

class MyApp < Sinatra::Base
  get '/' do
    'Hello'
  end

  # ⚠️ Must call run! or use config.ru
  run! if app_file == $0
end

Thread Safety

# ⚠️ Instance variables not thread-safe
@counter = 0

get '/increment' do
  @counter += 1            # Race condition!
end

# ✅ Use thread-safe storage
configure do
  set :counter, Concurrent::Atom.new(0)
end

get '/increment' do
  settings.counter.swap { |n| n + 1 }
end

Halt Stops Execution

get '/admin' do
  halt 401 unless admin?
  'Admin panel'            # Not reached if halt is called
end

before do
  halt 403 unless authorized?
  # Code after halt not executed
end

Deployment

Production Server

# Gemfile
gem 'puma'

# config/puma.rb
workers 2
threads 1, 6
port ENV.fetch('PORT', 4567)
environment ENV.fetch('RACK_ENV', 'production')
bundle exec puma -C config/puma.rb

Heroku Deployment

Procfile:

web: bundle exec puma -C config/puma.rb
heroku create
git push heroku main
heroku config:set RACK_ENV=production
heroku open

Docker

Dockerfile:

FROM ruby:3.2
WORKDIR /app
COPY Gemfile* ./
RUN bundle install
COPY . .
EXPOSE 4567
CMD ["bundle", "exec", "ruby", "app.rb", "-o", "0.0.0.0"]
docker build -t sinatra-app .
docker run -p 4567:4567 sinatra-app

Nginx Reverse Proxy

nginx.conf:

upstream app {
  server 127.0.0.1:4567;
}

server {
  listen 80;
  server_name example.com;

  location / {
    proxy_pass http://app;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
  }
}

Examples

RESTful API

require 'sinatra'
require 'json'

# List all
get '/api/posts' do
  content_type :json
  Post.all.to_json
end

# Get one
get '/api/posts/:id' do
  content_type :json
  post = Post.find(params[:id])
  halt 404, { error: 'Not found' }.to_json unless post
  post.to_json
end

# Create
post '/api/posts' do
  content_type :json
  post = Post.create(
    title: params[:title],
    body: params[:body]
  )
  status 201
  post.to_json
end

# Update
put '/api/posts/:id' do
  content_type :json
  post = Post.find(params[:id])
  halt 404 unless post
  post.update(
    title: params[:title],
    body: params[:body]
  )
  post.to_json
end

# Delete
delete '/api/posts/:id' do
  content_type :json
  post = Post.find(params[:id])
  halt 404 unless post
  post.destroy
  status 204
end

File Upload

post '/upload' do
  unless params[:file]
    halt 400, 'No file provided'
  end

  file = params[:file]

  # Validate
  halt 400, 'File too large' if file[:tempfile].size > 5_000_000

  # Save
  filename = file[:filename]
  path = File.join('uploads', filename)
  File.open(path, 'wb') do |f|
    f.write(file[:tempfile].read)
  end

  redirect "/uploads/#{filename}"
end

Authentication

require 'bcrypt'

helpers do
  def authenticated?
    session[:user_id]
  end

  def current_user
    @current_user ||= User.find(session[:user_id]) if authenticated?
  end

  def require_login
    redirect '/login' unless authenticated?
  end
end

get '/login' do
  erb :login
end

post '/login' do
  user = User.find_by(email: params[:email])
  if user && BCrypt::Password.new(user.password) == params[:password]
    session[:user_id] = user.id
    redirect '/'
  else
    flash[:error] = 'Invalid credentials'
    redirect '/login'
  end
end

get '/logout' do
  session.clear
  redirect '/'
end

before '/dashboard*' do
  require_login
end

Rate Limiting

require 'rack/attack'

class MyApp < Sinatra::Base
  use Rack::Attack

  Rack::Attack.throttle('req/ip', limit: 100, period: 60) do |req|
    req.ip
  end

  Rack::Attack.throttle('login/ip', limit: 5, period: 60) do |req|
    req.ip if req.path == '/login' && req.post?
  end
end

Also see