Sinatra

Sinatra #

Sinatra adalah micro-framework web yang memungkinkan kamu membangun aplikasi web dengan sangat sedikit kode. Berbeda dari Rails yang menyediakan segalanya dari ORM hingga mailer, Sinatra hanya memberikan fondasi minimal: routing HTTP dan rendering template. Tidak ada struktur direktori yang dipaksakan, tidak ada generator, tidak ada konvensi yang wajib diikuti — kamu memutuskan sendiri bagaimana mengorganisasi aplikasi. Kesederhanaan ini membuatnya ideal untuk API microservice, webhook handler, prototipe cepat, atau aplikasi kecil yang tidak membutuhkan kompleksitas Rails. Sebuah aplikasi Sinatra fungsional bisa ditulis dalam satu file dengan sepuluh baris kode.

Sinatra vs Rails — Kapan Pilih Mana? #

Sinatra cocok untuk:
  ✓ Microservice dan API sederhana
  ✓ Webhook handler (GitHub, Stripe, Twilio)
  ✓ Prototipe cepat — bisa jalan dalam 5 menit
  ✓ Aplikasi kecil yang tidak butuh database kompleks
  ✓ Tool internal dengan beberapa endpoint
  ✓ Saat tim sudah punya ORM dan library pilihan sendiri
  ✓ Embedded dalam aplikasi Rack yang lebih besar

Rails cocok untuk:
  ✓ Aplikasi web full-featured dengan banyak model
  ✓ Tim yang butuh konvensi yang konsisten
  ✓ Aplikasi yang butuh ActiveRecord, ActionMailer, dll.
  ✓ Ketika produktivitas lebih penting dari fleksibilitas
  ✓ Proyek jangka panjang dengan tim besar

Instalasi dan Aplikasi Pertama #

gem install sinatra
gem install puma   # web server yang direkomendasikan
# Gemfile
gem 'sinatra', '~> 3.1'
gem 'puma',    '~> 6.4'
# app.rb — aplikasi Sinatra paling sederhana
require 'sinatra'

get '/' do
  'Halo, dunia!'
end

get '/halo/:nama' do
  "Halo, #{params[:nama]}!"
end
# Jalankan
ruby app.rb            # berjalan di port 4567
ruby app.rb -p 3000    # port kustom
rackup config.ru       # via Rack

Routing — HTTP Methods dan Parameter #

Sinatra mendukung semua HTTP method dengan sintaks yang sangat bersih:

require 'sinatra'
require 'json'

# GET — ambil data
get '/produk' do
  content_type :json
  Produk.all.to_json
end

# POST — buat data baru
post '/produk' do
  data = JSON.parse(request.body.read, symbolize_names: true)
  produk = Produk.create!(data)
  status 201
  produk.to_json
end

# PUT — ganti seluruh resource
put '/produk/:id' do
  produk = Produk.find(params[:id])
  produk.update!(JSON.parse(request.body.read))
  produk.to_json
end

# PATCH — perbarui sebagian
patch '/produk/:id' do
  produk = Produk.find(params[:id])
  produk.update!(JSON.parse(request.body.read))
  produk.to_json
end

# DELETE — hapus
delete '/produk/:id' do
  Produk.find(params[:id]).destroy
  status 204
end

# HEAD dan OPTIONS
head '/produk' do
  # hanya header, tidak ada body
end

options '/produk' do
  headers['Allow'] = 'GET, POST, HEAD, OPTIONS'
  status 200
end

Parameter Route #

# Named parameter — :nama
get '/pengguna/:id' do
  "Pengguna ID: #{params[:id]}"
end

# Splat — *
get '/file/*' do
  "File: #{params[:splat].first}"
  # GET /file/dokumen/laporan.pdf → params[:splat] = ["dokumen/laporan.pdf"]
end

# Wildcard dengan nama
get '/aset/:jenis/*' do
  "Jenis: #{params[:jenis]}, Path: #{params[:splat].first}"
end

# Query string — otomatis tersedia via params
# GET /cari?q=laptop&halaman=2
get '/cari' do
  q       = params[:q]
  halaman = params[:halaman].to_i
  "Cari '#{q}' halaman #{halaman}"
end

# Regex sebagai route
get %r{/versi/(\d+)} do |versi|
  "Versi: #{versi}"
end

# Kondisi route
get '/admin', host_name: /^admin\./ do
  "Panel Admin"
end

