petitviolet_blog

@petitviolet blog

RailsとReact hookでGistみたいなWebアプリを作りかけた

Ruby(Rails)の練習がてらGistみたいなやつを作ろうとしていた(過去形)。
https://github.com/petitviolet/acoder

モチベーションはこの辺

  • Ruby on Rails 6を触ってみたかった
    • ActionTextとか
  • React hooks触ってみたかった
    • TypeScript
  • RubyでもGraphQLやってみたかった
    • Getting Startedくらい

作りかけた?

飽きた。
個人開発だし真面目にcommitしたりしなかったりPR作ったり作らなかったりでめっちゃ雑なプロセスだったと反省している。

以下メモと感想。

API

Ruby on Rails 6.0で動いている。

ActionTextを捨てた

目玉機能っぽいActionTextを使ってコードスニペットのエディタを作ろう!と思っていたがReactで書いているクライアントに対してActionTextを使って作ったビューをいい感じに埋め込むことが出来ず諦めた。

こういうことやりたい人っていないのかな、参考になりそうなのがうまく見付けられなかったし面倒くさかったので。

自前認証 -> devise_token_authで認証

最初は自前でTokenを使った認証を実装したが面白くないのでdeviseでやってみた。
が、cookie認証を実装する元気はなくヘッダにtokenを埋め込んで認証したかったので、deviseだけだとどうしても辛くdevise-token-authを使ってやってみることにした。

とにかくdeviseどころかRailsにまだ慣れていない自分には動作を追いかけるのが大変で辛かった。

確かにこういった認証機構を自前で実装するのは大変なのでdeviseみたいにオールインワンでやってくれるのは便利だと思う反面、結局のところ実装を読んで理解して使わないといけないだろうしそれだったら自分で実装した方がシンプルになるのでは...という感想。
少なくともぜひ使いたい!という感想は持てなかった。

GraphQL

ScalaではわりとGraphQLやっていたけどRuby(Rails)でも素振りしておくかというところ。
こんな感想を持ったりしていたのがコンテキスト。

とはいえとりあえず/graphql叩けるようにした、以上のことはまだしていないので素振りの回数は足りてない。

フレームワークのおかげでわりとさくっとGraphQLエンドポイントを動かすことが出来て楽っぽい。
Scalaでやったときは型のおかげでじっくり安全なAPIを作れたが、一方Rubyでやるとよく分からんがさくっと動いたみたいな感想になり、まあそれがRubyの良さでしょうと割り切ってしまえば問題なさそう。 例えばContextがただのHashだったり。 GraphQLそのものはある程度わかっているつもりだが、graphql-rubyの使い方を習得する必要がありそれはまた必要が発生してからでいいや。

Front

ざっと要素を並べるとこのあたり

  • TypeScript
  • React(hook)
  • styled-component
  • react-bootstrap, font-awesome
  • eslint + prettier

storybookなるものも使ってみようと思っていたが忘れていた。
TypeScript、文字列でkeyの存在チェックして暗黙的な型キャストみたいなことが出来るようになっていたのは発見。
けど、そんな文字列でマッチさせるなんてやり方よりパターンマッチしたいよね。 そして折角だからasync/awaitも一応使ったがあまりに小さいアプリなので恩恵は感じられなかった。 個人的にはPromiseのまま使う方が好き。というのもawaitって要するにAwait.result(f, Duration.Inf)でしょという理解なので雑に使うには怖くない?

UIのデザイン力は無いし身に付ける気も今のところ全くないのでbootstrapでレイアウトだけ適当に弄ったりした。

React hook

Vue使うかも悩んだけどhook触りたかったので。
以前React触ってたときはReduxとかpropsのバケツリレーが大変だった記憶があり、React hookを使うとなんか良さそうに書けるんじゃないかと思って手を出した。

ひと通り動かしてみて基本的なhook(useState, useEffect, useReducer, useContext, useMemo, useCallback)は触れるようになった。
単なる関数を組み合わせてコンポーネントを組み立てて、ひいてはアプリケーションを組み立てていく感覚は気持ち良い。

