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
Session dan Cookie #
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::Baseuntuk aplikasi yang tumbuh — gaya modular memungkinkan pembagian kode ke file-file terpisah dan penggabungan viaRack::URLMap.beforedanafterfilter untuk cross-cutting concerns — autentikasi, logging, dan CORS header cukup ditulis sekali di filter, bukan di setiap route.helpers do...enduntuk method yang dibagi — method di dalamhelperstersedia di semua route dan template; gunakan untukcurrent_user,wajib_login!, format helper, dll.haltuntuk early exit —halt 401atauhalt 404, bodymenghentikan eksekusi route dan langsung mengirim response; lebih bersih dariif/elseberlapis.content_type :jsonsebelum response — selalu set content type secara eksplisit; bisa diletakkan dibeforefilter agar berlaku untuk semua route API.Rack::Testuntuk testing — tidak perlu server sungguhan;Rack::Test::Methodsmenyediakanget,post,put,deleteyang bekerja langsung dengan aplikasi Rack.- Error handler per tipe exception —
error ArgumentError do...endmemungkinkan penanganan error yang granular tanparescuedi setiap route.send_fileuntuk 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.