set(:admin_only) { |_| condition { halt 403 unless current_user&.admin? } }
get '/rahasia', :admin_only => true do
  "Konten rahasia"
end

Request dan Response #

# Objek request
get '/info-request' do
  {
    method:       request.request_method,
    path:         request.path_info,
    url:          request.url,
    host:         request.host,
    port:         request.port,
    ip:           request.ip,
    user_agent:   request.user_agent,
    content_type: request.content_type,
    secure:       request.secure?,
    xhr:          request.xhr?,        # apakah AJAX request
    body:         request.body.read,
    referer:      request.referer
  }.to_json
end

# Headers request
get '/dengan-header' do
  token = request.env['HTTP_AUTHORIZATION']
  "Authorization: #{token}"
end

# Mengatur response
get '/custom-response' do
  status 202                              # set HTTP status
  headers 'X-Custom-Header' => 'nilai'   # set header
  headers 'Cache-Control'   => 'no-cache'
  body 'Response dengan header kustom'   # set body
end

# Content type
get '/json' do
  content_type :json
  { pesan: "ok", data: [1, 2, 3] }.to_json
end

get '/xml' do
  content_type :xml
  "<root><pesan>ok</pesan></root>"
end

# Redirect
get '/lama' do
  redirect '/baru'           # 302 Found
  redirect '/baru', 301      # 301 Moved Permanently
end

# Stream response (SSE / Server-Sent Events)
get '/stream' do
  content_type 'text/event-stream'
  stream(:keep_open) do |keluar|
    10.times do |i|
      keluar << "data: event ke-#{i}\n\n"
      sleep 1
    end
    keluar.close
  end
end

# Unduh file
get '/unduh/:nama' do
  send_file "public/files/#{params[:nama]}",
    filename:     params[:nama],
    disposition:  'attachment',
    type:         'application/octet-stream'
end

# Halt — hentikan eksekusi dan kirim response
get '/protected' do
  halt 401, { error: 'Unauthorized' }.to_json unless autentikasi_ok?
  { data: 'rahasia' }.to_json
end

Template — ERB, Haml, dan JSON #

ERB Template #

# app.rb
require 'sinatra'

get '/halaman' do
  @judul   = "Selamat Datang"
  @produk  = [{ nama: "Laptop", harga: 15_000_000 }]
  erb :halaman   # render views/halaman.erb
end

get '/inline' do
  nama = params[:nama] || "Tamu"
  erb "<h1>Halo <%= nama %>!</h1>"   # template inline
end
<!-- views/layout.erb — layout default untuk semua halaman -->
<!DOCTYPE html>
<html lang="id">
<head>
  <title><%= @judul || "Toko Ruby" %></title>
</head>
<body>
  <nav>
    <a href="/">Beranda</a>
    <a href="/produk">Produk</a>
  </nav>

  <main>
    <%= yield %>
  </main>

  <footer>© 2024 Toko Ruby</footer>
</body>
</html>
<!-- views/halaman.erb -->
<h1><%= @judul %></h1>
<ul>
  <% @produk.each do |p| %>
    <li><%= p[:nama] %> — Rp <%= p[:harga] %></li>
  <% end %>
</ul>

JSON Response Helper #

require 'sinatra'
require 'json'

# Helper untuk response JSON yang konsisten
helpers do
  def json_sukses(data, status_code = 200)
    content_type :json
    status status_code
    { status: "sukses", data: data }.to_json
  end

  def json_error(pesan, status_code = 400)
    content_type :json
    status status_code
    { status: "error", pesan: pesan }.to_json
  end
end

get '/api/produk' do
  json_sukses(Produk.all)
end

get '/api/produk/:id' do
  produk = Produk.find_by(id: params[:id])
  halt 404, json_error("Produk tidak ditemukan") unless produk
  json_sukses(produk)
end

Filters — Before dan After #

require 'sinatra'

# Before filter — dijalankan sebelum setiap route
before do
  content_type :json
  @mulai_waktu = Time.now
end

# Before filter untuk path tertentu
before '/admin/*' do
  halt 401, { error: 'Unauthorized' }.to_json unless session[:admin]
end

before '/api/*' do
  token = request.env['HTTP_AUTHORIZATION']&.delete_prefix("Bearer ")
  halt 401 unless token && valid_token?(token)
  @current_user = User.find_by_token(token)
end

