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.

← All articles