実装だとこんな感じ。useForm.tsx

Webpacker -> Webpack

最初はRailsにべったりなrails/webpackerを使っていたのだがいかんせんよくわからず自由を求めてwebpackに移行した。

大体変更はこの辺: #1 Webpacker to webpack
必要な変更が多くて難儀したがなんとか。

TrixEditor -> AceEditor

ActionTextは諦めたけどせめてTrixEditorを使ってみるか!と思っていたが使い勝手があまり良くなく感じ、AceEditorに乗り換えたりした。

デプロイ

Herokuで動かせるようにしてみた。 RailsといえばHeroku!

apiとfrontendという2つのプロジェクトが1つのリポジトリに置いてあるのでmakeでデプロイ出来るようにした。 バージョンをgitハッシュとしたかったのでデプロイと同時に環境変数に埋め込んで参照するようにしてみたらわりとよかったかも。

フロント力が足りずwebpack --mode=productionするとineffective mark-compacts near heap limit allocation failed - javascript heap out of memoryなどという無力感あるエラーを吐いて死んだ。 --max_old_space_sizeであったり--gc_intervalの値を調整するなどしたがダメで、よくわからないままwebpack --mode=developmentしたら動いたなんだそれ。

CI

時代はGitHub Actions!
いい加減触っておくか、みたいな感覚でYAMLファイルを書いてみて動かせるようにしてみた。
必要十分かなという感想。
肝心なテストはほとんどない。

Railsでdevise_token_auth使ってトークン認証する

Railsで認証を実装するのに有名なGemであるplataformatec/deviseを使ってトークン認証を実装するためのGemであるlynndylanhurley/devise_token_authを使ってトークンによる認証を実装する。
+αで少しカスタマイズする方法も書く。

ちなみにdeviseにもかつてはTokenAuthenticatableがあったらしいがなくなった

バージョンはこちら。

$ bundle list | grep -e 'devise' -e ' rails '
  * devise (4.7.1)
  * devise_token_auth (1.1.3)
  * rails (6.0.0)

インストールと初期設定

公式ドキュメントとかを見れば良い。

$ echo "gem 'devise_token_auth'" >> Gemfile
$ bundle install
$ rails g devise_token_auth:install Account accounts

これによって必要なファイル群(モデル、マイグレーション、initializerなど)が生成されるので、それを編集すれば良い。

これで終わってしまってはアレなので、以降では自分がやってみたことについて書く。

モデル

今回はAccoutとした。

class Account < ActiveRecord::Base
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         :lockable
  include DeviseTokenAuth::Concerns::User
end

まあ普通でdeviseの機能がそれっぽく使える感じ。

schema

Migrationファイルを弄った結果このようなaccountsテーブルとなっている。

  create_table "accounts", force: :cascade do |t|
    t.string "provider", default: "email", null: false
    t.string "uid", default: "", null: false
    t.string "encrypted_password", default: "", null: false
    t.string "reset_password_token"
    t.datetime "reset_password_sent_at"
    t.boolean "allow_password_change", default: false
    t.datetime "remember_created_at"
    t.string "confirmation_token"
    t.datetime "confirmed_at"
    t.datetime "confirmation_sent_at"
    t.integer "failed_attempts", default: 0, null: false
    t.string "unlock_token"
    t.datetime "locked_at"
    t.string "name", null: false
    t.string "email", null: false
    t.json "tokens"
    t.datetime "created_at", precision: 6, null: false
    t.datetime "updated_at", precision: 6, null: false
    t.index ["confirmation_token"], name: "index_accounts_on_confirmation_token", unique: true
    t.index ["email"], name: "index_accounts_on_email", unique: true
    t.index ["reset_password_token"], name: "index_accounts_on_reset_password_token", unique: true
    t.index ["uid", "provider"], name: "index_accounts_on_uid_and_provider", unique: true
    t.index ["unlock_token"], name: "index_accounts_on_unlock_token", unique: true
  end