# After filter — dijalankan setelah setiap route
after do
  durasi = ((Time.now - @mulai_waktu) * 1000).round(2)
  headers 'X-Response-Time' => "#{durasi}ms"
  logger.info "#{request.request_method} #{request.path}#{response.status} (#{durasi}ms)"
end

# After untuk path tertentu
after '/api/*' do
  # tambahkan CORS headers untuk semua response API
  headers(
    'Access-Control-Allow-Origin'  => '*',
    'Access-Control-Allow-Methods' => 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
    'Access-Control-Allow-Headers' => 'Content-Type, Authorization'
  )
end

Helpers — Method yang Bisa Dipakai di Route dan Template #

require 'sinatra'

helpers do
  def current_user
    @current_user ||= User.find_by(id: session[:user_id])
  end

  def login?
    !current_user.nil?
  end

  def wajib_login!
    halt 401, { error: 'Silakan login' }.to_json unless login?
  end

  def wajib_admin!
    halt 403, { error: 'Akses ditolak' }.to_json unless current_user&.admin?
  end

  def format_rupiah(angka)
    "Rp #{angka.to_i.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\1.').reverse}"
  end

  def pagination(koleksi, per_page: 20)
    halaman = (params[:page] || 1).to_i
    offset  = (halaman - 1) * per_page
    {
      data:        koleksi.limit(per_page).offset(offset),
      total:       koleksi.count,
      halaman:     halaman,
      per_halaman: per_page,
      total_halaman: (koleksi.count.to_f / per_page).ceil
    }
  end
end

get '/profil' do
  wajib_login!
  { user: current_user.as_json(only: [:id, :nama, :email]) }.to_json
end

get '/admin/dashboard' do
  wajib_login!
  wajib_admin!
  { statistik: ambil_statistik }.to_json
end

require 'sinatra'

# Aktifkan session (simpan di cookie yang di-encrypt)
enable :sessions
set :session_secret, ENV.fetch("SESSION_SECRET") { SecureRandom.hex(64) }

# Atau konfigurasi lebih detail
use Rack::Session::Cookie,
  key:          '_toko_session',
  expire_after: 2592000,   # 30 hari dalam detik
  secret:       ENV["SESSION_SECRET"],
  httponly:     true,
  secure:       ENV["RACK_ENV"] == "production"

# Gunakan session
post '/login' do
  data = JSON.parse(request.body.read, symbolize_names: true)
  user = User.authenticate(data[:email], data[:password])

  if user
    session[:user_id] = user.id
    session[:login_at] = Time.now.iso8601
    { sukses: true, user: { id: user.id, nama: user.nama } }.to_json
  else
    halt 401, { error: "Email atau password salah" }.to_json
  end
end

delete '/logout' do
  session.clear
  { sukses: true }.to_json
end

# Cookie langsung
get '/set-cookie' do
  response.set_cookie("preferensi", {
    value:    "gelap",
    expires:  Time.now + (30 * 24 * 60 * 60),
    httponly: true,
    path:     "/"
  })
  "Cookie diset"
end

get '/baca-cookie' do
  preferensi = request.cookies["preferensi"]
  "Preferensi: #{preferensi}"
end

Aplikasi Modular — Sinatra::Base #

Untuk aplikasi yang lebih besar, gunakan gaya modular dengan Sinatra::Base:

# app/controllers/produk_app.rb
require 'sinatra/base'
require 'json'

