プログラミングのゴミ箱

日々の学習の中で知らなかったことについて、調べたことを解説します。

Rails5 + React(TypeScript)でログイン機能を実装する

今回はアプリ開発の上で頻繁に使うであろうログイン機能を作ってみました。意外と苦戦することも多かったのでその備忘録です。
主に参考にしたサイトは下のやつです。
qiita.com

作ったアプリはGithubに公開しています。
https://github.com/trmti/FileSharingApp3/tree/dev

ではやっていきます。

,,環境構築

まずは、front側とapi側の環境を作っていきます。

プロジェクトの作成

以下のコマンドを実行してrailsプロジェクトを作成します。今回はDockerを使って環境を作っていますが、Dockerがよくわからない人はruby2.5.xとrails5.2.x、React17.xを使って環境を作ってもらえれば同じ動作をすると思います。
まずはコマンドプロンプトで以下のコマンドを実行します。

mkdir rails-react-auth && cd rails-react-auth
touch docker-compose.yml
mkdir api && cd api
touch Dockerfile Gemfile Gemfile.lock entrypoint.sh
cd ..
mkdir front && cd front
touch Dockerfile

実行すると以下のようなファイル構成ができます。

rails-react-auth
|__ docker-compose.yml
|__ api
      |__ Dockerfile
      |__ Gemfile
      |__ Gemfile.lock
      |__ entrypoint.sh
|_ front
     |_ Dockerfile

それぞれのファイルに必要な情報を記述していきます。

# Dockerfile(api)
FROM ruby:2.5
RUN apt-get update -qq && apt-get install -y nodejs postgresql-client
RUN mkdir /rails-react-app
WORKDIR /rails-react-app

COPY Gemfile /rails-react-app/Gemfile
COPY Gemfile.lock /rails-react-app/Gemfile.lock
RUN bundle install
COPY . /rails-react-app

# Add a script to be executed every time the container starts.
COPY entrypoint.sh /usr/bin/
RUN chmod +x /usr/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]
EXPOSE 3000

# Start the main process.
CMD ["rails", "server", "-b", "0.0.0.0"]
# Gemfile
source 'https://rubygems.org'
gem 'rails', '~> 5.2.6', '>= 5.2.6.2'
# Gemfile.lock
//何も書かなくて大丈夫です
# entrypoint.sh
#!/bin/bash
set -e

# Remove a potentially pre-existing server.pid for Rails.
rm -f /rails-react-app/tmp/pids/server.pid

# Then exec the container's main process (what's set as CMD in the Dockerfile).
exec "$@"
# Dockerfile(front)
FROM node:17-alpine3.14
WORKDIR /usr/src/app/rails-react-app

COPY ./rails-react-app/package*.json /usr/src/app/rails-react-app/
# docker-compose.yml
version: '3'

services:
  db:
    image: postgres
    volumes:
      - postgres-data:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD: password
  api:
    build:
      context: ./api/
      dockerfile: Dockerfile
    command: /bin/sh -c "rm -f /rails-react-app/tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
    image: ikko3dayo/api
    volumes:
      - ./api:/rails-react-app
      - ./api/vendor/bundle:/rails-react-app/vendor/bundle
    environment:
      TZ: Asia/Tokyo
      RAILS_ENV: development
    ports:
      - 3000:3000
    depends_on:
      - db
  front:
    build:
      context: ./front/
      dockerfile: Dockerfile
    volumes:
      - ./front/rails-react-app:/usr/src/app/rails-react-app
    command: sh -c "npm start"
    ports:
      - '8000:3000'
      - '6006:6006'
volumes:
  postgres-data:
    driver: local


これらのファイルを作成したら実際に環境を作っていきます。
プロジェクトルートに行き、以下のコマンドを実行します。

docker-compose run api rails new . --force --no-deps --database=postgresql --api
docker-compose run --rm front sh -c "npm install -g create-react-app && create-react-app rails-react-app --template typescript"
docker-compose build

これで環境の構築が完了しました。

railsとdatabaseを繋げる

