Express, RubyonRails, Heroku, DockerでWebアプリをフルスタック開発しよう！

# 作りたいアプリ

フロントエンド側のレポジトリ 
https://github.com/wafuwafu13/go-go-pokemon

バックエンド側のリポジトリ
https://github.com/wafuwafu13/go-go-pokemon-db

- [PokeAPI](https://pokeapi.co/)を使用し、ポケモンの画像と体重を表示する。
- 体重を足した値を入力し、正解だったら、そのポケモンの名前とidをデータベースに保存する。

<img width="1429" alt="スクリーンショット 2020-06-05 14.15.43.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/402245/c4c00216-142f-9815-2fda-50133b5e1f2c.png">


- dbに保存されたポケモンの一覧を表示する。
- ポケモンの削除機能もつける。

<img width="1431" alt="スクリーンショット 2020-06-05 14.17.04.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/402245/8461a139-c0ed-b738-aa4e-ebe5b2e62b7f.png">

# データの流れ

<img width="877" alt="スクリーンショット 2020-06-05 14.56.32.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/402245/2de33bba-dcd1-5438-fc23-aea8622a8b3f.png">

``ブラウザ``から``PokeAPIサーバー``へのリクエストには**fetch**、　``Express``から``Railsサーバー``へのリクエストには**axios**を使っています。

# HTML, CSS, JavaScriptでフロントエンドを開発

「データの流れ」の、赤に該当する部分です。
フレームワークを使わず、DOM操作を駆使して実装しています。
全ての実装はしていませんが、**fetch**を使ってPokeAPIにアクセスし、ポケモンの画像と体重をブラウザに描画しているのがわかると良いと思います。

```html:index.html

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8">
        <title>ポケモン計算</title>
        <link rel="stylesheet" href="styles.css">
    </head>

    <body>

        <h1 class="title">ポケモン計算</h1>

        <div id="pokemonList">
            <div class="pokemon" id="pokemon-1">
                <img class="pokemon-img">　
                <p class="pokemon-weight"></p>　
            </div>
            <div class="pokemon" id="pokemon-2">
                <img class="pokemon-img">　
                <p class="pokemon-weight"></p>　
            </div>
        </div>

        <div id="action-wrapper">
            <a class="action action-btn" onclick="consultarPokemones()">つぎへ</a>
            <input class="action" id="answer-input" type="number">
            <a class="action action-btn" onclick="checkResult()">答える</a>
        </div>
    
        <script>
    
            let lists = document.getElementById("pokemonList")

            consultarPokemones()

            function createRandomNum(){
                let min = 1
                let max = 800
                return Math.floor(Math.random() * (max + 1 - min)) + min
            }

            function resetState(){
                document.getElementsByClassName("pokemon-img")[0].classList.remove("correct")
                document.getElementsByClassName("pokemon-img")[1].classList.remove("correct")
                document.getElementsByClassName("action-btn")[1].classList.remove("disabled")
                document.getElementById("answer-input").value = ""
            }

            function consultarPokemones(){
                resetState()
                let pokeId1 = createRandomNum()
                let pokeId2 = createRandomNum()
                consultarPokemon(pokeId1, 1)
                consultarPokemon(pokeId2, 2)
            }
            
            function consultarPokemon(pokeId, num){
                fetch(`https://pokeapi.co/api/v2/pokemon/${pokeId}`)
                .then((response) => {
                    response.json()
                    .then((pokemon) => {
                      createPokemon(pokemon, num)
                    })
                })
            }
            
            function createPokemon(pokemon, num){
                let item = lists.querySelector(`#pokemon-${num}`)
                let images = item.getElementsByTagName("img")[0] 
                images.setAttribute("src",pokemon.sprites.front_default)
                let weight = item.getElementsByTagName("p")[0]
                weight.textContent = pokemon.weight
            }

            function checkResult(){
                let ansValue = document.getElementById("answer-input").value
                let weight = document.getElementsByClassName("pokemon-weight")
                let correctAns = Number(weight[0].textContent) + Number(weight[1].textContent)
                if (ansValue == correctAns){
                    document.getElementsByClassName("pokemon-img")[0].classList.add("correct")
                    document.getElementsByClassName("pokemon-img")[1].classList.add("correct")
                    document.getElementsByClassName("action-btn")[1].classList.add("disabled")
                }
            }

        </script>

    </body>
