「いつもの」が結構ありそうなので書いておく。
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 だな。
YAML を置いておくとアプリケーション内から設定にアクセスできるマン。
大枠で言うと Rails::Application.config_for
と同じ使い心地です。
# layout.erb
<title><% if content_for?(:title) %><%= yield_content(:title) %> | <% end %>APP_NAME</title>
# users/show.erb
<% content_for :title, @user.name %>
みたいなヤツ。 title の例だけで無いとツラいのが分かるはず。
json @obj
すると JSON dump して content-type 付けて返してくれるヤツ。
development で有効にすると開発が楽で幸せ。
configure :development do
register Sinatra::Reloader
also_reload "models/**/*"
also_reload "helpers/**/*"
end
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 }
以下を食わせている。前半は 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
を 500 じゃなく 404 で返したいので以下を書く。
error ActiveRecord::RecordNotFound do |e|
not_found
end
Time を出力するときに I18n.l
を使うと楽なので YAML を読み込ませておく。
I18n.load_path << Dir[File.expand_path("config/locales/*.yml")]
builder gem を使え!
ログイン
current_user
と authenticate_user!
さえあれば事足りることが多いので、 omniauth だけを使っている。
def current_user
return nil unless session[:user_id]
@current_user ||= User.find_by(id: session[:user_id])
end
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 のままで一旦いいやって思って手を付けていない)