Roll With IT

tamakiのIT日記

【後半】Rails APIを叩いてユーザー情報をDBに保存する(Next.js × Railsで実践するアプリ開発)

はじめに

Next.jsとRailsを使ってオリジナルのWebアプリを開発しています。

github.com

ログイン機能を備えたアプリにするために、Google認証でログインする方法を選択しました。この記事では、実装過程において調べた情報や試行錯誤しハマった経験などをまとめたいと思います。

前編、後編の2部構成でお届けします。

前編

Next.jsのNextAuth.jsを使用しGoogle認証ログイン、ログアウトに焦点を当てた内容です。 前編はこちら

後編

ログインしたユーザーの情報をデータベースに保存する方法について書きます。Rails APIを叩いてデータベースに保存する方法です。Next.jsとRailsを使ったユーザー登録と削除についてまとめたいと思います。

技術スタック

フロントエンド:
  • Next.js '13.2.4'
  • 使用するnpm
    • next-auth '4.20.1'
    • axios '1.3.4'
バックエンド:
  • Ruby '3.2.1'
  • Rails '7.0.4.2' API mode
  • 使用するgem
    • rack-cors '2.0.0'
データベース: 
リポジトリ

github.com

Rails APIを叩いてユーザー情報をDBに保存する

Railsのプロジェクトを作成する

rails new

$ rails new . --api --database=postgresql --skip-webpack-install --skip-javascript --skip-test

オプションの説明

  • --api: APIモードでRailsアプリケーションを作成します。不要なミドルウェアやジェネレータを削除します。
  • --database=postgresql: デフォルトのデータベースとしてPostgreSQLを指定します。
  • --skip-webpack-install: Webpackerのインストールをスキップします。Next.jsでフロントエンドを構築するためWebPackは不要です。Rails7ではデフォルトで使用しなくなったっぽいですが、念のため付けておきます。
  • --skip-javascript: JavaScriptのサポートを無効にします。
  • --skip-test: RailsのデフォルトのテストフレームワークであるMinitestをスキップし、代わりにRSpecとCypressを使用することを想定しています。(今回はテストは省略しています)

.gitを削除する

$ rm -rf .git

.gitディレクトリを削除します。.gitを削除するなら最初から--skip-gitすることもできますが、初期の.gitignoreが欲しいのでスキップはせずに後から削除します。

因みにファイル構成は以下のようにしているため.gitはプロジェクトのトップで管理しています。

.
├── .git
│   ├── COMMIT_EDITMSG
│   ├── FETCH_HEAD
│   ├── HEAD
│   ├── config
│   ├── description
│   ├── hooks
│   ├── index
│   ├── info
│   ├── logs
│   ├── objects
│   └── refs
└── next-rails-google-auth
    ├── backend(Rails)
    └── frontend(Next.js)

モジュール名を変更する

初期値だとディレクトリ名のBackendになっています。プロジェクト名に名前を変更しておきます。

# backend/config/application.rb

module NextRailsGoogleAuthApi

データベースを作成しモデルを準備する

最低限必要なカラムだけ設定し作成します。細かい制約、バリデーションなどは省略します。

# backend/db/schema.rb

  create_table "users", force: :cascade do |t|
    t.string "provider"
    t.string "uid"
    t.string "name"
    t.string "email"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
  end

ルーティングを設定する

User登録、削除のためのルーティングを設定します。

# backend/config/routes.rb

 post 'auth/:provider/callback', to: 'api/v1/users#create'
 delete 'users/:email', to: 'api/v1/users#destroy', constraints: { email: %r{[^/]+} }

コントローラーを準備する

createが成功したらhead :okと返します。

当初、ここでredirect_to 'http://localhost:4000/'としてリダイレクト処理をしていました。しかしステータスコード302を返しているのが原因でエラーが出ていました。

今回やりたいことは「ユーザー登録が成功したかどうか?」の確認がしたいだけなのでリダイレクトは不要でした。ステータスコード200を返すだけで十分なので、head :okと変更し、無事ログイン処理は成功しました。

# backend/app/controllers/api/v1/users_controller.rb

module Api
  module V1
    class UsersController < ApplicationController

      def create
        # 引数の条件に該当するデータがあればそれを返す。なければ新規作成する
        user = User.find_or_create_by(provider: params[:provider], uid: params[:uid], name: params[:name], email: params[:email])                      
        if user
          head :ok
        else
          render json: { error: "ログインに失敗しました" }, status: :unprocessable_entity
        end
      rescue StandardError => e
        render json: { error: e.message }, status: :internal_server_error
      end

      def destroy
        user = User.find_by(email: params[:email])
        if user
          user.destroy
        else
          render json: { error: "ユーザーが見つかりませんでした" }, status: :not_found
        end
      rescue StandardError => e
        render json: { error: e.message }, status: :internal_server_error
      end
    end
  end