api/config/database.ymlを以下のように編集します。

# database.yml

default: &default
  adapter: postgresql
  encoding: unicode
  //ここから編集
  host: db
  username: postgres
  password: password
  # For details on connection pooling, see Rails configuration guide
  # http://guides.rubyonrails.org/configuring.html#database-pooling
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>

では、実際にサーバーを立ち上げて見ましょう。

docker-compose up

http://localhost:3000http://localhost:8000にアクセスしてみて、それぞれreactとrailsの初期画面が出てくれば成功です。

apiサーバーの設定

CORSの設定

許可したサーバーからアクセスできるようにCORSを設定します。
Gemfileから

gem "rack-cors"

という部分を探し、コメントを外します。
次にapi/config/initializers/cors.rbというファイルを以下のように編集します。

ails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins 'http://localhost:8000'

    resource '*',
      headers: :any,
      expose: ["access-token", "expiry", "token-type", "uid", "client"],
      methods: [:get, :post, :put, :patch, :delete, :options, :head],
      credentials: true
  end
end

これでlocalhost:8000 (reactのサーバー)からのアクセスが許可されました。

deviceのインストール

Gemfileに

gem "device"
gem "device_token_auth"

というgemを追加します。
dockerを再起動します。

docker-compose up

Userモデルの作成

dockerのapiコンテナにアクセスします。

docker-compose exec api sh

アクセスしたコンテナの中で以下のコマンドを実行します。

rails g device:install
rails g devise_token_auth:install User auth
rails db:migrate

次にapi/app/config/initializers/device_token_auth.rbの45行目辺りのコメントを外します。

  config.headers_names = {:'access-token' => 'access-token',
                         :'client' => 'client',
                         :'expiry' => 'expiry',
                         :'uid' => 'uid',
                         :'token-type' => 'token-type' }

各種コントローラーの作成

apiコンテナ内で以下のコマンドを実行してコントローラーを作成します。

rails g controller api/v1/auth/registration
rails g controller api/v1/auth/sessions

それぞれのコントローラーを以下のように編集します。

# api/app/controllers/api/v1/auth/registration_controller.rb

# アカウント作成用コントローラー
class Api::V1::Auth::RegistrationsController < DeviseTokenAuth::RegistrationsController
  private

    def sign_up_params
      params.permit(:email, :password, :password_confirmation, :name)
    end
end
# api/app/controllers/api/v1/auth/session_controller.rb

# ログイン状態確認用コントローラー
class Api::V1::Auth::SessionsController < ApplicationController
  def index
    if current_api_v1_user
      render json: { is_login: true, data: current_api_v1_user }
    else
      render json: { is_login: false, message: "ユーザーが存在しません" }
    end
  end
end
# api/app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  include DeviseTokenAuth::Concerns::SetUserByToken

  skip_before_action :verify_authenticity_token
  helper_method :current_user, :user_signed_in?
end

ルーティングを設定

# api/app/config/routes.rb

Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :test, only: %i[index]

      mount_devise_token_auth_for 'User', at: 'auth', controllers: {
        registrations: 'api/v1/auth/registrations'
      }

      namespace :auth do
        resources :sessions, only: %i[index]
      end
    end
  end
end

動作確認

Postmanを使って動作確認をします。
https://orange-satellite-856034-1.postman.co/


f:id:chanichiwasshoi:20220316153006p:plain
こんな感じになっていれば成功です。ログインもやってみます。

f:id:chanichiwasshoi:20220316153159p:plain

いい感じですね。

front側

frontでは簡単化のためにantdというライブラリを使います。細かい部分は違うかも知れませんが大まかな画面構成は以下のようになります。

f:id:chanichiwasshoi:20220316153445p:plain
f:id:chanichiwasshoi:20220316153449p:plain

プロジェクトの初期設定

App.tsxを以下のように編集します。

# App.tsx

import { VFC } from "react"

export const App: VFC = () => {
    return <></>
}

index.tsxも以下のように編集します。

