久々に sinatra app を作った

「いつもの」が結構ありそうなので書いておく。

app.rb ペラ 1 でツラくなったときの対策はだいたい sonots パイセンの ちっちゃくはじめておっきく育てる sinatra アプリの作り方 に書いてあって、これは今でも有効なので読んでおくと良いです。

ディレクトリ構成

REPO
├── app.rb
├── bin/
├── config/
│  ├── database.yml
│  ├── initializers/
│  └── locales/
├── config.ru
├── Gemfile
├── Gemfile.lock
├── helpers/
├── models/
├── public/
└── views/
  • sinatra らしさをなるべく残してある
    • 例えば config/boot.rb を用意するかは非常に悩んだのだけれど、起点は app.rb であって欲しい
  • models/, helpers/ は分けた
    • app.rb に直接書いていると 5 model ぐらいしか無くても数百行になってさすがに見通しが悪いので
  • config/initializers/ を置いた
    • 置き場所に迷ったものを放り込みやすくて便利

もう一声大きくなったらもっと Railsディレクトリ構造に近づける *1 けど、一旦こんな感じで。

セキュリティ対策

erubi を使う

XSS 対策のために escape: true を使いたい。

https://github.com/jeremyevans/erubi/blob/1.9.0/lib/erubi.rb#L57

erubi gem を入れた上で

class App < Sinatra::Base
  set :erb, escape: true
  ...

しておくと <%= ... %> がデフォルトで escape される。

String ごとに html_safe フラグを付けられるわけではないので ActiveSupport::SafeBuffer ほど便利ではないが、無いより 100 億倍良い。

これは完全に余談なんだけど Padrino は SafeBuffer が導入されていて羨ましい。

ところで Erubi には「escape が優先される」と書いてあるので escape: true って使ったんだけど、検索しても 1 件も引っかからないのな。