routes

自動生成されるものだと/accountsがベースになってしまうが/api/accountsにしたい、という場合はこんな感じ。

scope :api do
  mount_devise_token_auth_for 'Account', at: 'accounts'
end

namespaceを使うとうまく行かなかったのでscopeを使っている。
これでrails routesするとこんな風になる。

$ bundle exec rails routes | grep accounts
        new_account_session GET    /api/accounts/sign_in(.:format)         devise_token_auth/sessions#new
            account_session POST   /api/accounts/sign_in(.:format)         devise_token_auth/sessions#create
    destroy_account_session DELETE /api/accounts/sign_out(.:format)        devise_token_auth/sessions#destroy
        new_account_password GET    /api/accounts/password/new(.:format)    devise_token_auth/passwords#new
      edit_account_password GET    /api/accounts/password/edit(.:format)   devise_token_auth/passwords#edit
            account_password PATCH  /api/accounts/password(.:format)        devise_token_auth/passwords#update
                            PUT    /api/accounts/password(.:format)        devise_token_auth/passwords#update
                            POST   /api/accounts/password(.:format)        devise_token_auth/passwords#create
cancel_account_registration GET    /api/accounts/cancel(.:format)          devise_token_auth/registrations#cancel
    new_account_registration GET    /api/accounts/sign_up(.:format)         devise_token_auth/registrations#new
  edit_account_registration GET    /api/accounts/edit(.:format)            devise_token_auth/registrations#edit
        account_registration PATCH  /api/accounts(.:format)                 devise_token_auth/registrations#update
                            PUT    /api/accounts(.:format)                 devise_token_auth/registrations#update
                            DELETE /api/accounts(.:format)                 devise_token_auth/registrations#destroy
                            POST   /api/accounts(.:format)                 devise_token_auth/registrations#create
          new_account_unlock GET    /api/accounts/unlock/new(.:format)      devise_token_auth/unlocks#new
              account_unlock GET    /api/accounts/unlock(.:format)          devise_token_auth/unlocks#show
                            POST   /api/accounts/unlock(.:format)          devise_token_auth/unlocks#create
api_accounts_validate_token GET    /api/accounts/validate_token(.:format)  devise_token_auth/token_validations#validate_token

トークンによる認証の実装

みんな大好きApplicationControllerに書く。

class ApplicationController < ActionController::API
  include DeviseTokenAuth::Concerns::SetUserByToken

  before_action :authenticate_account!, unless: :devise_controller?
  before_action :configure_permitted_parameters, if: :devise_controller? 

  respond_to :json

  private

    def devise_token_auth_controller?
      params[:controller].split('/')[0] == 'devise_token_auth'
    end

    def configure_permitted_parameters
      # DBにaccounts.nameカラムがある場合
      devise_parameter_sanitizer.permit(:sign_up, keys: [:name])
      devise_parameter_sanitizer.permit(:account_update, keys: [:name])
    end 
end

必要最小限でこんな感じ。

  • deviseにある:devise_controller?がdevise_token_authでも使える
  • devise_parameter_sanitizer.permitで必要なパラメータを追加できる

というところがポイント。

routesをカスタマイズする

上の方に書いたやつでもいいけど、少しカスタマイズする。
具体的にはsign up用のパスをPOST /api/accountsではなくPOST /api/accounts/sign_upにしたい。

mount_devise_token_auth_for:skipオプションと、devise_scope :accountの組み合わせで実現できる。

  scope :api do
    mount_devise_token_auth_for 'Account', at: 'accounts', skip: [:registrations]

    devise_scope :account do
      post '/accounts/sign_up', to: 'devise_token_auth/registrations#create'
      put '/accounts', to: 'devise_token_auth/registrations#update'
      delete '/accounts', to: 'devise_token_auth/registrations#destroy'
    end
  end

この結果、こんなroutesが生成される。

