ruby / web framework
I use a ~500-line Ruby and Rack-based web framework. The app is a monolith serving HTML with templates, Postgres via DB, middleware, and background work via job queues.
Rack app with routing
The core is a Rack app that maps URL patterns to handler objects:
module Framework
class App
def initialize
@routes = {"GET" => [], "POST" => []}
end
def get(path, handler)
@routes["GET"] << [compile(path), path, handler]
end
def post(path, handler)
@routes["POST"] << [compile(path), path, handler]
end
def redirect(from, to)
@routes["GET"] << [compile(from), from, [:redirect, to]]
end
# Rack interface
def call(env)
req = Rack::Request.new(env)
handler, params = match(req.request_method, req.path_info)
if handler.nil?
return [404, {"Content-Type" => "text/html"}, [File.read("public/404.html")]]
end
if handler.is_a?(Array) && handler[0] == :redirect
return [301, {"Location" => handler[1]}, []]
end
env["router.params"] = params
response = catch(:halt) do
handler.call(env)
end
response
end
private def match(method, path)
@routes[method]&.each do |pattern, _, handler|
if (m = pattern.match(path))
return [handler, m.named_captures]
end
end
nil
end
private def compile(path)
pattern = path.gsub(/:([a-z_]+)/, '(?<\1>[^/]+)')
Regexp.new("\\A#{pattern}\\z")
end
end
end
Routes are registered at boot:
app = Framework::App.new
app.get "/health", HealthHandler::Check.new(db)
app.get "/companies/:id", CompaniesHandler::Show.new(db)
app.post "/companies/create", CompaniesHandler::Create.new(db)
app.redirect "/old-path", "/new-path"
run app
Path parameters like :id become named captures
available via env["router.params"].
The catch(:halt) in call allows handlers
to short-circuit with throw :halt, response.
This is useful for auth checks that need to redirect
without awkward return if patterns.
Handlers
Handlers are instantiated once at boot with their dependencies,
then call(env) is invoked per-request.
Subclasses implement handle (not call)
so setup and auth are automatic:
module Framework
class Handler
attr_reader :db, :req, :params, :session
def initialize(db)
@db = db
end
def call(env)
setup(env)
require_login
handle
end
def setup(env)
@env = env
@req = Rack::Request.new(env)
@params = @req.params.merge(env["router.params"] || {})
@session = env["rack.session"] || {}
end
def handle
raise NotImplementedError, "#{self.class} must implement #handle"
end
def render(template, locals = {})
html = Template.render(template, locals.merge(default_locals))
[200, {"Content-Type" => "text/html"}, [html]]
end
def redirect(location)
[303, {"Location" => location}, []]
end
def head(status, headers = {})
[status, headers, []]
end
private def require_login
if current_user
return true
end
@session[:return_to] = @req.fullpath
throw :halt, redirect("/login")
end
private def default_locals
{
current_user: current_user,
params: params,
session: @session,
flash: @env["app.flash"] || {}
}
end
end
end
A handler hierarchy keeps auth secure by default.
Handler requires login.
PublicHandler skips auth (for login pages, webhooks, health checks).
AdminHandler adds require_admin.
A health check handler:
module HealthHandler
class Check < Framework::PublicHandler
def handle
db.exec("SELECT 1")
head 200
end
end
end
A typical page handler:
module CompaniesHandler
class Show < Framework::Handler
def handle
co = Companies::Find.new(db).call(id: params["id"])
if co.nil?
return [404, {"Content-Type" => "text/html"}, [File.read("public/404.html")]]
end
render "companies/show", co: co
end
end
end
Encrypted cookies
For sensitive values like auth tokens, use AES-256-GCM encryption rather than just signing:
class EncryptedCookieJar
def initialize(request_cookies, secret)
@request_cookies = request_cookies
@key = OpenSSL::Digest::SHA256.digest(secret)[0, 32]
end
def [](name)
data = @request_cookies[name]
if data.nil?
return nil
end
decrypt(data)
end
private def encrypt(plaintext)
cipher = OpenSSL::Cipher.new("aes-256-gcm").encrypt
cipher.key = @key
iv = cipher.random_iv
ciphertext = cipher.update(plaintext) + cipher.final
tag = cipher.auth_tag
Base64.urlsafe_encode64(iv + tag + ciphertext)
end
private def decrypt(data)
raw = Base64.urlsafe_decode64(data)
cipher = OpenSSL::Cipher.new("aes-256-gcm").decrypt
cipher.key = @key
cipher.iv = raw[0, 12]
cipher.auth_tag = raw[12, 16]
cipher.update(raw[28..]) + cipher.final
rescue
nil
end
end
Use Rack::Session::Cookie (signed) for the session.
Use the encrypted cookie jar only for the auth token.
Boot sequence
Require gems explicitly rather than using Bundler.require.
Explicit requires make the dependency graph visible.
Bundler.require hides what you depend on
and adds autoload magic that slows boot.
Running with Puma
require "puma"
require_relative "lib/db"
require_relative "lib/framework/boot"
require_relative "lib/framework/server"
DB.configure do |c|
c.pool_size = ENV.fetch("WEB_CONCURRENCY").to_i * ENV.fetch("WEB_THREADS").to_i
c.reap = true
end
conf = Puma::Configuration.new do |c|
c.app Framework::Server.app(DB.pool)
c.environment ENV.fetch("APP_ENV")
c.bind "tcp://0.0.0.0:#{ENV.fetch("PORT")}"
c.preload_app!
c.threads ENV.fetch("WEB_THREADS").to_i, ENV.fetch("WEB_THREADS").to_i
c.workers ENV.fetch("WEB_CONCURRENCY").to_i
c.before_worker_boot do
DB.reset_pool! # fresh connections after fork
end
end
Puma::Launcher.new(conf).run
Testing handlers
Handler tests use Rack::Test against the routes
without the full middleware stack.
See ruby / test for the test framework.
class HandlerTest < Test
include Rack::Test::Methods
def app
Framework::Server.routes(db)
end
def sign_in
set_cookie("remember_token=test")
end
end
class HealthTest < HandlerTest
def test_health
resp = get("/health")
ok { resp.status == 200 }
end
end
class CompaniesShowTest < HandlerTest
def test_show
sign_in
co = insert_company(name: "Acme Inc")
resp = get("/companies/#{co.id}")
ok { resp.status == 200 }
ok { resp.body.include?("Acme Inc") }
end
end
HTTP methods
GET and POST only. HTML forms can only submit GET and POST. Two verbs simplify routing, middleware, and CSRF handling.