escape_html: true で検索するとめちゃくちゃ尊い PR が出てきた。(先月じゃーん

github.com

Rack::Protection を使う

デフォルトで使われているんだけど、remote_token であって authenticity_token ではないので置き換える。

set :protection, use: %i[authenticity_token], except: %i[remote_token]

remote_token は referrer が同じだったら通している。 https://github.com/sinatra/sinatra/blob/v2.0.8.1/rack-protection/lib/rack/protection/remote_token.rb#L17-L19

authenticity_token は (GET, HEAD, OPTIONS, TRACE 以外は) 常にチェックする。 https://github.com/sinatra/sinatra/blob/v2.0.8.1/rack-protection/lib/rack/protection/authenticity_token.rb#L98-L106

これで例え XSS があったとしても CSRF されなくなって安心。

ところでこの記事書こうと思ってついでに自社の過去事例を漁っていたら 14 年前の面白記述を見つけた。サードパーティ製ツールを大事にしている精神が感じられて最高でした。

diary.hatenastaff.com

SessionStore を Cookie から変更

クライアント側に構造体を保存しているとセッションの無効化が困難、みたいなアレです。

ritou.hatenablog.com

ユーザごとのログインセッションを任意に破棄できるようにしようと思うとログイン処理のたびに 1 レコード増えることになって、「素朴な Web アプリケーションが許される時代は終わったなぁ」って気がしますね。

よく使う extension, middleware

ほとんど Sinatra::Contrib だな。

Sinatra::ConfigFile

YAML を置いておくとアプリケーション内から設定にアクセスできるマン。 大枠で言うと Rails::Application.config_for と同じ使い心地です。

Sinatra::ContentFor

# layout.erb
<title><% if content_for?(:title) %><%= yield_content(:title) %> | <% end %>APP_NAME</title>
# users/show.erb
<% content_for :title, @user.name %>

みたいなヤツ。 title の例だけで無いとツラいのが分かるはず。

Sinatra::JSON

json @obj

すると JSON dump して content-type 付けて返してくれるヤツ。

Sinatra::Reloader

development で有効にすると開発が楽で幸せ。

configure :development do
  register Sinatra::Reloader
  also_reload "models/**/*"
  also_reload "helpers/**/*"
end

Rack::Flash

rack-flash3 gem でやってる。

redirect 時に notice を渡す書き方もしたいので、Padrino::Flash からパクっておきます。

https://github.com/padrino/padrino-framework/blob/0.15.0/padrino-core/lib/padrino-core/application/flash.rb#L179-L215

REPL

bin/console にこれを置いておくだけでだいぶ楽。

#!/usr/bin/env ruby
require "bundler/setup"
require "irb"
require File.expand_path("../app", __dir__)

def reload!
  Sinatra::Reloader.perform(App)
end

IRB.start(__FILE__)

reload!ActiveSupport::Dependencies と違って丁寧に remove_const とかをやっているわけではなくただ require し直しているだけだけど、model を読み込み直したいぐらいの用途なら足る。

initialize

ディレクトリ構成のところで言った config/initializers を一番最初に読み込ませている。

Dir[File.expand_path("config/initializers/*", __dir__)].sort.each {|f| require f }

ActiveRecord

以下を食わせている。前半は establish_connection 用。後半は timestamp 周り。

path = File.expand_path("config/database.yml", __dir__)
specs = YAML.safe_load(ERB.new(IO.read(path)).result, aliases: true)
ActiveRecord::Base.configurations = specs.stringify_keys
ActiveRecord::Base.establish_connection(env.to_sym)

Time.zone_default = Time.find_zone!("Asia/Tokyo")
ActiveRecord::Base.time_zone_aware_attributes = true
ActiveRecord::Base.default_timezone = :utc

前半は sinatra-activerecord gem で良いんだけど、メンテが滞ってるのを見て から使わなくなってしまった。薄いのでシュッと剥がせたし。最近は次に名乗り出てくれたメンテナが元気なので大丈夫かもしれない。

timestamp は Rails だと Railtie が良い感じにやってくれているが、生で ActiveRecord を使うときは自分で指定する必要がある。 https://github.com/rails/rails/blob/v6.0.3/activerecord/lib/active_record/railtie.rb#L69-L74

あとは models/ 以下に配置して、 ApplicationRecord も欲しいので

class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true
end
require_relative "application_record"
class User < ApplicationRecord
  ...
end

のような感じにしてある。

ActiveRecord::RecordNotFound

ActiveRecord::RecordNotFound を 500 じゃなく 404 で返したいので以下を書く。

error ActiveRecord::RecordNotFound do |e|
  not_found
  # 404 HTML を返したかったらこう
  # send_file "public/404.html", status: 404
end

I18n

Time を出力するときに I18n.l を使うと楽なので YAML を読み込ませておく。

# config/initializers/i18n.rb
I18n.load_path << Dir[File.expand_path("config/locales/*.yml")]

RSS/Atom

builder gem を使え!

ログイン

current_userauthenticate_user! さえあれば事足りることが多いので、 omniauth だけを使っている。

# application_helper.rb
def current_user
  return nil unless session[:user_id]
  @current_user ||= User.find_by(id: session[:user_id])
end
# app.rb
get "/auth/github/callback" do
  ...

  redirect env["omniauth.origin"] || "/"
end

private

  def authenticate_user!
    return if current_user

    if request.safe?
      redirect "/auth/github?origin=#{CGI.escape(request.fullpath)}"
    else
      redirect "/auth/github"
    end
  end

テスト

request spec

rspec-request_describer gem にお世話になっています。send_request を書いておけば rack-test で使える。

routing がカオスになってきたらリクエストを投げた後に last_request.env["sinatra.route"] を確認することで、routing spec の真似事ができる。

RSpec.describe "/", type: :request do
  let(:app) { App }
  let(:send_request) { send(http_method, path, request_body, env).status }

  describe "GET /" do
    it {
      is_expected.to eq 200
      expect(last_request.env["sinatra.route"]).to eq "GET /"
    }
  }
}

fixture

https://github.com/rspec/rspec-rails/blob/v4.0.1/lib/rspec/rails/file_fixture_support.rb をパクってきて spec/support/ 以下に置いてます。

module FileFixtureSupport
  extend ActiveSupport::Concern
  include ActiveSupport::Testing::FileFixtures
  included do
    self.file_fixture_path = File.join(File.expand_path("..", __dir__), "fixtures", "files")
  end
end

RSpec.configure do |config|
  config.include FileFixtureSupport
end

database_rewinder

頑張れば use_transactional_tests 使えるかもしれないけど、読み解くのサボっているので愛用しています。

まとめ

これぐらいの準備で割と Rails と似た書き味で書けるし、だいたい Sinatra::Contrib にあるから安心って感じですね。Padrino に標準装備されているものが多いのですごく悩ましい……。padrino-helpers に嫌な思い出があるだけで Padrino は悪くないので使えば良いのかもしれない。

デプロイしたのが先週なので、運用が始まったらもっと色々考えることが増えていきそうだと思う。例えばログ。(今は標準出力 && Rack::CommonLogger のままで一旦いいやって思って手を付けていない)

*1:GitHub を数時間泳いだところ、https://github.com/cesarfigueroa/acme がめちゃくちゃ良いので参考にしたい