Skip to main content

第3章: Todo CRUD API(認証なし版)

この章で学ぶこと(所要時間: 3時間)

この章では、認証機能を実装する前に、まずTodoアプリケーションの基本的なCRUD機能を実装します。APIの基本を学び、テスト駆動開発(TDD)の実践も行います。

はじめに

第2章でRails APIの基盤が整いました。いよいよ本格的なAPI開発に入ります。

多くのチュートリアルでは最初に認証機能を実装しますが、この教材ではあえて認証なしでCRUD機能を先に作ります。なぜなら:

  1. CRUD操作の基本に集中できる
  2. テストが書きやすい(認証を考えなくて良い)
  3. 段階的に複雑さを増やせる

実際の開発でも、プロトタイプ段階では認証なしで基本機能を作り、後から認証を追加することはよくあります。

この章を終えると...

  • RESTful APIの設計原則が理解できる
  • モデルの関連付け(アソシエーション)が使えるようになる
  • seed_fuを使ったテストデータ管理ができる
  • RSpecでAPIのテストが書ける
  • 実践的なCRUD操作が実装できる

この章で身につくスキル

  • モデルの設計とマイグレーション
  • ActiveRecordのアソシエーション
  • seed_fuによるデータ投入
  • RESTfulなAPIエンドポイント設計
  • RSpecによるリクエストスペック
  • JSONレスポンスの整形
この章の進め方
  1. セクション0: 作業ブランチの作成(実践)
  2. セクション1〜2: モデルの作成と設定(実践)
  3. セクション3: seed_fuでテストデータ投入(実践)
  4. セクション4〜5: CRUD実装と追加機能(実践)
  5. セクション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・FKとは?
  • PK(Primary Key): 主キー。テーブル内の各レコードを一意に識別するカラム
  • FK(Foreign Key): 外部キー。他のテーブルとの関連を示すカラム。todosテーブルのuser_idは、usersテーブルのidを参照しています
password_digestとは?

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/

生成されたマイグレーションファイルを確認します:

db/migrate/xxx_create_users.rb
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のマイグレーションファイルも確認します:

db/migrate/xxx_create_todos.rb
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モデルを編集します:

app/models/user.rb
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とは?

dependent: :destroyは、ユーザーが削除されたときの動作を指定します:

  • ユーザーを削除すると、そのユーザーのTodoも自動的に削除
  • データの整合性を保つための重要な設定

Todoモデルも編集します:

app/models/todo.rb
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でテストデータを定義します:

spec/factories/users.rb
FactoryBot.define do
factory :user do
sequence(:email) { |n| "user#{n}@example.com" }
name { "テストユーザー" }
password { "password" }
password_confirmation { "password" }
end
end
spec/factories/todos.rb
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モデルのテストを作成します:

spec/models/user_spec.rb
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モデルのテストを作成します:

spec/models/todo_spec.rb
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よりも高機能なデータ投入ツールです。

なぜseed_fu?

標準のdb/seeds.rbとの違い:

  • 冪等性: 何度実行しても同じ結果になる
  • 環境別管理: development/staging/productionで異なるデータ
  • 更新可能: 既存データの更新も簡単
  • 高速: 大量データの投入も効率的

実際にやってみましょう!

Gemfileに追加します:

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

ユーザーのシードデータを作成します:

db/fixtures/development/01_users.rb
# 開発用のテストユーザーを作成
# 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のシードデータも作成します:

db/fixtures/development/02_todos.rb
# 各ユーザーに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/todosindexTodo一覧取得
GET/api/v1/todos/:idshowTodo詳細取得
POST/api/v1/todoscreateTodo作成
PATCH/PUT/api/v1/todos/:idupdateTodo更新
DELETE/api/v1/todos/:iddestroyTodo削除
現時点での注意

認証機能がないため、リクエストでuser_idを指定する必要があります。 これはセキュリティ的に問題がある実装ですが、学習のために一時的に採用します。 第4章で認証機能を追加して、この問題を解決します。

ルーティングの設定

実際にやってみましょう!

ルーティングを設定します:

config/routes.rb
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

コントローラーを実装します:

app/controllers/api/v1/todos_controller.rb
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を実装します:

app/serializers/todo_serializer.rb
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も実装します:

app/serializers/user_serializer.rb
class UserSerializer < ActiveModel::Serializer
attributes :id, :name, :email

# パスワード関連の情報は返さない
end

APIのテスト

RSpecでAPIの動作をテストします。

実際にやってみましょう!

テスト用のディレクトリを作成します:

ターミナルで実行
# specディレクトリ構造を作成
docker compose exec web mkdir -p spec/requests/api/v1

リクエストスペックを作成します:

spec/requests/api/v1/todos_spec.rb
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(テスト駆動開発)について

テストを先に書いてから実装する開発手法です。

TDDのサイクル:

  1. Red: テストを書いて失敗させる
  2. Green: テストが通る最小限のコードを書く
  3. 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コマンド

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アクションを拡張します:

app/controllers/api/v1/todos_controller.rb(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
💡 URLパラメータでの検索・フィルタリング

RailsのAPIでは、URLのクエリパラメータを使って検索やフィルタリングを行うのが一般的です:

  • ?user_id=1params[:user_id]でユーザーを特定(認証実装前なので必須)
  • ?q=Railsparams[:q]で検索キーワードを取得
  • ?completed=trueparams[: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統計を返すエンドポイントを追加します。

実際にやってみましょう!

ルーティングに追加します:

config/routes.rb
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
💡 collectionブロックとは?

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
⚠️ もしstatsルートが表示されない場合

ルーティングファイルを保存後、サーバーを再起動してください:

docker compose restart web

TodosControllerにstatsアクションを追加します:

app/controllers/api/v1/todos_controller.rb(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アクションのテストも更新が必要です:

spec/requests/api/v1/todos_spec.rb(更新後)
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

実際にやってみましょう!

追加機能のテストを作成します:

spec/requests/api/v1/todos_spec.rb(追加分)
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でプルリクエストを作成します:

  1. ブラウザでGitHubリポジトリを開く
  2. 「Compare & pull request」ボタンをクリック
  3. プルリクエストの内容を入力:

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

参考資料

より深く学びたい方向け:


次章: 第4章: 認証基盤(Cookieベース)