</html>
```



# RubyonRailsでAPIを開発

[Railsで超簡単API](https://qiita.com/k-penguin-sato/items/adba7a1a1ecc3582a9c9) を大いに参考にしました。


## APIモードでRailsアプリの作成

あとで**Heroku**でデプロイするため、dbを**PostgreSQL**に設定します。

```
$ rails new go-go-pokemon-db --api -d postgresql
```

## モデル・コントローラの作成

``name``と``poke_id``をカラムに持つ``Pokemonモデル``と、``pokemonsコントローラー``を作成します。

```
$ rails g model Pokemon name:string poke_id:integer
$ rails g controller pokemons
$ rails db:create
$ rails db:migrate
```

## 名前空間を意識したルーティングの設定

```ruby:confing/routes.rb
Rails.application.routes.draw do
  namespace 'api' do
    namespace 'v1' do
      resources :pokemons
    end
  end
end
```

## コントローラの設定

updateは定義していません。
showも、今回は個別のポケモンの情報は外部サイトに飛ぶようにしたので使っていません。

```ruby:api/v1/pokemons.controller.rb
module Api
  module V1
    class PokemonsController < ApplicationController
      before_action :set_pokemon, only: [:show, :destroy]

      def index
        pokemons = Pokemon.order(created_at: :desc)
        render json: { status: 'SUCCESS', message: 'Loaded pokemons', data: pokemons }
      end

      def show
        render json: { status: 'SUCCESS', message: 'Loaded the pokemon', data: @pokemon }
      end

      def create
        pokemon = Pokemon.new(pokemon_params)
        if pokemon.save
          render json: { status: 'SUCCESS', data: pokemon }
        else
          render json: { status: 'ERROR', data: pokemon.errors }
        end
      end

      def destroy
        @pokemon.destroy
        render json: { status: 'SUCCESS', message: 'Deleted the pokemon', data: @pokemon }
      end

      private

      def set_pokemon
        @pokemon = Pokemon.find_by(poke_id: params[:id])
      end

      def pokemon_params
        params.require(:pokemon).permit(:name, :poke_id)
      end
    end
  end
end
```

## サンプルデータの格納

```
$ rails c
> Pokemon.create(name:'ピカチュウ', poke_id:1)
> Pokemon.create(name:'ポッチャマ', poke_id:2)
```

curlコマンドなりpostmanなりブラウザなりで想定どうりにapiを叩けるか確認しておきます。

## GitHubにpushしHerokuでデプロイ

```
$ git add .
$ git commit -m "first commit"
$ git push origin master
$ heroku create
$ git push heroku
$ heroku run rake db:migrate
```

デプロイが完了したら、本番環境にもサンプルデータを格納します。

```
$ heroku run rails c
> Pokemon.create(name:'ピカチュウ', poke_id:1)
> Pokemon.create(name:'ポッチャマ', poke_id:2)
```

curlコマンドなりpostmanなりブラウザなりで想定どうりにapiを叩けるか確認しておきます。

```
$ curl https://agile-thicket-77925.herokuapp.com/api/v1/pokemons
{"status":"SUCCESS","message":"Loaded pokemons","data":[{"id":2,"name":"ポッチャマ","poke_id":2,"created_at":"2020-06-04T02:55:48.219Z","updated_at":"2020-06-04T02:55:48.219Z"},{"id":1,"name":"ピカチュウ","poke_id":1,"created_at":"2020-06-04T02:55:40.375Z","updated_at":"2020-06-04T02:55:40.375Z"}]}% 
```

わかりやすい名前に変えておくと良いですね。

```
$ heroku apps:rename go-go-pokemon-api-server
```

# Expressでバックエンドを開発

[axios の導入と簡単な使い方](https://qiita.com/ksh-fthr/items/2daaaf3a15c4c11956e9)が参考になりました。
また、Expressに入門して[Express入門](https://qiita.com/wafuwafu13/items/4e05a6b9dd9a7b4897b0)を書きました。
**Express**の``get``によりブラウザからのアクセスを受け取り、**axios**の``get``、``post``、``delete``により、Railsサーバーへアクセスしています。

```js:server.js
const express = require('express');
const app = express();