$ be rails routes | ag registration
accounts_sign_up POST   /api/accounts/sign_up(.:format) devise_token_auth/registrations#create
        accounts PUT    /api/accounts(.:format)         devise_token_auth/registrations#update
                 DELETE /api/accounts(.:format)         devise_token_auth/registrations#destroy

curlでリクエストを送ってみる

ここまで実装してきたRailsアプリに対してcurlでリクエストを送る。

sign_up

まずはアカウント登録処理。

$ curl -H "Content-Type: application/json" \
       -d "$(jo name=hoge email=hoge@example.com password=password password_confirmation=password)" \
       'localhost:3000/api/accounts/sign_up' \
       | jq -S .
{
  "data": {
    "allow_password_change": false,
    "created_at": "2019-10-31T14:21:54.026Z",
    "email": "hoge@example.com",
    "id": 1,
    "name": "hoge",
    "provider": "email",
    "uid": "hoge@example.com",
    "updated_at": "2019-10-31T14:21:54.120Z"
  },
  "status": "success"
}

いい感じ。

sign_in

今作成したアカウントの情報を使ってemail/passwordでsign_inする。

$ curl -H "Content-Type: application/json" \
       -d "$(jo email=hoge@example.com password=password)" \
       'localhost:3000/api/accounts/sign_in' \
       -i
HTTP/1.1 200 OK
X-Frame-Options: SAMEORIGIN
X-XSS-Protection: 1; mode=block
X-Content-Type-Options: nosniff
X-Download-Options: noopen
X-Permitted-Cross-Domain-Policies: none
Referrer-Policy: strict-origin-when-cross-origin
Content-Type: application/json; charset=utf-8
access-token: twkb92Ycy28DuMyfBYn4GA
token-type: Bearer
client: D1cGRIzfDhxYgoRSN8xsIw
expiry: 1573741507
uid: hoge@example.com
ETag: W/"ed6728748b7589356745b69594dc77f9"
Cache-Control: max-age=0, private, must-revalidate
X-Request-Id: abc3e906-3f87-44b3-849a-19ce4cf3418c
X-Runtime: 0.229688
Vary: Origin
Transfer-Encoding: chunked

{"data":{"id":1,"email":"hoge@example.com","provider":"email","uid":"hoge@example.com","allow_password_change":false,"name":"hoge"}}

ここで大事なのはヘッダのaccess-token, client, uidの3つ。 少なくともこの3つが認証処理に必要な文字列となる。

認証する

認証してただその情報を返してくれるcontrollerとrouteを用意する。

controllerがこんな感じ

class Api::SessionsController < ApplicationController
  def whoami
    render json: current_user, status: :ok
  end
end

routesはこんな感じ

  namespace :api, defaults: { format: :json } do
    get '/whoami', to: 'sessions#whoami'
  end

これに対して、先程sign_inで手に入れた認証情報を乗せたリクエストを送ってみる。

$ curl -H "Content-Type: application/json" \
       -H "access-token: twkb92Ycy28DuMyfBYn4GA" \
       -H "client: D1cGRIzfDhxYgoRSN8xsIw" \
       -H "uid: hoge@example.com" \
       'localhost:3000/api/whoami' \
       | jq -S .
{
  "allow_password_change": false,
  "created_at": "2019-10-31T14:21:54.026Z",
  "email": "hoge@example.com",
  "id": 1,
  "name": "hoge",
  "provider": "email",
  "uid": "hoge@example.com",
  "updated_at": "2019-10-31T14:25:07.999Z"
}

誤ったリクエストを送るとちゃんと怒られる

$ curl -H "Content-Type: application/json" \
       'localhost:3000/api/whoami' \
       | jq -S .
{
  "errors": [
    "You need to sign in or sign up before continuing."
  ]
}

認証情報を1つの文字列にぶち込む

とはいえ毎回access-token, client, uidの3つをヘッダに乗せるのはめんどくさい。 そこで1つの文字列に全部入れて扱えるようにヘルパーを書いてみる。