import React from 'react';
import ReactDOM from 'react-dom';
import { App } from './App';
import 'antd/dist/antd.less';
import reportWebVitals from './reportWebVitals';

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

使わないファイルなどは消して大丈夫です。

次にtsconfig.jsonに以下のように記述します。

# tsconfig.json

{
  "compilerOptions": {
   ... (省略)
    "baseUrl": "src"
  },
  "include": ["src"]
}

これによって、srcフォルダがこのプロジェクトのルートディレクトリになります。

次に、srcフォルダの中に必要なフォルダとファイルを作っていきます。以下のようなファイル構成を作ってください。

src
|__ type.ts
|__ auth
|__ db
|__ pages
|__ components
      |__ atoms
      |__ molecules
      |__ organisms
      |__ templates

type.tsはプロジェクトの中に出てくる型を定義するために作ります。

apiの呼び出し

type.tsファイルを以下のように編集します。

export interface SignUpParams {
  name: string;
  email: string;
  password: string;
  passwordConfirmation: string;
}

// サインイン
export interface SignInParams {
  email: string;
  password: string;
}

export interface User {
  id: number;
  uid: string;
  provider: string;
  email: string;
  name: string;
  nickname?: string;
  image?: string;
  allowPasswordChange: boolean;
  created_at?: Date;
  updated_at?: Date;
}

export type UserData = {
  data: User;
};

export type FetchUserSuccess = {
  status: 'success';
  data: User;
};

export type FetchFailed = {
  status: 'error';
  message: string;
};

export type FetchUser = FetchUserSuccess | FetchFailed;

ユーザーデータが持つ型をUserと定義し、リクエストが作成した場合の型をFetchUserSuccess。リクエストが失敗した場合の方をFetchFailedと定義します。また、SignUpParamsにはユーザーを作成するときの引数の型を定義し、SignInParamsにはログインするときの型を定義します。

TypeScriptでhttpリクエストを使うためにaxiosというライブラリをインストールし、Cookieを扱うためにjs-cookieというライブラリをインストールします。

docker-compose exec front sh -c "npm i axios js-cookie"

また、APIのホストを環境変数として保持するためにrails-react-appフォルダの中に「.env」ファイルを作成し、以下のように記述します。

# .env

# development
REACT_APP_API_HOST="http://localhost:3000"

Reactでは.envファイルに「REACT_APP_」から続く変数を定義することで環境変数を作ることができるようになります。
# developmentというのはdevelopment環境での環境変数という意味です。他にもtest環境やproduction環境での変数も定義できます。

次に、db フォルダの中にuser.tsファイルを作成し、以下のように記述します。

# user.ts

import axios, { AxiosResponse } from 'axios';
import {
  SignUpParams,
  SignInParams,
  UserData,
  FetchUserSuccess,
  FetchFailed,
} from 'type';
import Cookies from 'js-cookie';

const api_url = `${process.env.REACT_APP_API_HOST}/api/v1`;

export const loginUser = (params: SignInParams) => {
  const res = axios
    .post(`${api_url}/auth/sign_in`, params, { withCredentials: true })
    .then((prop: AxiosResponse<UserData>): FetchUserSuccess => {
      const data = prop.data.data;
      const headers = prop.headers;
      Cookies.set('_access_token', headers['access-token']);
      Cookies.set('_client', headers.client);
      Cookies.set('_uid', headers.uid);
      return { status: 'success', data };
    })
    .catch((): FetchFailed => {
      return {
        status: 'error',
        message: 'ログインに失敗しました。',
      };
    });
  return res;
};

export const signUpUser = (params: SignUpParams) => {
  const res = axios
    .post(`${api_url}/auth`, params, { withCredentials: true })
    .then((prop: AxiosResponse<UserData>): FetchUserSuccess => {
      const data = prop.data.data;
      const headers = prop.headers;
      Cookies.set('_access_token', headers['access-token']);
      Cookies.set('_client', headers.client);
      Cookies.set('_uid', headers.uid);

      return { status: 'success', data };
    })
    .catch((): FetchFailed => {
      return {
        status: 'error',
        message: 'アカウント登録に失敗しました',
      };
    });
  return res;
};