app.set('views', __dirname + '/views')
app.set('view engine', 'ejs')

app.use(express.static(__dirname + '/public'))

const axiosBase = require('axios');
const axios = axiosBase.create({
  baseURL: 'https://go-go-pokemon-api-server.herokuapp.com/api/v1',
  headers: {
    'Content-Type': 'application/json',
    'X-Requested-With': 'XMLHttpRequest'
  },
  responseType: 'json'
});

app.get('/', (res) => {
  res.sendfile(__dirname + '/public/index.html');
})

app.get('/pokemon', (req, res, next) => {
  axios.get('/pokemons')
  .then((response) => {
    res.render('index', {data: response.data.data})
    console.log(response.data)
  })
  .catch((error) => {
    console.log('ERROR!! occurred in Backend.' + error)
  });
});

app.get('/post/:name/:id', (req, res, next) => {
  axios.post('/pokemons', {
    name: req.params.name,
    poke_id: req.params.id
  })
  .then((response) => {
    console.log(response.data)
  })
  .catch((error) => {
    console.log('ERROR!! occurred in Backend.' + error)
  });
});

app.get('/delete/:id', (req, res, next) => {
  axios.delete('pokemons/' + req.params.id)
  .then((response) => {
    console.log(response.data)
  })
  .catch((error) => {
    console.log('ERROR!! occurred in Backend.' + error)
  });
})
app.listen(3000);
console.log('server is running')
```

## Expressのデプロイ

ポートの設定を変えます。

```js:server.js
...
let port = process.env.PORT || 8080;
app.listen(port);
console.log('server is running')
````

Procfileを作成します。

```text:Procfile
web: node server.js
```

以下のコマンドを叩きます。

```
$ git add .
$ git commit -m "deploy setting"
$ git push origin master
$ heroku create go-go-pokemon
$ heroku git:remote --app go-go-pokemon
$ git push heroku
```

# RSpecでAPIのテスト

