読者です 読者をやめる 読者になる 読者になる

Rails5 API + devise でユーザーの認証と追加機能を実装した API を作成する

目標

Rails v5.0.0 から追加されたapiオプションを使い, ユーザーの追加と認証機能を実装したベーシックな Rails API を作る.

rails new

まずはプロジェクトを作成します

$ rails new devise-api --api --skip-bundle

Gemfile に次の gem を追加し, bundle install

gem 'devise'
gem 'active_model_serializers'

devise

devise を立ち上げます

$ rails generate devise:install
      create  config/initializers/devise.rb
      create  config/locales/devise.en.yml

User モデルを作成します

$ rails generate devise User
      invoke  active_record
      create    db/migrate/20160710134334_devise_create_users.rb
      create    app/models/user.rb
      invoke    test_unit
      create      test/models/user_test.rb
      create      test/fixtures/users.yml
      insert    app/models/user.rb
       route  devise_for :users

token 認証

access_token カラムをユーザーテーブルに追加します

$ rails generate migration add_access_token_to_user
class AddAccessTokenToUser < ActiveRecord::Migration[5.0]
  def change
    add_column :users, :access_token, :string
  end
end

サンプルとしてトークンの生成にユーザーのidトークン自身を使用します. devise モジュールの説明についてはこちら

# app/models/user.rb
class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable and :omniauthable
  # :recoverable, :rememberable, :trackable
  devise :database_authenticatable, :registerable, :validatable

  after_create :update_access_token!

  validates :email, presence: true

  def update_access_token!
    self.access_token = "#{self.id}:#{Devise.friendly_token}"
    save
  end

end

ユーザー認証のロジックは application_controller に配置されます

# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
  include AbstractController::Translation

  before_action :authenticate_user_from_token!

  respond_to :json

  ##
  # User Authentication
  # Authenticates the user with OAuth2 Resource Owner Password Credentials
  def authenticate_user_from_token!
    auth_token = request.headers['Authorization']

    if auth_token
      authenticate_with_auth_token auth_token
    else
      authenticate_error
    end
  end

  private

  def authenticate_with_auth_token auth_token
    unless auth_token.include?(':')
      authenticate_error
      return
    end

    user_id = auth_token.split(':').first
    user = User.where(id: user_id).first

    if user && Devise.secure_compare(user.access_token, auth_token)
      # User can access
      sign_in user, store: false
    else
      authenticate_error
    end
  end

  ##
  # Authentication Failure
  # Renders a 401 error
  def authenticate_error
    # User's token is either invalid or not in the right format
    render json: { error: t('unauthorized') }, status: 401 # Authentication
  end
end

sessions_controller に対して login ルートを割り当てます.

# config/routes.rb
Rails.application.routes.draw do
  devise_for :user, only: []

  namespace :v1, defaults: { format: :json } do
    resource :login, only: [:create], controller: :sessions
  end
end

sessions_controller は次のようにログインリクエストを処理します.

# app/controllers/v1/sessions_controller.rb
module V1
  class SessionsController < ApplicationController
    skip_before_action :authenticate_user_from_token!

    # POST /v1/login
    def create
      @user = User.find_for_database_authentication(email: params[:email])
      return invalid_email unless @user

      if @user.valid_password?(params[:password])
        sign_in :user, @user
        render json: @user, serializer: SessionSerializer, root: nil
      else
        invalid_password
      end
    end

    private

    def invalid_email
      warden.custom_failure!
      render json: { error: t('invalid_email') }
    end

    def invalid_password
      warden.custom_failure!
      render json: { error: t('invalid_password') }
    end
  end
end

session_serializer.rb によって, オブジェクトを整形します.

# app/serializers/v1/session_serializer.rb
module V1
  class SessionSerializer < ActiveModel::Serializer

    attributes :email, :token_type, :user_id, :access_token

    def user_id
      object.id
    end

    def token_type
      'Bearer'
    end

  end
end

ユーザーのサインアップ

次にユーザーの登録プロセスを実装します.

