第3章: Todo CRUD API(認証なし版)
この章で学ぶこと(所要時間: 3時間)
この章では、認証機能を実装する前に、まずTodoアプリケーションの基本的なCRUD機能を実装します。APIの基本を学び、テスト駆動開発(TDD)の実践も行います。
はじめに
第2章でRails APIの基盤が整いました。いよいよ本格的なAPI開発に入ります。
多くのチュートリアルでは最初に認証機能を実装しますが、この教材ではあえて認証なしでCRUD機能を先に作ります。なぜなら:
- CRUD操作の基本に集中できる
- テストが書きやすい(認証を考えなくて良い)
- 段階的に複雑さを増やせる
実際の開発でも、プロトタイプ段階では認証なしで基本機能を作り、後から認証を追加することはよくあります。
この章を終えると...
- RESTful APIの設計原則が理解できる
- モデルの関連付け(アソシエーション)が使えるようになる
- seed_fuを使ったテストデータ管理ができる
- RSpecでAPIのテストが書ける
- 実践的なCRUD操作が実装できる
この章で身につくスキル
- モデルの設計とマイグレーション
- ActiveRecordのアソシエーション
- seed_fuによるデータ投入
- RESTfulなAPIエンドポイント設計
- RSpecによるリクエストスペック
- JSONレスポンスの整形
- セクション0: 作業ブランチの作成(実践)
- セクション1〜2: モデルの作成と設定(実践)
- セクション3: seed_fuでテストデータ投入(実践)
- セクション4〜5: CRUD実装と追加機能(実践)
- セクション6: GitHubへのプッシュ(実践)
- 第2章でRSpec環境が構築済みであること
- Dockerコンテナが正常に動作していること
- GitHubで作業ブランチを管理できること
それでは、実践的なAPI開発を始めます!
0. 作業ブランチの作成
前章と同様に、新しいブランチを作成して作業を始めます。
実際にやってみましょう!
# mainブランチに切り替え
git checkout main
# 最新の状態に更新
git pull origin main
# feature/chapter-3ブランチを作成して切り替え
git checkout -b feature/chapter-3
# ブランチが切り替わったか確認
git branch
1. モデルの設計と作成
データベース設計
まず、どのようなデータ構造にするか設計します。今回はユーザーとTodoの関係をシンプルに表現した単純なデータモデルとなるので、以下のようになります。実際のプロダクトでは、タグ機能やカテゴリ分け、優先度、期限日などの機能を追加することで、より複雑で実用的なデータ構造になっていきます。
リレーション説明: 1人のユーザー(users)は複数のTodo(todos)を持つことができます
- PK(Primary Key): 主キー。テーブル内の各レコードを一意に識別するカラム
- FK(Foreign Key): 外部キー。他のテーブルとの関連を示すカラム。todosテーブルのuser_idは、usersテーブルのidを参照しています
password_digestは、has_secure_passwordを使用する際の命名規則です。
- 実際のパスワードは保存されません
- bcryptでハッシュ化された値が保存されます
- 第4章で詳しく説明しますが、今は「パスワードを安全に保存する場所」と理解してください
モデルの作成
実際にやってみましょう!
まずUserモデルを作成します:
# Userモデルを生成
docker compose exec web rails generate model User email:string:uniq name:string password_digest:string
# 生成されたファイルを確認
ls -la db/migrate/
生成されたマイグレーションファイルを確認します:
class CreateUsers < ActiveRecord::Migration[8.0]
def change
create_table :users do |t|
t.string :email, null: false
t.string :name, null: false
t.string :password_digest, null: false
t.timestamps
end
add_index :users, :email, unique: true
end
end
次にTodoモデルを作成します:
# Todoモデルを生成
docker compose exec web rails generate model Todo user:references title:string description:text completed:boolean
# 生成されたファイルを確認
ls -la db/migrate/
Todoのマイグレーションファイルも確認します:
class CreateTodos < ActiveRecord::Migration[8.0]
def change
create_table :todos do |t|
t.references :user, null: false, foreign_key: true
t.string :title, null: false
t.text :description
t.boolean :completed, default: false, null: false
t.timestamps
end
# 複合インデックスを追加(ユーザーごとのTodo取得を高速化)
add_index :todos, [:user_id, :created_at]
end
end
add_index :todos, [:user_id, :created_at]は複合インデックスです。
- ユーザーのTodo一覧を取得する際のパフォーマンスが向上
- 作成日時順でソートする際も高速化
User.find(1).todos.order(created_at: :desc)のようなクエリで効果を発揮
マイグレーションの実行
作成したマイグレーションファイルで実際にデータベースにテーブルを作成します。
実際にやってみましょう!
# マイグレーションを実行
docker compose exec web rails db:migrate
# テーブルが作成されたか確認
docker compose exec web rails console
Railsコンソール内で確認:
[1] pry(main)> # テーブル一覧を表示
[2] pry(main)> ActiveRecord::Base.connection.tables
=> ["schema_migrations", "ar_internal_metadata", "users", "todos"]
[3] pry(main)> # スキーマを確認
[4] pry(main)> User.column_names
=> ["id", "email", "name", "password_digest", "created_at", "updated_at"]
[5] pry(main)> Todo.column_names
=> ["id", "user_id", "title", "description", "completed", "created_at", "updated_at"]
[6] pry(main)> exit
モデル作成をコミット
一度ここまでの変更をコミットしておきます。こまめにコミットすることで、問題が発生した際に原因を特定しやすくなり、必要に応じて特定の時点に戻ることができます。
実際にやってみましょう!
# 変更内容を確認
git status
git diff
# モデルとマイグレーションをコミット
git add .
git commit -m "モデル作成: UserとTodo"
2. モデルの関連付けとバリデーション
アソシエーションの設定
モデル間の関係を定義します。
実際にやってみましょう!
Userモデルを編集します:
class User < ApplicationRecord
# パスワードのセキュア化(第4章で使用)
has_secure_password
# アソシエーション
has_many :todos, dependent: :destroy
# バリデーション
validates :email, presence: true,
uniqueness: { case_sensitive: false },
format: { with: URI::MailTo::EMAIL_REGEXP }
validates :name, presence: true, length: { maximum: 50 }
validates :password, length: { minimum: 6 }, allow_nil: true
# メールアドレスを保存前に小文字に変換
before_save { self.email = email.downcase }
end
dependent: :destroyは、ユーザーが削除されたときの動作を指定します:
- ユーザーを削除すると、そのユーザーのTodoも自動的に削除
- データの整合性を保つための重要な設定
Todoモデルも編集します:
class Todo < ApplicationRecord
# アソシエーション
belongs_to :user
# バリデーション
validates :title, presence: true, length: { maximum: 100 }
validates :description, length: { maximum: 500 }
validates :completed, inclusion: { in: [true, false] }
# スコープ
scope :completed, -> { where(completed: true) }
scope :incomplete, -> { where(completed: false) }
scope :recent, -> { order(created_at: :desc) }
end
スコープは、よく使うクエリに名前を付ける機能です:
# スコープを使わない場合
Todo.where(completed: true).order(created_at: :desc)
# スコープを使う場合
Todo.completed.recent
# チェーンも可能
user.todos.incomplete.recent.limit(5)
モデルのテスト
モデルが正しく動作するかテストします。
実際にやってみましょう!
テスト用のディレクトリとファイルを作成します:
# specディレクトリ構造を作成
docker compose exec web mkdir -p spec/models
docker compose exec web mkdir -p spec/factories
FactoryBotでテストデータを定義します:
FactoryBot.define do
factory :user do
sequence(:email) { |n| "user#{n}@example.com" }
name { "テストユーザー" }
password { "password" }
password_confirmation { "password" }
end
end
FactoryBot.define do
factory :todo do
association :user
sequence(:title) { |n| "Todo #{n}" }
description { "これはテスト用のTodoです" }
completed { false }
trait :completed do
completed { true }
end
trait :with_long_description do
description { "長い説明文" * 50 }
end
end
end
Userモデルのテストを作成します:
require 'rails_helper'
RSpec.describe User, type: :model do
describe 'バリデーション' do
it '有効なファクトリを持つこと' do
expect(build(:user)).to be_valid
end
it 'メールアドレスがなければ無効であること' do
user = build(:user, email: nil)
user.valid?
expect(user.errors[:email]).to include("can't be blank")
end
it '重複したメールアドレスは無効であること' do
create(:user, email: 'test@example.com')
user = build(:user, email: 'TEST@EXAMPLE.COM')
user.valid?
expect(user.errors[:email]).to include("has already been taken")
end
end
describe 'アソシエーション' do
it 'Todoを複数持てること' do
user = create(:user)
create_list(:todo, 3, user: user)
expect(user.todos.count).to eq 3
end
it '削除されたらTodoも削除されること' do
user = create(:user)
create(:todo, user: user)
expect { user.destroy }.to change(Todo, :count).by(-1)
end
end
end
Userモデルのテストを実行します:
# Userモデルのテストのみを実行
docker compose exec web rspec spec/models/user_spec.rb
以下のような結果が表示されれば成功です:
User
バリデーション
有効なファクトリを持つこと
メールアドレスがなければ無効であること
重複したメールアドレスは無効であること
アソシエーション
Todoを複数持てること
削除されたらTodoも削除されること
Finished in 0.12345 seconds (files took 1.23 seconds to load)
5 examples, 0 failures
次にTodoモデルのテストを作成します:
require 'rails_helper'
RSpec.describe Todo, type: :model do
describe 'バリデーション' do
it '有効なファクトリを持つこと' do
expect(build(:todo)).to be_valid
end
it 'タイトルがなければ無効であること' do
todo = build(:todo, title: nil)
todo.valid?
expect(todo.errors[:title]).to include("can't be blank")
end
it 'タイトルが100文字を超える場合は無効であること' do
todo = build(:todo, title: 'a' * 101)
todo.valid?
expect(todo.errors[:title]).to include("is too long (maximum is 100 characters)")
end
it '説明が500文字を超える場合は無効であること' do
todo = build(:todo, description: 'a' * 501)
todo.valid?
expect(todo.errors[:description]).to include("is too long (maximum is 500 characters)")
end
end
describe 'アソシエーション' do
it 'ユーザーに属すること' do
association = described_class.reflect_on_association(:user)
expect(association.macro).to eq :belongs_to
end
end
describe 'スコープ' do
let!(:completed_todo) { create(:todo, :completed) }
let!(:incomplete_todo) { create(:todo, completed: false) }
it '完了済みのTodoを取得できること' do
expect(Todo.completed).to include(completed_todo)
expect(Todo.completed).not_to include(incomplete_todo)
end
it '未完了のTodoを取得できること' do
expect(Todo.incomplete).to include(incomplete_todo)
expect(Todo.incomplete).not_to include(completed_todo)
end
it '作成日時の新しい順に取得できること' do
old_todo = create(:todo, created_at: 1.day.ago)
new_todo = create(:todo, created_at: 1.hour.ago)
# recentスコープ内での順序を確認
todos = Todo.recent
new_todo_index = todos.index(new_todo)
old_todo_index = todos.index(old_todo)
expect(new_todo_index).to be < old_todo_index
end
end
end
Todoモデルのテストを実行します:
# Todoモデルのテストのみを実行
docker compose exec web rspec spec/models/todo_spec.rb
以下のような結果が表示されれば成功です:
Todo
バリデーション
有効なファクトリを持つこと
タイトルがなければ無効であること
タイトルが100文字を超える場合は無効であること
説明が500文字を超える場合は無効であること
アソシエーション
ユーザーに属すること
スコープ
完了済みのTodoを取得できること
未完了のTodoを取得できること
作成日時の新しい順に取得できること
Finished in 0.23456 seconds (files took 1.23 seconds to load)
8 examples, 0 failures
最後に、すべてのモデルテストを実行して確認します:
# すべてのモデルテストを実行
docker compose exec web rspec spec/models
以下のような結果が表示されれば成功です:
User
バリデーション
有効なファクトリを持つこと
メールアドレスがなければ無効であること
重複したメールアドレスは無効であること
アソシエーション
Todoを複数持てること
削除されたらTodoも削除されること
Todo
バリデーション
有効なファクトリを持つこと
タイトルがなければ無効であること
タイトルが100文字を超える場合は無効であること
説明が500文字を超える場合は無効であること
アソシエーション
ユーザーに属すること
スコープ
完了済みのTodoを取得できること
未完了のTodoを取得できること
作成日時の新しい順に取得できること
Finished in 0.34567 seconds (files took 1.23 seconds to load)
13 examples, 0 failures
モデル設定をコミット
モデルのアソシエーションとバリデーションの設定が完了したので、この時点で変更をコミットします。
実際にやってみましょう!
git add .
git commit -m "モデル設定: アソシエーションとバリデーション"
3. seed_fuによるテストデータ管理
seed_fuの導入
seed_fuは、Railsの標準的なseedよりも高機能なデータ投入ツールです。
標準のdb/seeds.rbとの違い:
- 冪等性: 何度実行しても同じ結果になる
- 環境別管理: development/staging/productionで異なるデータ
- 更新可能: 既存データの更新も簡単
- 高速: 大量データの投入も効率的
実際にやってみましょう!
Gemfileに追加します:
group :development, :test do
# ... 既存のGem ...
gem "seed-fu", "~> 2.3" # シードデータ管理
end
Gemをインストールします:
# Dockerコンテナ内でbundle installを実行
docker compose run --rm web bundle install
# イメージを再ビルド
docker compose build
# コンテナを再起動
docker compose up -d
シードデータの作成
実際にやってみましょう!
seed_fu用のディレクトリを作成します:
# seed_fuのディレクトリ構造を作成
docker compose exec web mkdir -p db/fixtures/development
ユーザーのシードデータを作成します:
# 開発用のテストユーザーを作成
# seed_fuは同じデータを重複して作成しないので安心
User.seed(:email,
{
id: 1,
email: "alice@example.com",
name: "Alice",
password: "password",
password_confirmation: "password"
},
{
id: 2,
email: "bob@example.com",
name: "Bob",
password: "password",
password_confirmation: "password"
},
{
id: 3,
email: "charlie@example.com",
name: "Charlie",
password: "password",
password_confirmation: "password"
}
)
puts "ユーザーを作成しました"
Todoのシードデータも作成します:
# 各ユーザーにTodoを作成
# ファイル名の数字(02)で実行順序を制御
# Aliceのタスク
Todo.seed(:id,
{
id: 1,
user_id: 1,
title: "Rails APIの勉強",
description: "RESTful APIの設計原則を理解する",
completed: true
},
{
id: 2,
user_id: 1,
title: "RSpecでテストを書く",
description: "TodosControllerのテストを完成させる",
completed: false
},
{
id: 3,
user_id: 1,
title: "フロントエンドの実装",
description: "ReactでTodoリストのUIを作成",
completed: false
}
)
# Bobのタスク
Todo.seed(:id,
{
id: 4,
user_id: 2,
title: "買い物リスト",
description: "牛乳、パン、卵",
completed: false
},
{
id: 5,
user_id: 2,
title: "ジムに行く",
description: nil,
completed: true
}
)
# Charlieのタスク
Todo.seed(:id,
{
id: 6,
user_id: 3,
title: "レポート提出",
description: "月次レポートを上司に提出する",
completed: false
}
)
puts "Todoを作成しました"
puts " - Alice: #{Todo.where(user_id: 1).count}件"
puts " - Bob: #{Todo.where(user_id: 2).count}件"
puts " - Charlie: #{Todo.where(user_id: 3).count}件"
シードデータを投入します:
# seed_fuでデータを投入
docker compose exec web rails db:seed_fu
# データが投入されたか確認
docker compose exec web rails console
Railsコンソールで確認:
[1] pry(main)> # ユーザー数を確認
[2] pry(main)> User.count
=> 3
[3] pry(main)> User.pluck(:name, :email)
=> [["Alice", "alice@example.com"], ["Bob", "bob@example.com"], ["Charlie", "charlie@example.com"]]
[4] pry(main)> # Todo数を確認
[5] pry(main)> Todo.count
=> 6
[6] pry(main)> User.find(1).todos.pluck(:title, :completed)
=> [["Rails APIの勉強", true], ["RSpecでテストを書く", false], ["フロントエンドの実装", false]]
[7] pry(main)> exit
seed_fu設定をコミット
seed_fuの設定とテストデータの作成が完了したので、この変更をコミットします。
実際にやってみましょう!
git add .
git commit -m "seed_fu導入: テストデータ管理"
4. TodosコントローラーとCRUD実装
RESTfulなAPI設計
まず、どのようなAPIエンドポイントを作るか設計します。
| HTTPメソッド | パス | アクション | 説明 |
|---|---|---|---|
| GET | /api/v1/todos | index | Todo一覧取得 |
| GET | /api/v1/todos/:id | show | Todo詳細取得 |
| POST | /api/v1/todos | create | Todo作成 |
| PATCH/PUT | /api/v1/todos/:id | update | Todo更新 |
| DELETE | /api/v1/todos/:id | destroy | Todo削除 |
認証機能がないため、リクエストでuser_idを指定する必要があります。
これはセキュリティ的に問題がある実装ですが、学習のために一時的に採用します。
第4章で認証機能を追加して、この問題を解決します。
ルーティングの設定
実際にやってみましょう!
ルーティングを設定します:
Rails.application.routes.draw do
# ヘルスチェック
get '/health', to: 'application#health'
# API v1
namespace :api do
namespace :v1 do
resources :todos
end
end
end
ルーティングを確認します:
# ルーティング一覧を表示
docker compose exec web rails routes | grep todos
期待される出力:
GET /api/v1/todos(.:format) api/v1/todos#index
POST /api/v1/todos(.:format) api/v1/todos#create
GET /api/v1/todos/:id(.:format) api/v1/todos#show
PATCH /api/v1/todos/:id(.:format) api/v1/todos#update
PUT /api/v1/todos/:id(.:format) api/v1/todos#update
DELETE /api/v1/todos/:id(.:format) api/v1/todos#destroy
コントローラーの実装
実際にやってみましょう!
TodosControllerを作成します:
# コントローラーを生成
docker compose exec web rails generate controller api/v1/todos
コントローラーを実装します:
module Api
module V1
class TodosController < ApplicationController
before_action :set_user
before_action :set_todo, only: [:show, :update, :destroy]
# GET /api/v1/todos
def index
@todos = @user.todos
render json: @todos
end
# GET /api/v1/todos/:id
def show
render json: @todo
end
# POST /api/v1/todos
def create
@todo = @user.todos.build(todo_params)
if @todo.save
render json: @todo, status: :created
else
render json: { errors: @todo.errors.full_messages },
status: :unprocessable_entity
end
end
# PATCH/PUT /api/v1/todos/:id
def update
if @todo.update(todo_params)
render json: @todo
else
render json: { errors: @todo.errors.full_messages },
status: :unprocessable_entity
end
end
# DELETE /api/v1/todos/:id
def destroy
@todo.destroy
head :no_content
end
private
# 一時的な実装:リクエストからuser_idを取得
def set_user
@user = User.find(params[:user_id])
rescue ActiveRecord::RecordNotFound
render json: { error: 'User not found' }, status: :not_found
end
def set_todo
@todo = @user.todos.find(params[:id])
rescue ActiveRecord::RecordNotFound
render json: { error: 'Todo not found' }, status: :not_found
end
def todo_params
params.require(:todo).permit(:title, :description, :completed)
end
end
end
end
レスポンスの整形(Serializer)
ActiveModelSerializersを使って、APIレスポンスを整形します。
実際にやってみましょう!
Serializerを作成します:
# Serializerを生成
docker compose exec web rails generate serializer todo
docker compose exec web rails generate serializer user
TodoSerializerを実装します:
class TodoSerializer < ActiveModel::Serializer
attributes :id, :title, :description, :completed, :created_at, :updated_at
# ユーザー情報も含める
belongs_to :user
# 作成からの経過時間を追加
attribute :elapsed_time
def elapsed_time
return nil unless object.created_at
seconds = Time.current - object.created_at
case seconds
when 0..59
"#{seconds.to_i}秒前"
when 60..3599
"#{(seconds / 60).to_i}分前"
when 3600..86399
"#{(seconds / 3600).to_i}時間前"
else
"#{(seconds / 86400).to_i}日前"
end
end
end
UserSerializerも実装します:
class UserSerializer < ActiveModel::Serializer
attributes :id, :name, :email
# パスワード関連の情報は返さない
end
APIのテスト
RSpecでAPIの動作をテストします。
実際にやってみましょう!
テスト用のディレクトリを作成します:
# specディレクトリ構造を作成
docker compose exec web mkdir -p spec/requests/api/v1
リクエストスペックを作成します:
require 'rails_helper'
RSpec.describe "Api::V1::Todos", type: :request do
let(:user) { create(:user) }
let(:todo) { create(:todo, user: user) }
describe "GET /api/v1/todos" do
it "ユーザーのTodo一覧を返すこと" do
create_list(:todo, 3, user: user)
get "/api/v1/todos", params: { user_id: user.id }
expect(response).to have_http_status(:success)
json = JSON.parse(response.body)
expect(json.size).to eq 3 # この時点では配列を直接返すため
end
it "存在しないユーザーの場合404を返すこと" do
get "/api/v1/todos", params: { user_id: 9999 }
expect(response).to have_http_status(:not_found)
end
end
describe "POST /api/v1/todos" do
it "新しいTodoを作成できること" do
todo_params = {
todo: {
title: "新しいタスク",
description: "これは新しいタスクです",
completed: false
},
user_id: user.id
}
expect {
post "/api/v1/todos", params: todo_params
}.to change(Todo, :count).by(1)
expect(response).to have_http_status(:created)
json = JSON.parse(response.body)
expect(json["title"]).to eq "新しいタスク"
end
it "無効なパラメータの場合エラーを返すこと" do
todo_params = {
todo: { title: "" },
user_id: user.id
}
post "/api/v1/todos", params: todo_params
expect(response).to have_http_status(:unprocessable_entity)
json = JSON.parse(response.body)
expect(json["errors"]).to include("Title can't be blank")
end
end
describe "PATCH /api/v1/todos/:id" do
it "Todoを更新できること" do
patch "/api/v1/todos/#{todo.id}",
params: {
todo: { completed: true },
user_id: user.id
}
expect(response).to have_http_status(:success)
expect(todo.reload.completed).to be true
end
end
describe "DELETE /api/v1/todos/:id" do
it "Todoを削除できること" do
todo # let!ではないので、ここで作成
expect {
delete "/api/v1/todos/#{todo.id}", params: { user_id: user.id }
}.to change(Todo, :count).by(-1)
expect(response).to have_http_status(:no_content)
end
end
end
テストを先に書いてから実装する開発手法です。
TDDのサイクル:
- Red: テストを書いて失敗させる
- Green: テストが通る最小限のコードを書く
- Refactor: コードを改善する
現場では意見が分かれることもありますが、APIのような明確な仕様がある場合は特に有効です。
テストを実行します:
# TodosControllerのテストを実行
docker compose exec web rspec spec/requests/api/v1/todos_spec.rb
curlでの動作確認
実際にAPIを叩いて動作を確認します。curlコマンドを使用して、各APIエンドポイントが正しく動作するかをテストしていきます。
実際にやってみましょう!
サーバーを起動してAPIをテストします:
# Railsサーバーが起動していることを確認
docker compose ps
# Todo一覧を取得(Alice)
curl -X GET 'http://localhost:3000/api/v1/todos?user_id=1' | jq
# 新しいTodoを作成
curl -X POST 'http://localhost:3000/api/v1/todos' \
-H "Content-Type: application/json" \
-d '{
"user_id": 1,
"todo": {
"title": "curlから作成したタスク",
"description": "APIのテストです",
"completed": false
}
}' | jq
# 特定のTodoを取得(ID=1)
curl -X GET 'http://localhost:3000/api/v1/todos/1?user_id=1' | jq
# Todoを更新(ID=1を完了状態に)
curl -X PATCH 'http://localhost:3000/api/v1/todos/1' \
-H "Content-Type: application/json" \
-d '{
"user_id": 1,
"todo": {
"completed": true
}
}' | jq
# Todoを削除(ID=1)
curl -X DELETE 'http://localhost:3000/api/v1/todos/1?user_id=1'
jqはJSONを見やすく整形するコマンドラインツールです。
インストールされていない場合は:
- Mac:
brew install jq - Ubuntu:
sudo apt-get install jq
CRUD実装をコミット
基本的なCRUD機能の実装が完了したので、この変更をコミットします。
実際にやってみましょう!
git add .
git commit -m "TodosController: CRUD API実装"
5. 追加機能の実装
検索とフィルタリング
Todo一覧に検索機能を追加します。
実際にやってみましょう!
TodosControllerのindexアクションを拡張します:
# GET /api/v1/todos
def index
@todos = @user.todos
# タイトルで検索
if params[:q].present?
@todos = @todos.where("title LIKE ?", "%#{params[:q]}%")
end
# 完了状態でフィルタリング
if params[:completed].present?
@todos = @todos.where(completed: ActiveModel::Type::Boolean.new.cast(params[:completed]))
end
# ページネーション(kaminariなしでシンプルに)
page = (params[:page] || 1).to_i
per_page = (params[:per_page] || 10).to_i
per_page = 100 if per_page > 100 # 最大100件
@todos = @todos.limit(per_page).offset((page - 1) * per_page)
# メタ情報を含めて返す
render json: {
todos: ActiveModelSerializers::SerializableResource.new(@todos).as_json,
meta: {
current_page: page,
per_page: per_page,
total_count: @user.todos.count
}
}
end
RailsのAPIでは、URLのクエリパラメータを使って検索やフィルタリングを行うのが一般的です:
?user_id=1→params[:user_id]でユーザーを特定(認証実装前なので必須)?q=Rails→params[:q]で検索キーワードを取得?completed=true→params[:completed]で完了状態を指定- 複数の条件は
&でつなげる:?user_id=1&q=Rails&completed=false
curlコマンドで動作確認してみましょう(Aliceのデータで検索):
# タイトルで検索(「Rails」を含むTodoを検索)
curl "http://localhost:3000/api/v1/todos?user_id=1&q=Rails" | jq
# => "Rails APIの勉強"がヒット
# タイトルで検索(「実装」を含むTodoを検索)
curl "http://localhost:3000/api/v1/todos?user_id=1&q=実装" | jq
# => "フロントエンドの実装"がヒット
# 完了済みのTodoのみ取得(Aliceは1件のみ完了済み)
curl "http://localhost:3000/api/v1/todos?user_id=1&completed=true" | jq
# => "Rails APIの勉強"のみ表示
# 未完了のTodoのみ取得
curl "http://localhost:3000/api/v1/todos?user_id=1&completed=false" | jq
# => "RSpecでテストを書く"と"フロントエンドの実装"が表示
# 検索とフィルタリングの組み合わせ(「テスト」を含む未完了のTodo)
curl "http://localhost:3000/api/v1/todos?user_id=1&q=テスト&completed=false" | jq
# => "RSpecでテストを書く"がヒット
# ページネーション(1ページ目、2件ずつ)
curl "http://localhost:3000/api/v1/todos?user_id=1&page=1&per_page=2" | jq
統計情報の追加
ユーザーのTodo統計を返すエンドポイントを追加します。
実際にやってみましょう!
ルーティングに追加します:
Rails.application.routes.draw do
# ヘルスチェック用のエンドポイント
get '/health', to: 'application#health'
# APIのルーティング
namespace :api do
namespace :v1 do
resources :todos do
collection do
get :stats # 統計情報を取得するエンドポイント
end
end
end
end
end
resources :todos のブロック内で使える特別なメソッドです:
collection - コレクション全体に対するアクション(IDなし)
collection do
get :stats # GET /api/v1/todos/stats
post :import # POST /api/v1/todos/import
delete :clear # DELETE /api/v1/todos/clear
end
member - 個別のリソースに対するアクション(IDあり)
member do
patch :toggle # PATCH /api/v1/todos/:id/toggle
post :duplicate # POST /api/v1/todos/:id/duplicate
get :history # GET /api/v1/todos/:id/history
end
今回の get :stats は、全てのTodoの統計情報を取得するため、特定のIDは不要なので collection を使用しています。
ルーティングが正しく設定されているか確認しましょう:
# statsルートが追加されているか確認
docker compose exec web rails routes | grep stats
期待される出力:
GET /api/v1/todos/stats(.:format) api/v1/todos#stats
ルーティングファイルを保存後、サーバーを再起動してください:
docker compose restart web
TodosControllerにstatsアクションを追加します:
module Api
module V1
class TodosController < ApplicationController
before_action :set_user
before_action :set_todo, only: [:show, :update, :destroy]
# GET /api/v1/todos
def index
# ===========================================
# このアクションは「5.1 検索とフィルタリング」で
# 以下の機能を実装済み:
# - タイトルでの検索(params[:q])
# - 完了状態でのフィルタリング(params[:completed])
# - ページネーション(params[:page], params[:per_page])
# ===========================================
# ... 実装済みのコードは省略 ...
end
# GET /api/v1/todos/:id(CRUD基本実装で作成済み)
def show
# ... 省略 ...
end
# POST /api/v1/todos(CRUD基本実装で作成済み)
def create
# ... 省略 ...
end
# PATCH/PUT /api/v1/todos/:id(CRUD基本実装で作成済み)
def update
# ... 省略 ...
end
# DELETE /api/v1/todos/:id(CRUD基本実装で作成済み)
def destroy
# ... 省略 ...
end
# ===========================================
# ↓ 今回新規追加するアクション
# ===========================================
# GET /api/v1/todos/stats
def stats
render json: {
total: @user.todos.count,
completed: @user.todos.completed.count,
incomplete: @user.todos.incomplete.count,
completion_rate: calculate_completion_rate
}
end
private
def set_user
# CRUD基本実装で作成済み
@user = User.find(params[:user_id])
rescue ActiveRecord::RecordNotFound
render json: { error: 'User not found' }, status: :not_found
end
def set_todo
# ... 省略(CRUD基本実装で作成済み)...
end
def todo_params
# ... 省略(CRUD基本実装で作成済み)...
end
# ===========================================
# ↓ 今回新規追加するprivateメソッド
# ===========================================
def calculate_completion_rate
total = @user.todos.count
return 0 if total.zero?
completed = @user.todos.completed.count
((completed.to_f / total) * 100).round(1)
end
end
end
end
curlコマンドで統計情報APIを確認してみましょう:
# 統計情報を取得(Alice: user_id=1)
curl "http://localhost:3000/api/v1/todos/stats?user_id=1" | jq
期待されるレスポンス例(Aliceのデータ):
{
"total": 3,
"completed": 1,
"incomplete": 2,
"completion_rate": 33.3
}
追加機能のテスト
動作確認ができたら、テストコードで機能を保証します。
検索・フィルタリング機能の実装で、indexアクションのレスポンス形式が変更されました:
- 変更前:
[todo1, todo2, ...](配列を直接返す) - 変更後:
{ todos: [...], meta: {...} }(構造化されたレスポンス)
そのため、既存のindexアクションのテストも更新が必要です:
describe "GET /api/v1/todos" do
it "ユーザーのTodo一覧を返すこと" do
create_list(:todo, 3, user: user)
get "/api/v1/todos", params: { user_id: user.id }
expect(response).to have_http_status(:success)
json = JSON.parse(response.body)
expect(json["todos"].size).to eq 3 # json.size → json["todos"].size に変更
end
it "存在しないユーザーの場合404を返すこと" do
get "/api/v1/todos", params: { user_id: 9999 }
expect(response).to have_http_status(:not_found)
end
end
実際にやってみましょう!
追加機能のテストを作成します:
describe "GET /api/v1/todos (with filters)" do
before do
create(:todo, user: user, title: "Rails APIの勉強", completed: true)
create(:todo, user: user, title: "RSpecでテストを書く", completed: false)
create(:todo, user: user, title: "フロントエンドの実装", completed: false)
end
it "タイトルで検索できること" do
get "/api/v1/todos", params: { user_id: user.id, q: "Rails" }
json = JSON.parse(response.body)
expect(json["todos"].size).to eq 1
end
it "完了状態でフィルタリングできること" do
get "/api/v1/todos", params: { user_id: user.id, completed: true }
json = JSON.parse(response.body)
expect(json["todos"].size).to eq 1
end
end
describe "GET /api/v1/todos/stats" do
before do
create(:todo, user: user, title: "Rails APIの勉強", completed: true)
create(:todo, user: user, title: "RSpecでテストを書く", completed: false)
create(:todo, user: user, title: "フロントエンドの実装", completed: false)
end
it "統計情報を返すこと" do
get "/api/v1/todos/stats", params: { user_id: user.id }
expect(response).to have_http_status(:success)
json = JSON.parse(response.body)
expect(json["total"]).to eq 3
expect(json["completed"]).to eq 1
expect(json["incomplete"]).to eq 2
expect(json["completion_rate"]).to eq 33.3
end
end
追加機能をコミット
検索・フィルタリング・統計情報の追加機能の実装が完了したので、この変更をコミットします。
実際にやってみましょう!
git add .
git commit -m "追加機能: 検索・フィルタリング・統計"
6. GitHubへのプッシュとプルリクエスト
コミット履歴の確認
実際にやってみましょう!
これまでのコミットを確認します:
# コミット履歴を確認
git log --oneline -n 10
GitHubへプッシュ
実際にやってみましょう!
作業内容をGitHubにプッシュします:
# feature/chapter-3ブランチをプッシュ
git push -u origin feature/chapter-3
プルリクエストの作成
GitHubでプルリクエストを作成します:
- ブラウザでGitHubリポジトリを開く
- 「Compare & pull request」ボタンをクリック
- プルリクエストの内容を入力:
Title:
第3章: Todo CRUD API(認証なし版)
Description:
## 概要
認証機能を実装する前段階として、基本的なCRUD APIを実装しました。
## 実装内容
- ✅ UserモデルとTodoモデルの作成
- ✅ モデル間のアソシエーション設定
- ✅ seed_fuによるテストデータ管理
- ✅ RESTful APIエンドポイントの実装
- ✅ 検索・フィルタリング機能
- ✅ 統計情報API
- ✅ RSpecによるテスト
## 動作確認
- [ ] `docker compose exec web rails db:seed_fu` でテストデータが投入されること
- [ ] `curl -X GET http://localhost:3000/api/v1/todos?user_id=1` でTodo一覧が取得できること
- [ ] `docker compose exec web rspec` で全テストが通ること
## 次章への課題
- user_idをリクエストで受け取る現在の実装はセキュリティリスクがある
- 第4章で認証機能を追加して、この問題を解決する予定
## レビューポイント
- APIの設計は適切か
- エラーハンドリングは十分か
- テストケースの網羅性
まとめ
この章で達成したこと
第3章お疲れさまでした!実践的なAPI開発の基礎が身につきました:
- モデル設計: UserとTodoの関連付け
- seed_fu: 効率的なテストデータ管理
- RESTful API: 標準的なCRUD操作の実装
- 追加機能: 検索・フィルタリング・統計
- テスト駆動開発: RSpecでの品質保証
現在の課題
user_idをパラメータで指定する現在の実装は、誰でも他人のTodoを操作できるセキュリティ問題があります。
次章で解決すること
第4章では、この問題を解決するために:
- 認証機能の実装
- Cookieベースのセッション管理
- セキュアなAPIの構築
🌿 第3章のコミット履歴
演習問題
理解を深めるために、以下の課題に挑戦してみます。
演習1: バルクアップデート機能
複数のTodoを一度に更新できるエンドポイントを追加してください。
期待されるリクエスト:
{
"user_id": 1,
"todo_ids": [1, 2, 3],
"todo": {
"completed": true
}
}
ヒント(クリックで展開)
- 新しいルートを追加:
patch :bulk_update, on: :collection where(id: params[:todo_ids])で複数のTodoを取得update_allメソッドで一括更新
答え(クリックで展開)
# config/routes.rb
resources :todos do
collection do
get :stats
patch :bulk_update # 追加
end
end
# app/controllers/api/v1/todos_controller.rb
def bulk_update
todo_ids = params[:todo_ids] || []
@todos = @user.todos.where(id: todo_ids)
if @todos.update_all(todo_params.to_h)
render json: { updated_count: @todos.count }
else
render json: { error: 'Update failed' }, status: :unprocessable_entity
end
end
演習2: ソート機能の追加
Todo一覧をソートできるようにしてください。
期待される使い方:
# 作成日時の降順(デフォルト)
GET /api/v1/todos?user_id=1&sort=created_at&order=desc
# タイトルの昇順
GET /api/v1/todos?user_id=1&sort=title&order=asc
ヒント(クリックで展開)
params[:sort]とparams[:order]を取得- ホワイトリストで許可するカラムを制限
orderメソッドでソート
答え(クリックで展開)
def index
@todos = @user.todos
# 既存のフィルタリング処理...
# ソート機能を追加
sort_column = %w[title created_at updated_at completed].include?(params[:sort]) ? params[:sort] : 'created_at'
sort_order = %w[asc desc].include?(params[:order]) ? params[:order] : 'desc'
@todos = @todos.reorder("#{sort_column} #{sort_order}")
# 既存のページネーション処理...
end
参考資料
より深く学びたい方向け: