Ruby on Rails

認証・API・テスト

Ruby on Rails

Devise・JWT・RSpec・FactoryBot

API モード

APIコントローラー・シリアライザー・JWT認証

app/controllers/api/v1/posts_controller.rb ruby
# API モード(ActionController::API を継承)
module Api
  module V1
    class PostsController < ApplicationController
      before_action :authenticate_jwt!
      before_action :set_post, only: [:show, :update, :destroy]

      def index
        posts = Post.published.recent.page(params[:page])
        render json: {
          data:  ActiveModelSerializers::SerializableResource.new(posts),
          meta:  pagination_meta(posts)
        }
      end

      def show
        render json: PostSerializer.new(@post).as_json
      end

      def create
        post = current_user.posts.build(post_params)
        if post.save
          render json: PostSerializer.new(post), status: :created
        else
          render json: { errors: post.errors.full_messages }, status: :unprocessable_entity
        end
      end

      private

      def set_post = @post = Post.find(params[:id])
      def post_params = params.require(:post).permit(:title, :body, :status)
    end
  end
end

# JWT 認証
class AuthController < ApplicationController
  skip_before_action :authenticate_jwt!, only: [:create]

  def create
    user = User.find_by(email: params[:email])
    if user&.authenticate(params[:password])  # has_secure_password
      token = JWT.encode(
        { user_id: user.id, exp: 24.hours.from_now.to_i },
        Rails.application.credentials.jwt_secret
      )
      render json: { token: token, user: UserSerializer.new(user) }
    else
      render json: { error: '認証失敗' }, status: :unauthorized
    end
  end
end

# application_controller.rb
def authenticate_jwt!
  header = request.headers['Authorization']
  token  = header&.split(' ')&.last
  payload = JWT.decode(token, Rails.application.credentials.jwt_secret).first
  @current_user = User.find(payload['user_id'])
rescue JWT::DecodeError, ActiveRecord::RecordNotFound
  render json: { error: '認証が必要です' }, status: :unauthorized
end

Devise 認証

インストール・カスタマイズ・ヘルパー

devise_setup.sh bash
# Gemfile
# gem 'devise'

bundle install
rails generate devise:install
rails generate devise User
rails generate devise:views  # ビューをカスタマイズする場合
rails db:migrate

# 生成されるルート
# GET  /users/sign_up   → registrations#new
# POST /users/sign_up   → registrations#create
# GET  /users/sign_in   → sessions#new
# POST /users/sign_in   → sessions#create
# DELETE /users/sign_out → sessions#destroy
devise_usage.rb ruby
# モデル設定
class User < ApplicationRecord
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable,
         :confirmable, :lockable, :trackable,
         :omniauthable, omniauth_providers: [:google_oauth2]
end

# コントローラーで使用できるヘルパー
authenticate_user!          # 未ログインをリダイレクト
current_user                # 現在のユーザー
user_signed_in?             # ログイン状態の確認

# ビューでのリンク
# link_to 'ログイン', new_user_session_path
# link_to 'ログアウト', destroy_user_session_path, method: :delete
# link_to '登録', new_user_registration_path

# ApplicationController でのカスタマイズ
class ApplicationController < ActionController::Base
  before_action :authenticate_user!

  def after_sign_in_path_for(resource)
    stored_location_for(resource) || dashboard_path
  end

  def after_sign_out_path_for(resource_or_scope)
    root_path
  end
end

# カスタムフィールドの追加
class Users::RegistrationsController < Devise::RegistrationsController
  private

  def sign_up_params
    params.require(:user).permit(:name, :email, :password, :password_confirmation)
  end

  def account_update_params
    params.require(:user).permit(:name, :email, :password, :password_confirmation, :current_password)
  end
end

RSpec と FactoryBot

モデル・リクエストスペック・FactoryBot

spec/factories/users.rb ruby
# FactoryBot の定義
FactoryBot.define do
  factory :user do
    name     { Faker::Name.name }
    email    { Faker::Internet.unique.email }
    password { 'password123' }
    role     { 'user' }
    active   { true }

    # トレイト(バリエーション)
    trait :admin do
      role { 'admin' }
    end

    trait :inactive do
      active { false }
    end

    # 関連
    trait :with_posts do
      after(:create) do |user|
        create_list(:post, 3, user: user)
      end
    end
  end

  factory :post do
    title  { Faker::Lorem.sentence }
    body   { Faker::Lorem.paragraphs(number: 3).join('\n') }
    status { 'published' }
    association :user

    trait :draft do
      status { 'draft' }
    end
  end
end
spec/requests/posts_spec.rb ruby
require 'rails_helper'

RSpec.describe 'Posts API', type: :request do
  let(:user) { create(:user) }
  let(:post) { create(:post, user: user) }
  let(:headers) { { 'Authorization' => "Bearer #{jwt_token(user)}" } }

  describe 'GET /api/v1/posts' do
    before { create_list(:post, 3, :published) }

    it 'returns published posts' do
      get '/api/v1/posts', headers: headers

      expect(response).to have_http_status(:ok)
      json = JSON.parse(response.body)
      expect(json['data'].length).to eq(3)
    end
  end

  describe 'POST /api/v1/posts' do
    let(:params) { { post: { title: 'New Post', body: 'Content', status: 'published' } } }

    context 'with valid params' do
      it 'creates a post' do
        expect {
          post '/api/v1/posts', params: params, headers: headers
        }.to change(Post, :count).by(1)

        expect(response).to have_http_status(:created)
      end
    end

    context 'with invalid params' do
      it 'returns errors' do
        post '/api/v1/posts',
             params: { post: { title: '' } },
             headers: headers

        expect(response).to have_http_status(:unprocessable_entity)
        expect(JSON.parse(response.body)['errors']).to be_present
      end
    end
  end
end