end

200以外はfalseを返す処理にしています。

// frontend/src/pages/api/auth/[...nextauth].ts

        if (response.status === 200) {
          return true
        } else {
          return false
        }

フロントエンドとバックエンドの連携を設定する

rack-cors

Rails APIは、rack-corsのgemを使って簡単にCORS(オリジン間リソース共有)の設定を行うことができます。このgemを利用することで、Next.js と Rails APIの連携が可能になります。

APIモードでRailsアプリを開発しているので、既にGemfileにrack-corsが記載されています。コメントアウトを解除してbundle installします。

# backend/Gemfile

# Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin AJAX possible
gem "rack-cors"

インストール後、cnfig/initializers/cors.rbファイルに設定内容を記述します。

origins "http://localhost:4000"とすることで、リクエストを受け取れるようにします。"http://localhost:4000"からのリクエストは許可しますよ。という意味です。

今回はNext.js側でhttp://localhost:4000を使っているため指定したいと思います。ちなみに、origins を*とすると、どこからでもRails APIを叩けることになるので危険です。

# backend/config/initializers/cors.rb

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins "http://localhost:4000"

    resource "*",
            headers: :any,
            methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end

axios

axiosは、非同期通信でデータを取得するためのモジュール、ライブラリです。Node.jsで書かれています。ブラウザや node.js で動く Promise ベースの HTTP クライアントです。REST APIを実行したいときなど、このnpmを使うと実装が簡単にできます。

今回は、Rails APIを叩くために利用したいと思います。

前編でインストール済みですが、まだの場合はインストールします。

$ npm install axios

Next.js側からRails APIを叩き、ユーザー情報をDBに保存する

コールバックを追加します。signIn()が実行されたときに呼び出す内容を定義します。

ユーザー情報を取得し、Rails APIに向けてPOSTします。apiUrl変数は、環境変数で管理しているので http://localhost:3000 となります。

// frontend/src/pages/api/auth/[...nextauth].ts

import NextAuth from 'next-auth'
import GoogleProvider from 'next-auth/providers/google'
import axios from 'axios'

const apiUrl = process.env.NEXT_PUBLIC_API_URL

export default NextAuth({
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET,
    }),
  ],
  callbacks: {
    async signIn({ user, account, profile }) {
      const provider = account.provider
      const uid = account.sub
      const name = user.name
      const email = user.email

      try {
        const response = await axios.post(`${apiUrl}/auth/${provider}/callback`, {
          provider,
          uid,
          name,
          email,
        })

        if (response.status === 200) {
          return true
        } else {
          return false
        }
      } catch (error) {
        console.log('エラー', error)
        return false
      }
    },
  },
})

signIn()の引数に渡している変数の中身を確認してみます。

userのid: '109704921881887261253',と accoutのsub: '109704921881887261253'は同じです。どっちがいいのかは分かりませんが、今回はaccount.subで値を取得することにしました。

 // user
{
  id: '109704921881887261253',
  name: 'ネクスト太郎',
  email: 'next.taro.1234@gmail.com',
  image: 'https://lh3.googleusercontent.com/a/AGNmyxaK1E15pp3Qp1MrPG0lH6MBjZK3aVeEBnh3hZ9M=s96-c'
}


// account
{
  provider: 'google',
  type: 'oauth',
  providerAccountId: '109704921881887261253',
  access_token: 'ya29.a0Ael9sCOqoygeGWIZD4vl3xmYzd09PE29hogehogehogehoge',
  expires_at: 1680098462,
  scope: 'openid https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile',
  token_type: 'Bearer',
  id_token: 'eyJhbGciOiJSUzI1NiIsImtpZCI6IjFhYWU4ZDdjOTIwNThiNWVlYTQ1Njg5NWJmODkwODQ1NzFlMzA2ZjMiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI0Mzg1MzU4NTU3MTctZWVkdm00bmYxODU5ZDFnYjJ1czhpdDNydTRhaHRhMWkuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiI0Mzg1MzU4NTU3MTctZWVkdm00bmYxODU5ZDFnYjJ1czhpdDNydTRhaHRhMWkuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMDk3MDQ5MjE4ODE4ODcyNjEyNTMiLCJlbWFpbCI6Im5leHQudGFyby4xMjM0QGdtYWlsLmNvbSIsImVtYWlsX3ZlcmlmaWVkIjp0cnVlLCJhdF9oYXNoIjoidTh1bF95UkdvWEtUTk81RlRVSndvdyIsIm5hbWUiOiLjg43jgq_jgrnjghogehogehogehoge'
}