class ApplicationController < ActionController::API
  ...

  before_action :split_tokens
  prepend_after_action :join_tokens

  private

    def split_tokens
      return if request.headers['X-Access-Token'].nil?

      token = JSON.parse(Base64.decode64(CGI.unescape(request.headers['X-Access-Token'])))
      request.headers['access-token'] = token['access-token']
      request.headers['client'] = token['client']
      request.headers['uid'] = token['uid']
    end

    def join_tokens
      return if response.headers['access-token'].nil?

      auth_json = {
        'access-token' => response.headers['access-token'],
        'client' => response.headers['client'],
        'uid' => response.headers['uid'],
      }
      response.headers.delete_if{|key| auth_json.include? key}
      response.headers['X-Access-Token'] = CGI.escape(Base64.encode64(JSON.dump(auth_json)))
    end

    ...
end

ものすごく雑にaccess-token, client, uidの3つの情報を、JSON + base64 + URLエンコードを組み合わせてX-Access-Tokenヘッダとして扱えるようにしている。

これによってX-Access-Tokenヘッダが返ってくるようになり、

$ curl -H "Content-Type: application/json" \
       -d "$(jo email=hoge@example.com password=password)" \
       'localhost:3000/api/accounts/sign_in' -i \
       | grep X-Access-Token
X-Access-Token: eyJhY2Nlc3MtdG9rZW4iOiJ3WEZmeVo2TEZROGNJWDFTV2tMeFVBIiwiY2xp%0AZW50IjoieEd1U0pHVVNRaUtueE8yb1pfWGJuUSIsInVpZCI6ImhvZ2VAZXhh%0AbXBsZS5jb20ifQ%3D%3D%0A

X-Access-Tokenヘッダを送り返せば認証が成功するようになる。

$ curl -H "Content-Type: application/json" \
       -H "X-Access-Token: eyJhY2Nlc3MtdG9rZW4iOiJ3WEZmeVo2TEZROGNJWDFTV2tMeFVBIiwiY2xp%0AZW50IjoieEd1U0pHVVNRaUtueE8yb1pfWGJuUSIsInVpZCI6ImhvZ2VAZXhh%0AbXBsZS5jb20ifQ%3D%3D%0A" \
       'localhost:3000/api/whoami' \
       | jq -S .
{
  "allow_password_change": false,
  "created_at": "2019-10-31T14:21:54.026Z",
  "email": "hoge@example.com",
  "id": 1,
  "name": "hoge",
  "provider": "email",
  "uid": "hoge@example.com",
  "updated_at": "2019-10-31T14:48:54.447Z"
}

HTTPリクエスト/レスポンスを記録してリプレイするためのruby gem作った

RubyGem作ってみよう、Rackミドルウェア書いてみよう、の習作。

みたいな使い方。

実用上は、Goで書かれたgoreplayなど、もっと便利なツール群がある。 名前はこれをオマージュしてつけた。

どんなものか

使い方は大体こんな雰囲気でconfig.ruとかに書く。

use Rack::Rreplay.Middleware(directory: './tmp', format: :json),
    { sample: 5, extra_header_keys: %w[X-ACCESS-TOKEN], debug: true }

HTTPリクエスト/レスポンスのdumpを吐き出すdirectoryとformat(:msgpack or :json)を与えつつ、オプションをいくつか渡せるようになっている。 この設定であればHTTPリクエスト5回につき1回dumpするようになっている。

出力結果はJSONなら例えばこんな感じ。