# app/controllers/v1/users_controller.rb
module V1
  class UsersController < ApplicationController
    skip_before_action :authenticate_user_from_token!, only: [:create]

    # POST
    # Create an user
    def create
      @user = User.new user_params

      if @user.save!
        render json: @user, serializer: V1::SessionSerializer, root: nil
      else
        render json: { error: t('user_create_error') }, status: :unprocessable_entity
      end
    end

    private

    def user_params
      params.require(:user).permit(:email, :password)
    end
  end
end
# config/routes.rb
Rails.application.routes.draw do
  devise_for :user, only: []

  namespace :v1, defaults: { format: :json } do
    resource :login, only: [:create], controller: :sessions
    resource :users, only: [:create]
  end
end

動作確認

データベースをマイグレーション(rake db:create db:migrate)して, サーバーを立ち上げ(rails server)ます. コンソールからユーザーを作成, ログインをテストします.

$ curl localhost:3000/v1/users --data "email=user@example.com&password=password"
{"email":"user@example.com","token_type":"Bearer","user_id":1,"access_token":"1:C3bUyza9y1AX-s_H2q5r"}
$ curl localhost:3000/v1/login --data "email=user@example.com&password=password"
{"email":"user@example.com","token_type":"Bearer","user_id":1,"access_token":"1:C3bUyza9y1AX-s_H2q5r"}

より強固なものにするために spec 等を書きましょう.

CORS

フロントエンドのアプリケーションをこの API に対しリクエストを送る形で作成するのですが, 通常の設定では別ドメインからのリクエストは拒否されてしまいます.

これを解決するには Cross-Origin Resource Sharing (CORS) をrack-cors を使ってクロスドメイン通信を許可します.

gem 'rack-cors'

設定を config/application.rb に追加します:

config.middleware.insert_before 'Rack::Runtime', 'Rack::Cors' do
  allow do
    origins '*'
    resource '*',
             headers: :any,
             methods: [:get, :put, :post, :patch, :delete, :options]
  end
end

この設定のままではすべてのドメインからのリクエストを許可しているので, セキュリティ面は脆弱です.

Rails5: データベースがrakeタスクからdropできない時の対処法

問題点

Rails5になってrakeタスクのdrop動作に大きな変更があったらしく, Rails4からアップデートして初めての rake db:drop がエラーを吐いてしまう.