class ProdukApp < Sinatra::Base
  configure do
    enable :logging
    set :show_exceptions, false
  end

  configure :development do
    set :show_exceptions, true
  end

  # Error handling
  error 404 do
    content_type :json
    { error: "Tidak ditemukan", path: request.path }.to_json
  end

  error 500 do
    content_type :json
    logger.error env['sinatra.error'].message
    { error: "Server error" }.to_json
  end

  error ArgumentError do
    content_type :json
    status 400
    { error: env['sinatra.error'].message }.to_json
  end

  before do
    content_type :json
  end

  get '/' do
    { status: "ok", versi: "1.0.0" }.to_json
  end

  get '/produk' do
    halaman  = (params[:page] || 1).to_i
    per_page = (params[:per_page] || 20).to_i.clamp(1, 100)
    aktif    = params[:aktif] != "false"

    produk = Produk.where(aktif: aktif)
                   .order(created_at: :desc)
                   .limit(per_page)
                   .offset((halaman - 1) * per_page)

    {
      data:        produk.as_json(only: [:id, :nama, :harga, :stok]),
      meta:        { halaman: halaman, per_halaman: per_page, total: Produk.where(aktif: aktif).count }
    }.to_json
  end

  get '/produk/:id' do
    produk = Produk.find_by(id: params[:id])
    halt 404 unless produk
    produk.as_json.to_json
  end

  post '/produk' do
    data = JSON.parse(request.body.read, symbolize_names: true)
    produk = Produk.new(data.slice(:nama, :harga, :stok, :kategori_id))

    if produk.save
      status 201
      produk.as_json.to_json
    else
      status 422
      { errors: produk.errors.full_messages }.to_json
    end
  end

  patch '/produk/:id' do
    produk = Produk.find_by(id: params[:id])
    halt 404 unless produk

    data = JSON.parse(request.body.read, symbolize_names: true)
    if produk.update(data.slice(:nama, :harga, :stok, :aktif))
      produk.as_json.to_json
    else
      status 422
      { errors: produk.errors.full_messages }.to_json
    end
  end

  delete '/produk/:id' do
    produk = Produk.find_by(id: params[:id])
    halt 404 unless produk
    produk.destroy
    status 204
  end
end

Struktur Direktori Modular #

toko_api/
├── Gemfile
├── config.ru          ← titik masuk Rack
├── app/
│   ├── controllers/
│   │   ├── produk_app.rb
│   │   ├── pengguna_app.rb
│   │   └── auth_app.rb
│   ├── models/
│   │   ├── produk.rb
│   │   └── pengguna.rb
│   └── helpers/
│       └── auth_helper.rb
├── config/
│   ├── database.yml
│   └── initializers/
├── db/
│   └── migrate/
└── public/
# config.ru — titik masuk Rack
require 'bundler/setup'
Bundler.require

require_relative 'config/database'
require_relative 'app/controllers/auth_app'
require_relative 'app/controllers/produk_app'
require_relative 'app/controllers/pengguna_app'

# Gabungkan beberapa aplikasi Sinatra dengan Rack::URLMap
run Rack::URLMap.new(
  '/api/auth'     => AuthApp,
  '/api/produk'   => ProdukApp,
  '/api/pengguna' => PenggunaApp
)

Middleware Rack #

Sinatra adalah aplikasi Rack — bisa menggunakan middleware Rack standar:

require 'sinatra/base'
require 'rack/cors'

class TapiApp < Sinatra::Base
  # CORS middleware
  use Rack::Cors do
    allow do
      origins  'https://frontend.example.com', /localhost:\d+/
      resource '*',
        headers: :any,
        methods: [:get, :post, :put, :patch, :delete, :options],
        credentials: false
    end
  end

  # Logging middleware
  use Rack::CommonLogger

  # Gzip compression
  use Rack::Deflater

  # Rate limiting (dengan rack-attack gem)
  use Rack::Attack

  # Custom middleware
  class JsonBodyParser
    def initialize(app)
      @app = app
    end

    def call(env)
      if env['CONTENT_TYPE']&.include?('application/json')
        body = env['rack.input'].read
        env['rack.input'].rewind
        env['parsed_json_body'] = JSON.parse(body, symbolize_names: true) rescue {}
      end
      @app.call(env)
    end
  end

  use JsonBodyParser
end

Integrasi ActiveRecord #

# Gemfile
# gem 'sinatra',      '~> 3.1'
# gem 'activerecord', '~> 7.1'
# gem 'sinatra-activerecord'

require 'sinatra'
require 'sinatra/activerecord'

# config/database.yml dibaca otomatis oleh sinatra-activerecord
set :database_file, 'config/database.yml'

class Produk < ActiveRecord::Base
  validates :nama,  presence: true
  validates :harga, numericality: { greater_than: 0 }
end

get '/produk' do
  content_type :json
  Produk.where(aktif: true).to_json
end

post '/produk' do
  content_type :json
  produk = Produk.new(JSON.parse(request.body.read, symbolize_names: true))
  if produk.save
    status 201
    produk.to_json
  else
    status 422
    { errors: produk.errors }.to_json
  end
end
# Rake tasks untuk database (dari sinatra-activerecord)
bundle exec rake db:create
bundle exec rake db:migrate
bundle exec rake db:seed

Testing dengan Rack::Test #

# spec/app_spec.rb
require 'spec_helper'
require 'rack/test'
require_relative '../app'

