📅

【ポートフォリオに生かせる!】Next.js14とRailsAPIで作るカレンダーアプリ📅

2024/10/06に公開

アプリデモ

https://x.com/Nao8epicmotion/status/1841009401051906192

ソースコード

https://github.com/Kroro1208/calendar-rails-nextjs

学習技術

  1. Full Calendar,shadcn-ui, tailwind使用したUIの構築
  2. Redis使用したRails7/Next.js14 認証フローとセッション管理方法
  3. TanstackQueryを使用したデータフェッチ方法
  4. react-hook-formを使用したフォーム管理
  5. Dockerを使用した環境構築
  6. MySQLを使用したDB操作
  7. Redisを使ったセッション管理

Redis使用したRails/NextJS 認証フローの簡単な図解

スクリーンショット 2024-09-25 222809.png

概要

このシステムでは、以下の流れで認証とセッション管理を行います

  1. ユーザーがNextJSアプリケーションでログインを試みる
  2. NextJSがRails APIに認証リクエストを送信
  3. Rails APIが認証を確認し、結果を返す
  4. 認証成功の場合、NextJSがRedisにセッション情報を保存
  5. Next.jsがセッションIDをCookieに保存
  6. 以降のリクエストでは、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を使用している際に提供されているバリデーションメソッドです。
それぞれ解説します。

  1. :database_authenticatable

    • 機能:パスワードの暗号化と認証を処理します。
    • 主な機能:
      • パスワードをハッシュ化してデータベースに保存
      • authenticateメソッドを提供し、ユーザーの認証を行う
    • 追加されるフィールド:
      • encrypted_password
  2. :registerable

    • 機能:ユーザーの新規登録と編集、削除を可能にします。
    • 主な機能:
      • サインアップのためのルーティングとコントローラーアクションを提供
      • アカウント編集・削除機能を提供
    • 追加されるメソッド:
      • register
      • sign_up
  3. :recoverable

    • 機能:パスワードのリセットと回復メカニズムを提供します。
    • 主な機能:
      • パスワードリセット用のトークン生成
      • パスワードリセットの手順を提供
    • 追加されるフィールド:
      • reset_password_token
      • reset_password_sent_at
  4. :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操作を担当しています。

キャッチアップするのに参考にした記事

https://zenn.dev/prune/books/32a2fd62831c7f

ただ、紹介しておきながら申し訳ないのですが、ほぼ原型をとどめていません^^;
自分はRailsのみのキャッチアップに参考にさせていただきました!

ソースコードのコミットを辿っていきながら同じものを作っていってもらえればいいと思いますが、自分も調べながら失敗しながら作っているのでその辺はご了承ください!

Discussion