$ bundle exec rake db:drop
Mysql2::Error: Error dropping database (can't rmdir './db_name', errno: 66): DROP DATABASE IF EXISTS `db_name`
Couldn't drop database 'db_name'

mysqlから直接ドロップも出来ない

mysql> DROP DATABASE db_name;
ERROR 1010 (HY000): Error dropping database (can't rmdir './db_name', errno: 66)

解決策: データベースのファイルを根こそぎ削除する

まずはデータベースの入っているディレクトリを調べる:

mysql> select @@datadir;
+-----------------------+
| @@datadir             |
+-----------------------+
| /usr/local/var/mysql/ |
+-----------------------+
1 row in set (0.00 sec)

あるいは:

$ mysql -e "select @@datadir"
+-----------------------+
| @@datadir             |
+-----------------------+
| /usr/local/var/mysql/ |
+-----------------------+

データベースディレクトリに移動して, 該当ファイルを削除する:

$ cd /usr/local/val/mysql/
$ sudo rm -rf db_name

あとはデータベースを作りなおせば良い:

rake db:create

hatena-blog-and-fotolife v4.2.0 をリリース: エントリーリストとエントリーの取得ができるようになりました

hatena-blog-and-fotolife v4.2.0 をリリースしました。

以下の機能を追加しています。

エントリーリストの取得

コマンド: ⌥⌘I index

エントリーリストからエントリーの取得

get

Atomエディタからはてなブログエントリーの削除ができるようになりました

かねてより開発していたAtomパッケージ hatena-blog-entry-postをアップデートし、エントリーの削除機能を追加しました。

デモ

delete

このパッケージを使って、はてなブログにエントリーを投稿すると、次のようなコメントがファイルに挿入されます。

<!--
{"id":"6653812171401174041","title":"Atomエディタからはてなブログエントリーの削除ができるようになりました","categories":["Atom","hatena-blog-entry-post"],"draft":true}
-->

このコメントが存在する場合に限り、エントリーの更新/削除が可能になります。

また、更新/削除機能の追加に伴い、パッケージをhatena-blog-entry-postからhatena-blog-and-fotolifeへ変更しました。そのままアップデートしてお使いいただけます。

Atomパッケージのリネーム

Atomパッケージのリネームの仕方は、次の通りです:

$ apm publish --rename {新しい名前}

これでスムーズにパッケージのリネームが完了します。

https://atom.io/packages/hatena-blog-entry-post にアクセスしても、 https://atom.io/packages/hatena-blog-and-fotolife へとリダイレクトされますし、パッケージのアップデートもユーザーには影響なさそうです。

なにかあれば気軽にissueへどうぞ。

"Array values in the parameter are deprecated" エラー

問題と原因

Railsのサーバーやコンソール実行時次のような警告が出る場合:

$ rails server
Array values in the parameter are deprecated. Please use a String or nil.
An Array was passed in from bin/rails:3:in `load'
.
.
.

この場合原因はbin/springの11行目にある:

Gem.paths = { "GEM_PATH" => [Bundler.bundle_path.to_s, *Gem.path].uniq }

解決策

bin/springのこの11行目を次のように修正するか:

Gem.paths = { 'GEM_PATH' => [Bundler.bundle_path.to_s, *Gem.path].uniq.join(File::PATH_SEPARATOR) }

springを次のコマンドで最新のバージョンにアップデートすることで、この警告は解消される:

$ bundle update spring && bundle exec spring binstub --remove --all && bundle exec spring binstub --all

direnv使い始め

direnv

direnvとは

  • cdのようなシェルスクリプトをフックとしてディレクトリごとに環境変数を自動で設定してくれるツール
  • ディレクトリをぬけ出すと環境変数はリセットされる

インストールとセットアップ

brewを使ってインストール:

$ brew install direnv
$ brew link direnv

あるいはdirenvはGoで書かれているので、Goとmakeを使って:

$ git clone https://github.com/direnv/direnv
$ cd direnv
$ make install

インストール後、フックを追加:

echo 'eval "$(direnv hook bash)"' >> ~/.bashrc
echo 'eval "$(direnv hook zsh)"' >> ~/.zshrc
echo 'eval (direnv hook fish)' >> ~/.config/fish/config.fish
echo 'eval `direnv hook tcsh`' >> ~/.cshrc

もし、~/.bash_profileが存在しない場合は、以下の内容で新しく~/.bash_profileを作成、あるいは追加:

if [ -f ~/.bashrc ]; then
    . ~/.bashrc
fi

使用例

  • .envrcファイルでディレクトリ固有の環境変数を指定
  • direnv edit .コマンドでそのディレクトリの.envrcファイルを作成

使用する前に環境変数EDITORを設定しておく。

export EDITOR={エディタ}

ターゲットのディレクトリに.envrcファイルを作成:

$ mkdir test
$ cd test
$ echo ${FOO-nope} //環境変数FOOは存在しないのでnopeを返す
nope
$ direnv edit .

.envrcを編集する:

export FOO=foo

リロード:

$ direnv reload
direnv: loading ~/.direnvrc
direnv: loading .envrc
direnv: export +FOO

環境変数が設定されているか、確認:

$ echo ${FOO-nope}
foo

ディレクトリを抜けだしてみる:

$ cd ..
direnv: unloading
$ echo ${FOO-nope}
nope

Error response from daemon: client is newer than server (client API version: 1.23, server API version: 1.22)

Dockerに関するエラー:

$ docker ps
Error response from daemon: client is newer than server (client API version: 1.23, server API version: 1.22)

Machineの更新が必要なので、次のコマンドを打つ:

$ docker-machine upgrade [machine名]

再度チャレンジ:

$ docker ps
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES

大丈夫そう。