[APIテストの書き方](https://qiita.com/k-penguin-sato/items/defdb828bd54729272ad) を参考にしました。

```ruby:spec/requests/api/v1/pokemon_spec.rb
require 'rails_helper'

describe 'PokemonAPI' do
    let(:path) {'/api/v1/pokemons'}

    describe 'GET' do
        before do
            FactoryBot.create_list(:pokemon, 10)
            get path
            @json = JSON.parse(response.body)
        end

        it 'ステータスコード 200 を返す' do
            expect(response.status).to eq(200)
        end

        it '全てのポケモンを取得する' do
            expect(@json['data'].length).to eq(10)
        end
    end

    describe 'POST' do
        valid_params = { name: 'ポッチャマ', poke_id: 1 }

        it 'ポケモンが捕まえられている' do
            expect { post path, params: { pokemon: valid_params } }.to change(Pokemon, :count).by(+1)
            expect(response.status).to eq(200)
        end
    end

    describe 'DELETE' do
        before do
            @pokemon = FactoryBot.create(:pokemon)
        end

        it 'ポケモンを削除する' do
            expect { delete path + "/#{@pokemon.poke_id}" }.to change(Pokemon, :count).by(-1)
            expect(response.status).to eq(200)
        end
    end
end
```

# YAMLでAPIの仕様書を作成

[Swaggerの記法まとめ](https://qiita.com/rllllho/items/53a0023b32f4c0f8eabb) を参考にしました。

```yaml
swagger: "2.0"
info:
  description: "これはポケモン足し算に関するAPIです。"
  version: "1.0.0"
  title: "ポケモン足し算API"
  termsOfService: "http://swagger.io/terms/"
  contact:
    url: "https://tagawa.netlify.app/"
  license:
    name: "Apache 2.0"
    url: "http://www.apache.org/licenses/LICENSE-2.0.html"
paths:
  /pokemons:
    get:
      summary: "ポケモン一覧API"
      description: "捕獲したポケモンの情報を返します"
      responses:
        200:
          description: "成功時のレスポンス"
          schema:
            type: "object"
            properties:
              status:
                type: "string"
                example: "SUCCESS"
              message:
                type: "string"
                example: "Loaded pokemons"
              data:
                type: "array"
                items: 
                  type: "object"
                  properties:
                    id:
                      type: "integer"
                      example: 1
                    name:
                      type: "string"
                      example: "magikarp"
                    poke_id:
                      type: "integer"
                      example: 332
                    created_at:
                      type: "string"
                      example: "2020-06-05T00:22:35.486Z"
                    updated_at:
                      type: "string"
                      example: "2020-06-05T00:22:35.486Z"
  
  /pokemons/{name}/{poke_id}:
    post:
      summary: "ポケモン捕獲API"
      description: "指定したポケモンを保存します"
      parameters:
      - name: "name"
        in: "path"
        description: "捕獲したいポケモンの名前"
        required: true
        type: "string"
      - name: "poke_id"
        in: "path"
        description: "捕獲したいポケモンのID"
        required: true
        type: "integer"
        format: "int64"
      responses:
        200:
          description: "成功時のレスポンス"
          schema:
            type: "object"
            properties:
              status:
                type: "string"
                example: "SUCCESS"
              data:
                type: "object"
                properties:
                  id:
                    type: "integer"
                    example: 30
                  name: 
                    type: "string"
                    example: "leavanny"
                  poke_id: 
                    type: "integer"
                    example: 542
                  created_at:
                    type: "string"
                    example: "2020-06-05T00:22:35.486Z"
                  updated_at:
                    type: "string"
                    example: "2020-06-05T00:22:35.486Z"

  /pokemons/{poke_id}:
    delete:
      summary: "ポケモン削除API"
      description: "指定したポケモンを削除します"
      parameters:
      - name: "poke_id"
        in: "path"
        description: "削除したいポケモンのID"
        required: true
        type: "integer"
        format: "int64"
      responses:
        200:
          description: "成功時のレスポンス"
          schema:
            type: "object"
            properties:
              status:
                type: "string"
                example: "SUCCESS"
              message:
                type: "string"
                example: "Deleted the pokemon"
              data:
                type: "array"
                items: 
                  type: "object"
                  properties:
                    id:
                      type: "integer"
                      example: 1
                    name:
                      type: "string"
                      example: "magikarp"
                    poke_id:
                      type: "integer"
                      example: 332
                    created_at:
                      type: "string"
                      example: "2020-06-05T00:22:35.486Z"
                    updated_at:
                      type: "string"
                      example: "2020-06-05T00:22:35.486Z"
```

# Dockerの導入

## ローカルにDocker環境構築

参考にした記事
[Quickstart: Compose and Rails](https://docs.docker.com/compose/rails/)
[既存のRailsアプリにDockerを導入する手順](https://qiita.com/kenzoukenzou104809/items/b9e716204e0cd0cea447)
[Railsで使える環境変数を管理できるgem(dotenv-rails)や.envの導入方法](https://qiita.com/ryosuketter/items/ceb592dc6b23a20e51b5)

新たに``Dockerfile``、``docker-compose.yaml``、``.env``ファイルを階層の一番上に作成し、``database.yml``を編集します。

dbコンテナのenvironmentを設定しないと``db:create``時にエラーが出たので、dotenv-railsというgemを追加して``.env``の設定を``docker-compose.yaml``、``database.yml``に反映させてエラーを解決しました。

```Dockerfile:Dockerfile
# .ruby-versionに合わせる
FROM ruby:2.6.4 
RUN apt-get update -qq && apt-get install -y nodejs postgresql-client
RUN mkdir /myapp
WORKDIR /myapp
ADD Gemfile /myapp/Gemfile
ADD Gemfile.lock /myapp/Gemfile.lock
RUN bundle install
ADD . /myapp
```

```yaml:docker-compose.yaml
version: '3'
services:
  db:
    image: postgres
    environment:
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
      - POSTGRES_HOST_AUTH_METHOD=trust
    volumes:
      - postgresql-data:/var/lib/postgresql/data
  web:
    build: .
    command: bundle exec rails s -p 3000 -b '0.0.0.0'
    tty: true
    stdin_open: true
    depends_on: 
      - db
    ports:
      - '3000:3000'
    volumes:
      - .:/myproject
volumes:
    postgresql-data:
      driver: local
```

```yml:database.yml
default: &default
  adapter: postgresql
  encoding: unicode
  host: db  # 追加
  username: postgres # 追加
  password: <%= ENV['POSTGRES_PASSWORD'] %> # 追加
  # For details on connection pooling, see Rails configuration guide
  # https://guides.rubyonrails.org/configuring.html#database-pooling
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
```

```text:.env
POSTGRES_PASSWORD=hogehoge
```

ファイルの準備ができたら、下記のコマンドを実行してコンテナを起動させ、localhost:3000/api/v1/pokemons にアクセスできるか確認します。

```
$ docker-compose build
$ docker-compose run web rake db:create db:migrate
$ docker-compose up

localhost:3000/api/v1/pokemons に接続できる

ctr + c で終了

デタッチドモードで起動
$ docker-compose up -d 
停止
$ docker-compose stop  

dbの中身を確認
$ docker-compose run --rm web rails c 
irb(main):001:0> Pokemon.all

テストを走らせる
$ docker-compose run --rm web bundle exec rspec
```

## DockerのimageをHerokuにデプロイ

参考にした記事
[DockerでRailsの環境構築してHerokuへデプロイする](https://qiita.com/fuku_tech/items/dc6b568f7f34df10cae7)
[Rails6の本番環境でBlocked hostエラーが発生したときの対処法](https://qiita.com/kodai_0122/items/67c6d390f18698950440)

既存のサーバーでやりたかったのですが、エラーと格闘していく過程で新しく``heroku create``する形となってしまいました。

変更する必要のあるファイルは、``Dockerfile``、``development.rb``です。

```Dockerfile:Dockerfile
FROM ruby:2.6.4
RUN apt-get update -qq && apt-get install -y nodejs postgresql-client
RUN mkdir /myapp
WORKDIR /myapp
ADD Gemfile /myapp/Gemfile
ADD Gemfile.lock /myapp/Gemfile.lock
RUN bundle install
ADD . /myapp
# 追加
CMD ["rails", "server", "-b", "0.0.0.0"]
```

```ruby:development.rb
Rails.application.configure do
  config.hosts.clear # 追加
  ...
```

以下のコマンドを叩いてデプロイします。

```
$ git add .
$ git commit -m "deploy setting"
$ git push origin master
$ heroku container:login
$ heroku create
$ git remote set-url heroku <heroku createで生成されたURL>
$ heroku container:push web
$ heroku addons:create heroku-postgresql:hobby-dev
$ heroku run rails db:migrate
$ heroku container:release web
$ heroku open
```

# Userモデルと認証を作成

[Rails5 APIモードでつくるかんたんなトークンベース認証](https://dev.m6a.jp/entry/2018/11/14/162259)

# webpackの導入
# まとめ

RailsのAPIモードを使ったWebサービスのチュートリアル的なものがなかったので書いてみました。
割と複雑なデータの流れをきちんと整理できたのでよかったです。
CircleCIの導入とか生のJavaScriptをVue.tsに置き換えるとかAR.js導入とかやっていきたいです。