RSpec.describe ProdukApp do
  include Rack::Test::Methods

  def app
    ProdukApp
  end

  describe "GET /produk" do
    it "mengembalikan daftar produk" do
      create_list(:produk, 3, aktif: true)

      get '/produk'

      expect(last_response.status).to eq(200)
      body = JSON.parse(last_response.body, symbolize_names: true)
      expect(body[:data].length).to eq(3)
    end

    it "filter berdasarkan aktif" do
      create(:produk, aktif: true)
      create(:produk, aktif: false)

      get '/produk', aktif: "true"

      body = JSON.parse(last_response.body, symbolize_names: true)
      expect(body[:data].length).to eq(1)
    end
  end

  describe "POST /produk" do
    it "membuat produk baru" do
      post '/produk',
        { nama: "Laptop", harga: 15_000_000, stok: 5 }.to_json,
        { 'CONTENT_TYPE' => 'application/json' }

      expect(last_response.status).to eq(201)
      expect(JSON.parse(last_response.body)['nama']).to eq("Laptop")
    end

    it "gagal jika nama kosong" do
      post '/produk',
        { harga: 15_000_000 }.to_json,
        { 'CONTENT_TYPE' => 'application/json' }

      expect(last_response.status).to eq(422)
      body = JSON.parse(last_response.body, symbolize_names: true)
      expect(body[:errors]).to include("Nama can't be blank")
    end
  end

  describe "GET /produk/:id" do
    it "mengembalikan produk berdasarkan ID" do
      produk = create(:produk, nama: "Monitor")

      get "/produk/#{produk.id}"

      expect(last_response.status).to eq(200)
      expect(JSON.parse(last_response.body)['nama']).to eq("Monitor")
    end

    it "404 jika tidak ditemukan" do
      get '/produk/99999'
      expect(last_response.status).to eq(404)
    end
  end
end

Deployment #

# config.ru — untuk deployment production
require 'bundler/setup'
Bundler.require

require_relative 'app'

# Konfigurasi production
set :environment, :production
set :port,        ENV.fetch('PORT', 4567)
set :bind,        '0.0.0.0'

run Sinatra::Application
# Jalankan dengan Puma (production)
bundle exec puma config.ru -p $PORT -e production

# Dengan Procfile (Heroku/Render)
# web: bundle exec puma config.ru -p $PORT -e $RACK_ENV

# Dockerfile
# FROM ruby:3.3-alpine
# WORKDIR /app
# COPY Gemfile* ./
# RUN bundle install
# COPY . .
# EXPOSE 4567
# CMD ["bundle", "exec", "puma", "config.ru", "-p", "4567", "-e", "production"]

Ringkasan #

  • Satu file untuk aplikasi kecil — Sinatra tidak memaksakan struktur apapun; aplikasi sederhana bisa ditulis dalam satu file tanpa direktori khusus.
  • Gunakan Sinatra::Base untuk aplikasi yang tumbuh — gaya modular memungkinkan pembagian kode ke file-file terpisah dan penggabungan via Rack::URLMap.
  • before dan after filter untuk cross-cutting concerns — autentikasi, logging, dan CORS header cukup ditulis sekali di filter, bukan di setiap route.
  • helpers do...end untuk method yang dibagi — method di dalam helpers tersedia di semua route dan template; gunakan untuk current_user, wajib_login!, format helper, dll.
  • halt untuk early exithalt 401 atau halt 404, body menghentikan eksekusi route dan langsung mengirim response; lebih bersih dari if/else berlapis.
  • content_type :json sebelum response — selalu set content type secara eksplisit; bisa diletakkan di before filter agar berlaku untuk semua route API.
  • Rack::Test untuk testing — tidak perlu server sungguhan; Rack::Test::Methods menyediakan get, post, put, delete yang bekerja langsung dengan aplikasi Rack.
  • Error handler per tipe exceptionerror ArgumentError do...end memungkinkan penanganan error yang granular tanpa rescue di setiap route.
  • send_file untuk unduhan — lebih efisien dari membaca file ke memori; Sinatra (via Rack) streaming file langsung ke response.
  • Sinatra bukan pengganti Rails — untuk aplikasi dengan banyak model, relasi database kompleks, dan tim besar, Rails lebih tepat; Sinatra bersinar di microservice, webhook, dan API sederhana.

← Sebelumnya: Ruby on Rails   Berikutnya: ORM Adapter →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact