Rails API + Vue.js + TypeScriptで作るシンプルなTodoアプリ（SPA）

## 概要

業務でVue.jsを使う事になったため、とりあえずTodoアプリを作りながら入門してみました。

全体的な構成としては、バックエンドにRails API、フロントエンドにVue.jsを採用したSPA（Singe Page Application）になっています。

## 完成イメージ

![rails-vue-typescrit-todo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/68260962-7672-8db0-1c5b-ddc49085ffdc.gif)

## 全体構成

```
rails-vue-todo-app
├── backend
│   ├── app
│   ├── bin
│   ├── config
│   ├── db
│   ├── lib
│   ├── log
│   ├── public
│   ├── storage
│   ├── test
│   ├── tmp
│   ├── vendor
│   ├── config.ru
│   ├── docker-compose.yml
│   ├── Dockerfile
│   ├── entrypoint.sh
│   ├── Gemfile
│   ├── Gemfile.lock
│   ├── Rakefile
│   └── README.md
└── frontend
    ├── public
    ├── src
    ├── babel.config.js
    ├── package.json
    ├── README.md
    ├── tsconfig.json
    └── yarn.lock
```

- バックエンド
    - Ruby 3
    - Rails 6（APIモード）
- フロントエンド
    - Vue.js 3
    - TypeScript
- データベース
    - MySQL 8

今回は再現性を考慮してバックエンド + データベースの部分のみDockerで環境構築していきます。

したがって、先にDockerをインストールしておいてください。

参照記事: [DockerをMacにインストールする](https://qiita.com/kurkuru/items/127fa99ef5b2f0288b81)

また、フロントエンドの雛型はVue CLIを使用して作成するのでこちらも事前に準備をお願いします。

参照記事: [インストール | Vue CLI](https://v3.ja.vuejs.org/guide/installation.html#cli)

## 実装

準備ができたら実際に手を動かしていきます。

### プロジェクト本体

適当な名前で良いので、まずはプロジェクト本体を作成してください。

```
$ mkdir rails-vue-todo-spa && cd rails-vue-todo-spa
```

### バックエンド

今回は先にバックエンド部分から作成していきます。

#### 各種ディレクトリ・ファイルを作成

```
$ mkdir backend && cd backend
$ touch Dockerfile
$ touch docker-compose.yml
$ touch entrypoint.sh
$ touch Gemfile 
$ touch Gemfile.lock
```

```dockerfile:./backend/Dockerfile
FROM ruby:3.0

RUN apt-get update -qq && apt-get install -y build-essential libpq-dev nodejs

ENV APP_PATH /myapp

RUN mkdir $APP_PATH
WORKDIR $APP_PATH

COPY Gemfile $APP_PATH/Gemfile
COPY Gemfile.lock $APP_PATH/Gemfile.lock
RUN bundle install

COPY . $APP_PATH

COPY entrypoint.sh /usr/bin/
RUN chmod +x /usr/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]
EXPOSE 3000

CMD ["rails", "server", "-b", "0.0.0.0"]
```

```yml:./backend/docker-compose.yml
version: "3"
services:
  db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: password
    command: --default-authentication-plugin=mysql_native_password
    volumes:
      - mysql-data:/var/lib/mysql
      - /tmp/dockerdir:/etc/mysql/conf.d/
    ports:
      - 3306:3306
  api:
    build:
      context: .
      dockerfile: Dockerfile
    command: bash -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
    volumes:
      - .:/myapp
      - ./vendor/bundle:/myapp/vendor/bundle
    environment:
      TZ: Asia/Tokyo
      RAILS_ENV: development
    ports:
      - "3000:3000"
    depends_on:
      - db
volumes:
  mysql-data:
```

```sh:./backend/entrypoint.sh
#!/bin/bash
set -e

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

# Then exec the container's main process (what's set as CMD in the Dockerfile).
exec "$@"
```

```ruby:./backend/Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }

gem "rails", "~> 6"
```


```ruby:./backend/Gemfile.lock
# 空欄でOK
```

#### rails new

前述の通り、APIモードで作成します。

```
$ docker-compose run api rails new . --force --no-deps -d mysql --api
```

#### database.ymlを編集

デフォルトの状態だとデータベースとの接続ができないので「database.yml」の一部を書き換えます。

```yml:./backend/config/database.yml
default: &default
  adapter: mysql2
  encoding: utf8mb4
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: root
  password: password # デフォルトだと空欄になっているはずなので変更
  host: db # デフォルトだとlocalhostになっているはずなので変更

development:
  <<: *default
  database: myapp_development

test:
  <<: *default
  database: myapp_test

production:
  <<: *default
  database: <%= ENV["DATABASE_NAME"] %>
  username: <%= ENV["DATABASE_USERNAME"] %>
  password: <%= ENV["DATABASE_PASSWORD"] %>
```

#### コンテナを起動 & データベースを作成

```
$ docker-compose build
$ docker-compose up -d
$ docker-compose run api bundle exec rails db:create
```

#### localhost:3000 にアクセス

![68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f3638383835342f33613639663439332d383939372d623563362d636261342d3938343465313936306137312e706e67.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/eaad88fb-0d03-4f82-54e3-86129fc4a2a7.png)

[localhost:3000](http://localhost:3000/) にアクセスして初期状態の画面が表示されればOKです。

#### モデルを作成

```
$ docker-compose run api rails g model Todo title:string
$ docker-compose run api rails db:migrate
```

```rb:./backend/app/models/todo.rb
class Todo < ApplicationRecord
  validates :title, presence: true, length: { maximum: 140 }   
end
```

- title必須
- 最大140文字

忘れずにバリデーションも設定。

#### コントローラーを作成

```
$ docker-compose run api rails g controller api/v1/todos
```

```ruby:./backend/app/controllers/api/v1/todos_controller.rb
class Api::V1::TodosController < ApplicationController
  def index
    render json: { status: 200, todos: Todo.all, message: "success" }
  end

  def create
    todo = Todo.new(todo_params)

    if todo.save
      render json: { status: 200, todo: todo, message: "success" }
    else
      render json: { status: 500, todo: nil, message: todo.errors }
    end
  end

  def destroy
    todo = Todo.find(params[:id])

    if todo.destroy
      render json: { status: 200, todo: todo, message: "success" }
    else
      render json: { status: 500, todo: nil, message: todo.errors }
    end
  end

  private

    def todo_params
      params.require(:todo).permit(:title)
    end
end
```


#### ルーティングを記述

```ruby:./backend/config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
     resources :todos, only: %i[index create destroy]
    end 
  end 
end
```

#### 初期データを作成

```ruby:./backend/db/seeds.rb
puts 'Creating todos...'

# 適当なTodoを5つ作成
5.times do |i|
  Todo.create(title: "Todo#{i + 1}")
end

puts '...Done!'
```

動作確認用の初期データを作成。

```
$ docker-compose run api rails db:seed
```

#### curlコマンドで確認

```
$ curl -X GET http://localhost:3001/api/v1/todos
```
```json
{
    "status": 200,
    "message": "success",
    "todos": [
        {
            "id": 1,
            "title": "Todo1",
            "created_at": "2021-07-11T15:13:45.123Z",
            "updated_at": "2021-07-11T15:13:45.123Z"
        },
        {
            "id": 2,
            "title": "Todo2",
            "created_at": "2021-07-11T15:13:45.131Z",
            "updated_at": "2021-07-11T15:13:45.131Z"
        },
        {
            "id": 3,
            "title": "Todo3",
            "created_at": "2021-07-11T15:13:45.138Z",
            "updated_at": "2021-07-11T15:13:45.138Z"
        },
        {
            "id": 4,
            "title": "Todo4",
            "created_at": "2021-07-11T15:13:45.145Z",
            "updated_at": "2021-07-11T15:13:45.145Z"
        },
        {
            "id": 5,
            "title": "Todo5",
            "created_at": "2021-07-11T15:13:45.151Z",
            "updated_at": "2021-07-11T15:13:45.151Z"
        }
    ]
}
```

しっかりデータが挿入されているのが確認できれば成功です。

#### CORS設定

今回の構成ではバックエンドとフロントエンドを完全に分けているため、RailsとVue.jsがそれぞれ別のドメインで立ち上がっています。（localhost:3000とlocalhost:8080）

この場合、デフォルトの状態だとセキュリティの問題でVue.js側からRails側のAPIを使用できない点に注意が必要です。

使用できるようにするためには、「CORS（クロス・オリジン・リソース・シェアリング）」の設定を行わなければなりません。

参照記事: [オリジン間リソース共有 (CORS)](https://developer.mozilla.org/ja/docs/Web/HTTP/CORS)

##### rack-corsをインストール

RailsにはCORSの設定を簡単に行えるgemがあるのでそちらをインストールしましょう。

```ruby:./backend/Gemfile
gem 'rack-cors'
```

APIモードで作成している場合、すでにGemfile内に記載されているのでコメントアウトを外すだけでOKです。

```
$ docker-compose build
```

Gemfileを更新したので再度ビルド。

##### cors.rbを編集

「config/initializers/」に設定ファイルが存在するはずなので、「localhost:8080」からアクセス可能なように編集しておきます。

```ruby:./backend/config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins 'localhost:8080' # Vue.js側で使用するドメイン

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

```
$ docker-compose restart
```

設定の変更を反映させるためにコンテナを再起動。

これでバックエンドの準備は完了です。

### フロントエンド

次にフロントエンドの部分を作成していきます。

```
$ mkdir frontend && cd frontend
```

#### vue create

前述の通り、Vue CLIで雛型を作っていくわけですが、その際にいくつか設定をしなければならないので以下のように行ってください。

```
$ vue create .

# 現在のディレクトリにプロジェクトを作成するかどうか
? Generate project in current directory? Yes

# 手動で細かい設定を行うかどうか
? Please pick a preset: Manually select features

# 必要な機能を選択 （ここでTypeScriptなどを選択）
? Check the features needed for your project: Choose Vue version, Babel, TS, Lin
ter

# バージョンを選択 （今回は3系）
? Choose a version of Vue.js that you want to start the project with 3.x

? Use class-style component syntax? No

? Use Babel alongside TypeScript (required for modern mode, auto-detected polyfi
lls, transpiling JSX)? Yes

? Pick a linter / formatter config: Basic

? Pick additional lint features: Lint on save

? Where do you prefer placing config for Babel, ESLint, etc.? In dedicated confi
g files

# 将来的に作られるプロジェクトにこの設定を適用するかどうか
? Save this as a preset for future projects? (y/N) No
```

対話形式で質問が行われ、全てに回答するとプロジェクトが作られ始められるはずです。

#### localhost:8080 にアクセス

```
$ yarn serve
```

![スクリーンショット 2021-07-12 0.40.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/445f3ff9-33d5-bca3-6cdc-11d8a355b079.png)

[localhost:8080](http://localhost:8080/) にアクセスして初期状態の画面が表示されればOKです。

#### 型定義ファイルを作成

この辺のやり方は人それぞれだと思いますが、今回は「src」ディレクトリ以下に「interfaces」というディレクトリを作成し、そこに「index.ts」という型定義ファイルを置きます。

この中に汎用的に使用する型（今回であればTodo型を色々なところで使い回す予定）の定義を記述しておき、必要に応じて呼び出す感じですね。

```
$ mkdir src/interfaces
$ touch src/interfaces/index.ts
```

```ts:./frontend/src/interfaces/index.ts
// Todo型
export interface Todo {
  id?: number
  title: string
}
```

#### APIクライアントを作成

Railsで作成したAPIを呼び出すための準備を行います。

```
$ yarn add axios
```

今回は [axios](https://github.com/axios/axios) という定番のライブラリを使用しましょう。

```
$ mkdir src/lib
$ mkdir src/lib/api
$ touch src/lib/api/client.ts
$ touch src/lib/api/todos.ts
```

```ts:./frontend/src/lib/api/client.ts
import axios from 'axios'

// axiosのインスタンスを作成
const client = axios.create({
  baseURL: 'http://localhost:3000/api/v1' // Rails側のAPIエンドポイント
})

export default client
```

```ts:./frontend/src/lib/api/todos.ts
import { AxiosPromise } from 'axios'
import client from './client'
import { Todo } from '../../interfaces/index'

// getTodosを実行した際のレスポンスデータの型
interface GetTodosResponse {
  status: number
  todos: Todo[]
  message: string
}

// createTodoを実行した際のレスポンスデータの型
interface CreateTodoResponse {
  status: number
  todo: Todo
  message: string
}

// deleteTodoを実行した際のレスポンスデータの型
interface DeleteTodoResponse {
  status: number
  todo: Todo
  message: string
}

// Todo一覧を取得
export const getTodos = (): AxiosPromise<GetTodosResponse> => {
  return client.get('/todos')
}

// Todoを新規作成
export const createTodo = (data: Todo): AxiosPromise<CreateTodoResponse> => {
  return client.post('/todos', data)
}

// Todoを削除
export const deleteTodo = (id: number): AxiosPromise<DeleteTodoResponse> => {
  return client.delete(`/todos/${id}`)
}
```

#### App.vueを編集

```vue:./frontend/src/App.vue
<template>
  <div id="app">
    <h1>Todo App</h1>
    <form @submit.prevent>
      <input type="text" v-model="state.title">
      <button @click="handleCreateTodo">Add</button>
    </form>
    <p v-for="todo in state.todos" v-bind:key="todo.id">
      {{ todo.title }} <button @click="handleDeleteTodo(todo.id)">Delete</button>
    </p>
  </div>
</template>

<script lang="ts">
import { defineComponent, reactive, onMounted } from 'vue' // defineComponent関数（TypeScriptを取り入れる場合に必要）などを読み込む
import { getTodos, createTodo, deleteTodo } from './lib/api/todos' // APIを呼び出すための関数を読み込む
import { Todo } from './interfaces/index' // 型定義を読み込む

interface State {
  todos: Todo[]
  title: string
}

export default defineComponent({
  setup() {
    const state = reactive<State>({
      todos: [],
      title: ''
    })

    // Todo一覧を取得
    const handleGetTodos = async (): Promise<void> => {
      try {
        const res = await getTodos()

        if (res?.status === 200) {
          state.todos = res.data.todos // todos（配列）に結果を格納
        }
      } catch (err) {
        console.log(err)
      }
    }

    // Todoを新規作成
    const handleCreateTodo = async (): Promise<void> => {
      if (state.title === '') return // 入力が空だった場合は以降の処理を行わない

      // Todo型に沿ったデータを作成
      const data: Todo = {
        title: state.title
      }

      try {
        const res = await createTodo(data)

        if (res?.status === 200) {
          state.todos = [...state.todos, res.data.todo] // todos（配列）の最後尾にtodoを追加
        } else {
          console.log(res.data.message)
        }
      } catch (err) {
        console.log(err)
      }

      state.title = '' // 追加後はtitleを空にする（フォーム内がリセットされる）
    }

    // Todoを削除
    const handleDeleteTodo = async (id: number): Promise<void> => {
      if (id == null) return // idが無かった場合は以降の処理を行わない
      
      try {
        const res = await deleteTodo(id)

        if (res?.status === 200) {
          state.todos = state.todos.filter(todo => todo.id !== id) // idが一致しないもののみに絞り込む（要するにidが一致したものを削除する）
        } else {
          console.log(res.data.message)
        }
      } catch (err) {
        console.log(err)
      }
    }

    // Vueインスタンスがマウントされるたびに実行される関数
    onMounted(() => {
      handleGetTodos()
    })

    // template内で使用したいものをreturn
    return {
      state,
      handleCreateTodo,
      handleDeleteTodo
    }
  }
})
</script>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>
```

コードの詳細については各コメント部分を読んでいただければ何となくわかると思います。

なお、今回は「[Composition API](https://v3.vuejs.org/guide/composition-api-introduction.html#setup-component-option)」という比較的新しめの機能を使っているため、まだ馴染みの無い方はググってみて欲しいです。

もしこれまでにReactを触られた事のある方は「Hooks」をイメージしてもらうとわかりやすいかもしれません。

参照記事: [Vue 3 Composition API を使ってみよう](https://qiita.com/azukiazusa/items/1a7e5849a04c22951e97)

#### 動作確認

![スクリーンショット 2021-07-12 1.08.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/688854/4c4533e9-3764-6cd3-1cfb-c96a520262d5.png)

再度 [localhost:8080](http://localhost:8080/) にアクセスしてこんな感じになっていれば完成です。事前にRails側で初期データを挿入しているので、Todo1~5までが表示されているはず。

## あとがき

以上、Rails API + Vue.js + TypeScriptでシンプルなSAP構成のTodoアプリを作ってみました。

あくまでサンプルなのでCSSをいじったりコンポーネントを分けたりといった工夫は一切していませんが、何となく雰囲気を掴むという点ではそこそこな情報を記載しているつもりです。

あとは各々で改造してみてください。

https://github.com/kazama1209/rails-vue-todo-spa