{
  "request": {
    "body": null,
    "headers": {
      "X-ACCESS-TOKEN": "eyJhY2Nlc3MtdG9rZW4iOiJGdkNyczdnT0pLYUN3SU5lTWNmMjVnIiwiY2xp%0AZW50IjoiZnlsc01qMHlfYTl3cHVpWmJiWUJodyIsInVpZCI6ImFsaWNlQGV4%0AYW1wbGUuY29tIn0%3D%0A",
      "content-type": "application/json",
      "cookie": null,
      "user-agent": "petitviolet curl"
    },
    "method": "GET",
    "path": "/api/whoami",
    "query_strings": ""
  },
  "response": {
    "body": "{\"id\":\"f7fb4b22-1792-477f-ab28-cd36491fd09f\",\"name\":\"alice\"}",
    "headers": {
      "Cache-Control": "max-age=0, private, must-revalidate",
      "Content-Type": "application/json; charset=utf-8",
      "X-Access-Token": "...",
      ...
    },
    "status": 200
  },
  "response_time": "0.395582",
  "time": "2019-12-30T23:43:41+09:00",
  "uuid": "c80468a9-5424-4a17-a1bc-6ab325252d36"
}

このログを使ってHTTPリクエストをリプレイするとこんな感じになる

$ bundle exec rreplay 'http://localhost:3000' ./tmp/rreplay.log.json -f json --verbose | jq -S .
{
  "response": {
    "actual": {
      "response": {
        "body": "{\"id\":\"f7fb4b22-1792-477f-ab28-cd36491fd09f\",\"name\":\"alice\"}",
        "headers": {
          "Cache-Control": "max-age=0, private, must-revalidate",
          "Content-Type": "application/json; charset=utf-8",
          "X-Access-Token": "...",
          ...
        },
        "status": "200"
      },
      "response_time": "0.029326"
    },
    "recorded": {
      "record": {
        "body": "{\"id\":\"f7fb4b22-1792-477f-ab28-cd36491fd09f\",\"name\":\"alice\"}",
        "headers": {
          "Cache-Control": "max-age=0, private, must-revalidate",
          "Content-Type": "application/json; charset=utf-8",
          "X-Access-Token": "...",
          ...
        },
        "status": 200
      },
      "response_time": "0.395582"
    }
  },
  "time": "2020-01-02T22:35:47+09:00",
  "uuid": "c80468a9-5424-4a17-a1bc-6ab325252d36"
}

このように、あるリクエストをもう一度投げた時にデグレしてないかとかレスポンスタイムに大きな差がないか、というのをいい感じに頑張ればテストや性能検証とかに使えないかな〜とは思っているが何も実装はしていない。

実装について

Rackミドルウェアという都合上、initialize(app, ...)call(env)が定義されたクラスであることが求められる。 HTTPのリクエストとレスポンスを吐き出していくためファイルのローテーションをやりたいというモチベーションが湧いてきて、initializeするたびにLoggerをnewするのは流石に避けたかったのでClass.newミドルウェアを作ることにした。

use Rack::Rreplay.Middleware(directory: './tmp', format: :json),
    { sample: 1, extra_header_keys: %w[X-ACCESS-TOKEN], debug: true }

このようにconfig.ru等に書くとミドルウェアが有効になるが、Rack::Rreplay.MiddlewareはClassオブジェクトを返す関数となっていて実装はこのあたりrreplay/rreplay.rb

関数の引数に与えたディレクトリ名を使ってログローテーションするLogger(正確にはLogger::LogDevice)のインスタンスをnewして使い回すためにClass.newで作ったクラスのクラス変数にloggerを埋め込むというやり方をしている。 クラス変数、というだけでなるべく使いたくない気持ちにはなるので別の方法があればよかったが思いつかなかった。

また、JSONとMessagePackの2種類のフォーマットをサポートしてみている。JSONをMessagePackにして少し圧縮しているだけだが。 MessagePackはUTF-8なStringではないのでbinaryとしてファイルに書き込む必要があり、内部的に使用しているLogger::LogDeviceのコンストラクタにbinmode: trueを渡すことで動くようになる。

Loggerを使い回すという観点だとClass.newではなくLoggerをユーザ側から差し込んでもらう方がシンプルにはなるが、こういったフラグ管理などを考えるとこれで良かったのかもしれない。(一応差し込めるようにはなっている)