【ポートフォリオに生かせる!】Next.js14とRailsAPIで作るカレンダーアプリ📅
アプリデモ
ソースコード
学習技術
- Full Calendar,shadcn-ui, tailwind使用したUIの構築
- Redis使用したRails7/Next.js14 認証フローとセッション管理方法
- TanstackQueryを使用したデータフェッチ方法
- react-hook-formを使用したフォーム管理
- Dockerを使用した環境構築
- MySQLを使用したDB操作
- Redisを使ったセッション管理
Redis使用したRails/NextJS 認証フローの簡単な図解
概要
このシステムでは、以下の流れで認証とセッション管理を行います
- ユーザーがNextJSアプリケーションでログインを試みる
- NextJSがRails APIに認証リクエストを送信
- Rails APIが認証を確認し、結果を返す
- 認証成功の場合、NextJSがRedisにセッション情報を保存
- Next.jsがセッションIDをCookieに保存
- 以降のリクエストでは、CookieのセッションIDを使用して認証状態を維持
詳細フロー
基本的にはGithubのコミットを辿っていって作成していただきたいので、ここからは実装のポイントとなる部分だけ解説していきます。
Rails側
1. ユーザーログイン(サインイン)
ユーザーがログインを試みる際、クライアント(NextJS)から以下のエンドポイントにPOSTリクエストが送信されます:
POST /auth/sign_in
このリクエストは devise_token_auth
gem によって処理されます。
2. 認証処理
devise_token_auth
は自動的に認証処理を行い、成功した場合はユーザー情報と認証トークンを返します。
3. 現在のユーザー状態の確認
ログイン後や、アプリケーションの再読み込み時に、現在のユーザーの認証状態を確認するために使用されるエンドポイントがあります:
# app/controllers/auth/sessions_controller.rb
class Auth::SessionsController < ApplicationController
def index
if current_user
render json: { is_login: true, data: current_user }
else
render json: { is_login: false, message: "ユーザーが存在しません" }
end
end
end
☝️ POINT ✏️
Userモデルに記載してある下記のコードについて
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable
include DeviseTokenAuth::Concerns::User
これらはDeviseTokenAuthを使用している際に提供されているバリデーションメソッドです。
それぞれ解説します。
-
:database_authenticatable
- 機能:パスワードの暗号化と認証を処理します。
- 主な機能:
- パスワードをハッシュ化してデータベースに保存
-
authenticate
メソッドを提供し、ユーザーの認証を行う
- 追加されるフィールド:
encrypted_password
-
:registerable
- 機能:ユーザーの新規登録と編集、削除を可能にします。
- 主な機能:
- サインアップのためのルーティングとコントローラーアクションを提供
- アカウント編集・削除機能を提供
- 追加されるメソッド:
register
sign_up
-
:recoverable
- 機能:パスワードのリセットと回復メカニズムを提供します。
- 主な機能:
- パスワードリセット用のトークン生成
- パスワードリセットの手順を提供
- 追加されるフィールド:
reset_password_token
reset_password_sent_at
-
:rememberable
- 機能:ユーザーのログイン情報を記憶するためのトークンを管理します。
- 主な機能:
- 「ログインを記憶する」機能を提供
- セッションの有効期限を管理
- 追加されるフィールド:
remember_created_at
まとめるとこれらのモジュールは、以下の様な認証関連機能を提供します:
-
:database_authenticatable
は基本的な認証システムの中核を形成します。 -
:registerable
はユーザー登録のフローを管理します。 -
:recoverable
はパスワードを忘れた場合の回復プロセスを担当します。 -
:rememberable
は長期セッションの管理を行います。
これらのモジュールを組み合わせることで、Deviseは包括的な認証システムを提供します。
最終的にユーザーが認証されているかどうかを確認し、適切なレスポンスを返してくれます。
4. ルーティング設定
これらのエンドポイントを使用可能にするために、routes.rb
ファイルに以下のような設定が必要です:
Rails.application.routes.draw do
mount LetterOpenerWeb::Engine, at: "/letter_opener" if Rails.env.development?
# 認証に必要なルーティングをマウント
mount_devise_token_auth_for "User", at: "auth", controllers: {
registrations: "auth/registrations",
}
namespace :auth do
resources :sessions, only: %i[index]
end
resources :events, only: %i[index create update destroy]
namespace :api do
namespace :v1 do
get "health_check", to: "health_check#index"
end
end
end
Next.js側
💡APIの作成✏️
import { Redis } from 'ioredis';
import { NextResponse } from 'next/server';
import { v4 as uuidv4 } from 'uuid';
const REDIS_URL = process.env.REDIS_URL;
if(!REDIS_URL) {
throw new Error("URLが未定義です");
}
const redis = new Redis(REDIS_URL);
const SESSIONID_EXPIRTY = 60 * 60 * 24
export async function POST(request: Request) {
const { userData } = await request.json();
const sessionId = uuidv4();
await redis.set(sessionId, JSON.stringify(userData), "EX", SESSIONID_EXPIRTY);
return NextResponse.json({sessionId: sessionId});
}
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const sessionId = searchParams.get("sessionId");
if(!sessionId) {
return NextResponse.json({ error: "セッションIDが見つかりませんでした" }, { status: 400} )
}
const userData = await redis.get(sessionId);
if(!userData) {
return NextResponse.json({ error: "セッションIDが見つかりませんでした" }, { status: 400} )
}
console.log(userData);
return NextResponse.json(JSON.parse(userData));
}
export async function DELETE(request: Request) {
const { searchParams } = new URL(request.url);
const sessionId = searchParams.get("sessionId");
if(!sessionId) {
return NextResponse.json({ error: "セッションIDが見つかりませんでした" }, { status: 400} )
}
await redis.del(sessionId);
return NextResponse.json({ message: "セッションIDを削除しました"});
}
1. ユーザーログイン
ユーザーがログインフォームに認証情報を入力し、送信します。
const handleLogin = async() => {
try {
const res = await signIn({ email, password });
const accessToken = res.headers["access-token"];
const clientToken = res.headers.client;
const uid = res.headers.uid;
if(accessToken && clientToken && uid) {
Cookies.set("_access_token", accessToken);
Cookies.set("_client", clientToken);
Cookies.set("_uid", uid);
router.push("/calendar");
} else {
throw new Error("認証されませんでした");
}
} catch (error) {
if (error instanceof Error) {
setError(error.message);
} else {
setError("ログイン中に予期せぬエラーが発生しました");
}
console.error("ログインエラー:", error);
}
}
2. Redisへのセッション情報保存
認証成功時、NextJSアプリケーションはRedisにセッション情報を保存します。
const createSession = async (userData: UserData) => {
try {
const response = await axios.post('/lib/api/session', { userData });
if (!response?.data?.sessionId) {
console.error("セッションIDが返されませんでした", response.data);
throw new Error("セッションIDが返されませんでした");
}
Cookies.set("sessionId", response.data.sessionId);
return response.data;
} catch (error) {
console.log(error);
}
}
3. セッションIDのCookie保存
セッションIDをクライアント側のCookieに保存します。
Cookies.set("sessionId", response.data.sessionId);
4. 認証状態の維持
以降のリクエストでは、CookieのセッションIDを使用して認証状態を確認します。
export const getUser = async () => {
const sessionId = Cookies.get("sessionId");
if (!sessionId) {
console.error("セッションIDが見つかりません");
throw new Error("セッションIDが見つかりません");
}
try {
const response = await axios.get('/lib/api/session', {
params: { sessionId },
validateStatus: (status) => status < 500
});
if (response.status !== 200) {
throw new Error(`ユーザー情報取得エラー: ${response.status}`);
}
return response.data;
} catch (error) {
console.error("サインインエラー:", error);
throw error;
}
}
5. docker-compose.ymlにredisの設定
(commitでは後で機能追加してます)
services:
db:
image: mysql:8.0.32
environment:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: myapp_development
MYSQL_USER: user
MYSQL_PASSWORD: password
ports:
- "3307:3306"
volumes:
- mysql_data:/var/lib/mysql
rails:
build:
context: ./rails
command: bash -c "bundle exec rails s -b 0.0.0.0 -p 3000"
volumes:
- ./rails:/myapp
ports:
- 3000:3000
depends_on:
- db
tty: true
stdin_open: true
environment:
- DATABASE_USERNAME=root
- DATABASE_PASSWORD=password
- DATABASE_HOST=db
- DATABASE_PORT=3306
- RAILS_ENV=development
next:
build:
dockerfile: ./next/Dockerfile
tty: true
stdin_open: true
volumes:
- ./next:/app
ports:
- "8000:3000"
environment:
- REDIS_URL=redis://redis:6379
depends_on:
- redis
**redis:
image: redis:latest
ports:
- "6379:6379"**
volumes:
mysql_data:
networks:
default:
name: google-calendar-clone_default
TanstackQueryを使用したデータフェッチ方法の解説
主に認証関連とイベント処理関連のAPIを作成して、それぞれに対してhooksを作成しました。
-
認証関連: サインイン、サインアップ、サインアウトなど、複数の認証操作を一つのカスタムフック
useAuth
内で管理しています。 - イベント処理関連: イベントの取得、作成、更新、削除など、個々の操作に対して個別のカスタムフックを定義しています。
1. 認証関連API
lib/api/auth.ts
import client from "./client"
import Cookies from "js-cookie";
import axios from 'axios';
interface SignUpParams {
name: string
email: string;
password: string;
password_confirmation: string;
}
interface SignInParams {
email: string;
password: string;
}
interface UserData {
id: string;
name: string;
email: string;
}
const getAuthHeaders = () => {
const accessToken = Cookies.get("_access_token");
const clientToken = Cookies.get("_client");
const uid = Cookies.get("_uid");
if (!accessToken || !clientToken || !uid) {
throw new Error("認証情報が見つかりません");
}
return {
"access-token": accessToken,
"client": clientToken,
"uid": uid
};
};
export const signUp = async (params: SignUpParams) => {
try {
const response = await client.post("/auth", { registration: params });
if (response.data.status === 'success') {
await createSession(response.data.data);
}
return response;
} catch (error) {
console.error("サインアップエラー:", error);
throw error;
}
};
export const signIn = async (params: SignInParams) => {
try {
const response = await client.post("/auth/sign_in", params);
if (response.data.data) {
await createSession(response.data.data);
}
return response;
} catch (error) {
console.error("サインインエラー:", error);
throw error;
}
}
export const signOut = async() => {
try {
await client.delete("/auth/sign_out", {
headers: getAuthHeaders()
});
await deleteSession();
Cookies.remove("_access_token");
Cookies.remove("_client");
Cookies.remove("_uid");
Cookies.remove("sessionId"); // セッションIDも削除する
} catch (error) {
console.error("サインアウトエラー:", error);
throw error;
}
}
export const getUser = async () => {
const sessionId = Cookies.get("sessionId");
if (!sessionId) {
console.error("セッションIDが見つかりません");
throw new Error("セッションIDが見つかりません");
}
try {
const response = await axios.get('/lib/api/session', {
params: { sessionId },
validateStatus: (status) => status < 500
});
if (response.status !== 200) {
throw new Error(`ユーザー情報取得エラー: ${response.status}`);
}
return response.data;
} catch (error) {
console.error("サインインエラー:", error);
throw error;
}
}
export const createSession = async (userData: UserData) => {
try {
const response = await axios.post('/lib/api/session', { userData });
if (!response?.data?.sessionId) {
console.error("セッションIDが返されませんでした", response.data);
throw new Error("セッションIDが返されませんでした");
}
Cookies.set("sessionId", response.data.sessionId);
return response.data;
} catch (error) {
console.log(error);
}
}
export const deleteSession = async () => {
const sessionId = Cookies.get("sessionId");
if (sessionId) {
try {
await axios.delete('/lib/api/session', { params: { sessionId } });
Cookies.remove("sessionId");
} catch (error) {
console.error("セッション削除エラー:", error);
throw error;
}
}
}
hooks/useAuth.ts
import { createSession, getUser, signIn, signOut, signUp } from "@/app/lib/api/auth";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation"
import Cookies from 'js-cookie';
export const useAuth = () => {
const router = useRouter();
const queryClient = useQueryClient();
const {
data: user,
isPending: isCheckingAuth,
error: userError,
} = useQuery({
queryKey: ["user"],
queryFn: getUser,
retry: false,
staleTime: 1000 * 60 * 5
});
if(userError) {
Cookies.remove("sessionId");
}
const loginMutation = useMutation({
mutationFn: signIn,
onSuccess: async (data) => {
const { headers } = data;
Cookies.set("_access_token", headers["access-token"]);
Cookies.set("_client", headers.client);
Cookies.set("_uid", headers.uid);
await createSession(data.data);
queryClient.invalidateQueries({ queryKey: ["user"] });
}
});
const signUpMutation = useMutation({
mutationFn: signUp,
onSuccess: async (data) => {
if(data.data.status === "success") {
await createSession(data.data.data);
queryClient.invalidateQueries({ queryKey: ["user"] });
router.push("/calendar");
}
}
});
const signOutMutation = useMutation({
mutationFn: signOut,
onSuccess: () => {
queryClient.removeQueries({ queryKey: ["user"] });
router.push("/");
}
});
return {
login: loginMutation.mutate,
isLogginIn: loginMutation.isPending,
loginError: loginMutation.error,
signUp: signUpMutation.mutate,
isSignUpPending: signUpMutation.isPending,
isSignUpError: signUpMutation.error,
signOut: signOutMutation.mutate,
isSignOutPending: signOutMutation.isPending,
user: user?.data,
isCheckingAuth,
userError,
};
}
2. イベント処理関係API
lib/api/events.ts
import Cookies from "js-cookie";
import client from "./client";
import type { ApiResponse, EventProps } from "../types/type";
const getAuthHeaders = () => {
const accessToken = Cookies.get("_access_token");
const clientToken = Cookies.get("_client");
const uid = Cookies.get("_uid");
if (!accessToken || !clientToken || !uid) {
throw new Error("認証エラーです");
}
return {
"access-token": accessToken,
"client": clientToken,
"uid": uid
};
};
export const getEvents = ():Promise<ApiResponse<EventProps[]>> => {
return client.get("/events", {
headers: getAuthHeaders()
});
}
export const createEvent = (params: EventProps) => {
return client.post("/events", {event: params}, {
headers: getAuthHeaders()
});
}
export const updateEvent = (eventId: number, params: EventProps) => {
return client.put(`/events/${eventId}`, {event: params}, {
headers: getAuthHeaders()
});
}
export const deleteEvent = (eventId: number):Promise<ApiResponse<void>> => {
return client.delete(`/events/${eventId}`, {
headers: getAuthHeaders()
});
}
export const getEvent = (eventId: number): Promise<ApiResponse<EventProps>> => {
return client.get(`/events/${eventId}`, {
headers: getAuthHeaders()
})
}
hooks/useEvents.ts
import { createEvent, deleteEvent, getEvent, getEvents, updateEvent } from '@/app/lib/api/events';
import type { ApiResponse, EventProps } from '@/app/lib/types/type';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
export function useEvents() {
return useQuery<ApiResponse<EventProps[]>, Error>({
queryKey: ['events'],
queryFn: getEvents
});
}
export function useEvent(eventId: number) {
return useQuery<ApiResponse<EventProps>, Error>({
queryKey: ['event', eventId],
queryFn: () => getEvent(eventId)
});
}
export function useCreateEvent() {
const queryClient = useQueryClient();
return useMutation<ApiResponse<EventProps>, Error, EventProps>({
mutationFn: createEvent,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] });
},
});
}
export function useUpdateEvent() {
const queryClient = useQueryClient();
return useMutation<ApiResponse<EventProps>, Error, { eventId: number; params: EventProps }>({
mutationFn: ({ eventId, params }) => updateEvent(eventId, params),
onSuccess: (data, variables) => {
queryClient.invalidateQueries({ queryKey: ['events'] });
queryClient.invalidateQueries({ queryKey: ['event', variables.eventId] });
},
});
}
export function useDeleteEvent() {
const queryClient = useQueryClient();
return useMutation<ApiResponse<void>, Error, number>({
mutationFn: deleteEvent,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] });
},
});
}
1. カスタムフックの構造と役割
認証関連
構造:
-
単一のフック:
useAuth
フックがサインイン、サインアップ、サインアウト、およびユーザー情報の取得を一括で管理しています。
役割:
- 認証に関連するすべての操作を一つのフック内で管理しています。
イベント処理関連
構造:
-
個別のフック: 各操作(取得、作成、更新、削除)ごとに専用のカスタムフックを定義しています。
-
useEvents
: イベント一覧を取得 -
useEvent
: 特定のイベントを取得 -
useCreateEvent
: イベントを作成 -
useUpdateEvent
: イベントを更新 -
useDeleteEvent
: イベントを削除
-
役割:
- 各フックが特定のAPI操作を担当しています。
キャッチアップするのに参考にした記事
ただ、紹介しておきながら申し訳ないのですが、ほぼ原型をとどめていません^^;
自分はRailsのみのキャッチアップに参考にさせていただきました!
ソースコードのコミットを辿っていきながら同じものを作っていってもらえればいいと思いますが、自分も調べながら失敗しながら作っているのでその辺はご了承ください!
Discussion