profile
{
  iss: 'https://accounts.google.com',
  azp: '438535855717-eedvm4nf1859d1gb2us8it3ru4ahta1i.apps.googleusercontent.com',
  aud: '438535855717-eedvm4nf1859d1gb2us8it3ru4ahta1i.apps.googleusercontent.com',
  sub: '109704921881887261253',
  email: 'next.taro.1234@gmail.com',
  email_verified: true,
  at_hash: 'u8ul_yRGoXKTNO5FTUJwow',
  name: 'ネクスト太郎',
  picture: 'https://lh3.googleusercontent.com/a/AGNmyxaK1E15pp3Qp1MrPG0lH6MBjZK3aVeEBnh3hZ9M=s96-c',
  given_name: '太郎',
  family_name: 'ネクスト',
  locale: 'ja',
  iat: 1680094863,
  exp: 1680098463
}

ユーザー情報を削除するコンポーネントも用意しておく

ログイン機能とログアウト機能のコンポーネントはすでに前編で作成済みですが、削除するためのコンポーネントがないので用意します。

// frontend/src/components/DeleteUser.jsx

import React from 'react'
import { useSession, signOut } from 'next-auth/react'
import axios from 'axios'

const DeleteUser = () => {
  const { data: session } = useSession()

  const apiUrl = process.env.NEXT_PUBLIC_API_URL

  const handleDeleteUser = async () => {
    if (!session || !session.user) {
      console.error('セッションが存在しません')
      return
    }

    try {
      const response = await axios.delete(`${apiUrl}/users/${session.user.email}`)

      if (response.status === 204) {
        signOut()
      } else {
        console.error('アカウント削除に失敗しました')
      }
    } catch (error) {
      console.log('エラーだよ全員集合!', error)
    }
  }

  if (session) {
    return (
      <div>
        <button onClick={() => handleDeleteUser()}>アカウントを削除する</button>
      </div>
    )
  }
  return null
}

export default DeleteUser

indexページに、作成したコンポーネントを配置する

  • Login.jsx
  • Logout.jsx
  • DeleteUser.jsx
// frontend/src/pages/index.jsx

import React from 'react'
import Head from 'next/head'
import { useSession } from 'next-auth/react'
import Login from '@/components/Login'
import Logout from '@/components/logout'
import DeleteUser from '@/components/deleteUser'

export default function Home() {
  const { data: session, status } = useSession()

  return (
    <>
      <Head>
        <title>next-rails-google-auth</title>
      </Head>
      <div>
        <h1>next-rails-google-auth</h1>

        {status === 'authenticated' ? (
          <div>
            <p>セッションの期限:{session.expires}</p>
            <p>ようこそ、{session.user.name}さん</p>
            <img src={session.user.image} alt='' style={{ borderRadius: '50px' }} />
            <div>
              <Logout />
            </div>
            <div>
            <DeleteUser />
            </div>
          </div>
        ) : (
          <Login />
        )}
      </div>
    </>
  )
}

実際に動かしてみる

アプリを起動させます。

ログイン画面

Googleログイン認証画面

rails consoleコマンドでデータベースに保存されているか確認してみます。

irb(main):001:0> User.all
  User Load (0.5ms)  SELECT "users".* FROM "users"
=>
[#<User:0x000000010d540688
  id: 5,
  provider: "google",
  uid: "109704921881887261253",
  name: "ネクスト太郎",
  email: "next.taro.1234@gmail.com",
  created_at: Mon, 27 Mar 2023 13:52:50.830761000 UTC +00:00,
  updated_at: Mon, 27 Mar 2023 13:52:50.830761000 UTC +00:00>]

ユーザー情報の登録ができていることが確認できました!

ログイン後の画面

  • ログアウトボタン
    • ログイン画面に戻る。ユーザー情報は削除されない。
  • アカウントを削除するボタン
    • ログイン画面に戻る。ユーザー情報が削除される。

「アカウントを削除する」を実行した後、同じくrails consoleコマンドで確認してみます。

irb(main):002:0> User.all
  User Load (0.4ms)  SELECT "users".* FROM "users"
=> []

ユーザー情報が削除されたことも確認できました!

おわりに

当初はomniauth-google-oauth2を使ってGoogle認証を行う予定でしたが、Next.jsを使う場合、NextAuth.jsの選択肢があることを知り、途中から切り替えて実装しました。

バックエンドでのユーザー管理や認証のロジックを一元化したい場合はomniauth-google-oauth2がおすすめのようですが、特にその予定はなかったのと、Railsではdevise を使ったユーザー認証は経験していたので、今回はフロントエンドでの認証フローを経験してみたいと思いNextAuth.jsを選択しました。

Next.js × Railsアプリ開発をしNextAuth.jsの利用を検討している方の参考になれば幸いです。

最後までお読みいただきありがとうございました!