export const logOutUser = () => {
  const res = axios
    .delete(`${api_url}/auth/sign_out`, {
      headers: {
        'access-token': Cookies.get('_access_token') || '',
        client: Cookies.get('_client') || '',
        uid: Cookies.get('_uid') || '',
      },
    })
    .then((prop: AxiosResponse<UserData>): FetchUserSuccess => {
      Cookies.remove('_access_token');
      Cookies.remove('_client');
      Cookies.remove('_uid');
      const data = prop.data.data;
      return { status: 'success', data };
    })
    .catch((): FetchFailed => {
      return {
        status: 'error',
        message: 'ログアウトに失敗しました',
      };
    });
  return res;
};

export const getCurrentUser = () => {
  if (
    !Cookies.get('_access_token') ||
    !Cookies.get('_client') ||
    !Cookies.get('_uid')
  ) {
    const res: FetchFailed = {
      status: 'error',
      message: 'ユーザーの取得に失敗しました',
    };
    return res;
  }
  const res = axios
    .get(`${api_url}/auth/sessions`, {
      headers: {
        'access-token': Cookies.get('_access_token') || '',
        client: Cookies.get('_client') || '',
        uid: Cookies.get('_uid') || '',
      },
    })
    .then((prop: AxiosResponse) => {
      if (prop.data.is_login) {
        const res: FetchUserSuccess = {
          status: 'success',
          data: prop.data.data,
        };
        return res;
      } else {
        const res: FetchFailed = {
          status: 'error',
          message: 'ユーザーが存在しません',
        };
        return res;
      }
    })
    .catch((): FetchFailed => {
      const res: FetchFailed = {
        status: 'error',
        message: 'ユーザーの取得に失敗しました',
      };
      return res;
    });
  return res;
};

axios.getと記述することで指定したサーバーにgetリクエストを送ることができます。帰ってきたデータはprop変数に入ります。
ログインに必要なaccess_tokenなどの情報はheaderに入っているため、ログイン時やユーザー登録時に取り出し、Cookieに設定することでリロード時にもログイン状態を保持することができます。

Contextの作成

Contextとはデータを様々なところから扱えるようにするグローバル変数のようなものを定義するReactのHookです。createContextでContextを作成し、useContextで使用します。
ログイン状態などの情報は様々の場所で使用するため、Contextを使用します。

authフォルダの中にAuthUserContext.tsxファイルを作成します。そこに以下のように記述します。

import React, { createContext, useContext, useState } from 'react';
import { loginUser, logOutUser, signUpUser, getCurrentUser } from 'db/user';
import { User, SignInParams, SignUpParams } from 'type';

type Props = {
  login: (props: SignInParams) => Promise<void>;
  logout: () => Promise<void>;
  signup: (props: SignUpParams) => Promise<void>;
  updateUser: () => Promise<void>;
  authUser: User | null;
  loading: boolean;
};

const AuthUserContext = createContext<Props>({
  login: async (_) => console.error('Providerが設定されていません'),
  logout: async () => console.error('Providerが設定されていません'),
  signup: async () => console.error('Providerが設定されていません'),
  updateUser: async () => console.error('Providerが設定されていません'),
  authUser: null,
  loading: false,
});

const AuthUserProvider: React.FC = ({ children }) => {
  const [authUser, setAuthUser] = useState<User | null>(null);
  const [loading, setLoading] = useState<boolean>(false);

  const login = async (props: SignInParams) => {
    setLoading(true);
    const res = await loginUser(props);
    if (res.status === 'success') {
      const data = res.data;
      setAuthUser(data);
    } else {
      console.error(res.message);
      throw new Error();
    }
    setLoading(false);
  };

  const logout = async () => {
    setLoading(true);
    const res = await logOutUser();
    if (res.status === 'success') {
      setAuthUser(null);
    } else {
      console.error(res.message);
      throw new Error();
    }
    setLoading(false);
  };

  const signup = async (data: SignUpParams) => {
    setLoading(true);
    const res = await signUpUser(data);
    if (res.status === 'success') {
      setAuthUser(res.data);
    } else {
      console.error(res.message);
      throw new Error();
    }
    setLoading(false);
  };

  const updateUser = async () => {
    setLoading(true);
    const res = await getCurrentUser();
    if (res.status === 'success') {
      setAuthUser(res.data);
    } else {
      console.error(res.message);
      setAuthUser(null);
      throw new Error();
    }
    setLoading(false);
  };

  return (
    <AuthUserContext.Provider
      value={{ login, logout, signup, updateUser, loading, authUser }}
    >
      {children}
    </AuthUserContext.Provider>
  );
};

export const useLogin = () => useContext(AuthUserContext).login;
export const useLogout = () => useContext(AuthUserContext).logout;
export const useSignup = () => useContext(AuthUserContext).signup;
export const useUpdateUser = () => useContext(AuthUserContext).updateUser;
export const useLoading = () => useContext(AuthUserContext).loading;
export const useAuthUser = () => useContext(AuthUserContext).authUser;

export default AuthUserProvider;
    <AuthUserContext.Provider
      value={{ login, logout, signup, updateUser, loading, authUser }}
    >

のようにContext.Providerを設定し、valueの中に使いたいデータや関数を定義することでその子コンポーネントはどこからでもそのデータを使えるようになります。
ここでは、login,logout,signup,updateUser,loading,authUserという6つのデータを渡しています。
また、createContextの引数にはContextの初期値を入れています。

それでは、設定したContextを使えるようにApp.tsxを編集します。

# App.tsx

import { VFC } from "react"
import AuthUserProvider from "auth/AuthUserProvider"

export const App: VFC = () => {
    return (
        <AuthUserProvider>
              <></>
        </AuthUserProvider>
}

このようにすることでAuthUserProviderの子コンポーネントはuseContextで先程valueに設定したデータにアクセスできるようになります。

コンポーネントの作成

コンポーネントGithubに上げているため、気になる人はここから見てください。
GitHub - trmti/FileSharingApp3 at dev

ルーティング

作成したコンポーネントを表示するためのルーティングを作成するため、react-router-domをインストールします。

docker-compose exec front sh -c "npm i react-router-dom"

その後、App.tsxを以下のように編集します。

# App.tsx

import { VFC, FC, useEffect } from 'react';
import {
  BrowserRouter,
  Routes,
  Navigate,
  Outlet,
  Route,
  RouteProps,
} from 'react-router-dom';
import AuthUserProvider, {
  useLoading,
  useAuthUser,
  useUpdateUser,
} from 'auth/AuthUserContext';
import Home from 'pages/Home';
import Signup from 'pages/Signup';
import Login from 'pages/Login';
import Logout from 'pages/Logout';


const PrivateRoute: FC<RouteProps> = () => {
  const updateUser = useUpdateUser();
  const loading = useLoading();
  const user = useAuthUser();
  useEffect(() => {
    (async () => {
      await updateUser();
    })();
  }, []);
  if (!loading) {
    return user !== null ? <Outlet /> : <Navigate to="/login" />;
  } else {
    return <></>;
  }
};

export const App: VFC = () => {
  return (
    <>
      <AuthUserProvider>
        <BrowserRouter>
            <Routes>
              <Route path="/" element={<Home />} />
              <Route path="/signup" element={<Signup />} />
              <Route path="/login" element={<Login />} />
              <Route path="/logout" element={<Logout />} />

              <Route path="/user" element={<PrivateRoute />}>
                <Route path="/user" element={<Home />} />
              </Route>
            </Routes>
        </BrowserRouter>
      </AuthUserProvider>
    </>
  );
};

このようにすることで「http://localhost:8000」にアクセスするとHomeコンポーネントを、「http://localhost:8000/signup」にアクセスするとSignupコンポーネントを表示するようになります。
今回は/userから始まるルートへアクセスするときに認証が必要なようにしています。
以上でログイン機能の実装が完了しました!お疲れ様です(T_T)