Ruby on Rails API / Vue.js / ぐるなび APIを使用した検索アプリの作り方

### 【完成イメージ】
![circleanimationmuvie](https://media.giphy.com/media/iDgeZbfPXRmXpCB0rX/giphy.gif)

https://ancient-retreat-65595.herokuapp.com/

### 環境
Ruby 2.6.3
Rails 5.2.3
vue.js

### 使用するAPI
ぐるなび
https://api.gnavi.co.jp/api/

### 実装したい機能
- 「エリア」「食べたいもの」をフォームに入力し、検索をすると、店舗一覧が表示される。

### 技術的なアプローチ

- RailsにVue.jsを載せる。(RailsはWEB API, Vue.jsはViewを担当)
- Rails側をWeb API化させる。
- Vue.js側でバリデーション処理をさせる。(無駄なリクエストを、ぐるなびAPIにしないため)
- Rails側にService層を作る。(ぐるなびAPIへの一連の処理(ビジネスロジック)の責務は、モデルでもコントローラーにも持たせたくないから)

### 処理の流れ
無駄かもしれないが、以下のような流れを想定しています。
1, Vue.js側でRailsにリクエスト。(リクエスト前にバリデーション処理をする)
2, Rails側で、Vue.jsから送られてきたパラメータを処理し、ぐるなびAPIから情報を取得。
3, ぐるなびAPIから取得した情報を、Rails側で処理し、Web API化したものを、Vue.js側にレスポンス。
4, Vue.js側のviewに描写。

## 1,RailsにVue.jsを載せる
避けた方が賢明なやり方

```コマンド:オプション指定で、vue.jsをインストールする方法は、私の場合、うまく行かなかった
# この方法はオススメしない
rails new [アプリ名] -d postgresql -B --webpack=vue
```

　
Herokuにデプロイすることを想定し、まずは、Railsのプロジェクトを作成する

```command:プロジェクトを作成
$ rails new [アプリ名] -d postgresql
```

```ruby:Gemfileに追記
gem "webpacker", github: "rails/webpacker"
```
追記したら、bundle install

```command:yarnを導入し、下記コマンドを実装し、vue.jsをインストールする
$ rails webpacker:install 
$ rails webpacker:install:vue
```
※「rails webpacker:install」でエラーを吐いた時の対処法(参考サイト)
https://qiita.com/bonoboz/items/52780af6a9d77d8a14ba

```command
$ rails g controller homes index
```

```html:views/homes/index.html.erb
<%= javascript_pack_tag 'hello_vue' %>
<%= stylesheet_pack_tag 'hello_vue' %>
```

起動し、「Hello Vue!」と表示されれば、OK。　　
※ルーティングは、routes.rbで、設定していることを前提。
特に何もなければ、「root to: "homes#index"」で良いかと思います。

## 2,foremanの導入
Procfileを使い、Webpackerの起動と、railsの起動を、簡単でできるようにする

```ruby:Gemfile
gem 'foreman'
```

※必要があれば、「gem install foreman」も実行する。

```commmand:/Procfile(rootの配下にファイルを作成)
web: bundle exec rails server -p $PORT
webpack: ./bin/webpack-dev-server
```
あとは、下記コマンドを実行するだけ

```
$ foreman start
```
「localhost:5000」でアクセスできればOK.

## 3,ぐるなびAPIとの連携
アクセスキーを取得している前提

今回、service層を作り、controllerをFatにしないようにします。

```ruby:/app/services/gurumes/search_service.rb
module Gurumes
  class SearchService
    def initialize(area, food)
      @area = area
      @food = food
      @hit = 20
    end

    def run
      uri = URI.parse URI.encode("https://api.gnavi.co.jp/RestSearchAPI/v3/?keyid=#{ENV["GURUNAVI_API"]}&address=#{@area}&freeword=#{@food}&&hit_per_page=#{@hit}")
      response = JSON.load(Net::HTTP.get(uri))
      response["rest"]
    end
  end
end
```

```command:controllerの作成
$ rails g controller api/v1/searches
```

```ruby:/controllers/api/v1/searches_controller.rb
class Api::V1::SearchesController < ApplicationController
  def index
    @shops = Gurumes::SearchService.new(params[:area],params[:food]).run
  end
end
```
このままだと、Serviceクラスが読まれないので、以下を追記

```ruby:.config/application.rb
config.autoload_paths += %W(#{config.root}/app/services)
```
## 4,RailsをWeb API化させる

```ruby:routes.rb(json形式で返すように指定してあげる)
root to: "homes#index"

namespace :api, {format: 'json'} do
  namespace :v1 do
    resources :searches, only: [:index]
  end
end
```

今回は、jbuilderを使うことにしたので、ファイルを作成し、下記を記述

```ruby:/views/api/v1/searches/index.json.jbuilder
json.array!@shops
```
あとは、
「localhost:5000/api/v1/searches」にGETでアクセスし、値が返ってきていたら、OK

## 5,view側を作成する
ここでは、検索して結果を表示させるだけのアプリのため、最低限の実装にしている。


```command:axiosの導入
$ yarn add axios
```

```command:view-routerの導入
$ yarn add vue-router
```

```command:bootstrapの導入
$ yarn add bootstrap-vue
```
vue.jsでbootstrapを利用する際、少し、記述方法が変わっているので、公式をチェックする。
https://bootstrap-vue.js.org/


```html:/views/homes/index.html.erb
<div id="app">
  <navbar></navbar>
  <div class="container">
    <router-view></router-view>
  </div>
</div>


<%= javascript_pack_tag 'gurume' %>
<%= stylesheet_pack_tag 'gurume' %>
```

```js:/javascript/packs/gurume.jsを作成
import Vue from 'vue/dist/vue.esm.js'
import Router from './router/router'
import Header from './components/header.vue'

import BootstrapVue from 'bootstrap-vue'
import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap-vue/dist/bootstrap-vue.css'
Vue.use(BootstrapVue)

Vue.config.productionTip = false

var app = new Vue({
  router: Router,
  el: '#app',
  components: {
    'navbar': Header,
  }
});
```
```js:/javascript/packs/router/router.jsを作成
import Vue from 'vue/dist/vue.esm.js'
import VueRouter from 'vue-router'
import Index from '../components/index.vue'

Vue.use(VueRouter)

export default new VueRouter({
  mode: 'history',
  routes: [
    { path: '/', component: Index },
  ],
})
```

```html:/javascript/packs/components/header.vueを作成
<template>
  <div>
    <b-navbar toggleable="lg" type="dark" variant="info">
      <b-navbar-brand href="/">Which One</b-navbar-brand>
      <b-navbar-toggle target="nav-collapse"></b-navbar-toggle>
      <b-collapse id="nav-collapse" is-nav>
        <b-navbar-nav>
          <b-nav-item href="#">テストリンク</b-nav-item>
          <b-nav-item href="#">テストリンク</b-nav-item>
        </b-navbar-nav>
      </b-collapse>
    </b-navbar>
  </div>
</template>
```

```vue:/javascript/packs/components/index.vueを作成
<template>
  <div>
    <div v-if="errors.length" class="error-box">
      <ul>
        <li v-for="error in errors" class="errors-list">{{ error }}</li>
      </ul>
    </div>
    <div class="row justify-content-center search-box">
      <div class="col-offset-2 col-sm-8 col-offset-2">
        <form @submit.prevent="exec">
          <div class="input-group">
            <input v-model="area" class="form-control text-center" placeholder="場所は？？">
            <span class="de-style food-form">で</span>
            <input v-model="food" class="form-control text-center food-form" placeholder="何が食べたい？？">
            <span class="input-group-addon btn"><button type="submit">検索</button></span>
          </div>
        </form>
      </div>
    </div>
    <div class="row justify-content-center">
        <div v-for="shop in shops" v-if="shops" v-bind:id="'row_shop_' + shop.id">
            <div class="shop-list">
              <a :href="shop.url" target="_blank">
                {{shop.name}}
                <img :src="shop.image_url.shop_image1">
              </a>
            </div>
        </div>
    </div>
  </div>
</template>


<script>
  let alert = document.getElementsByClassName("errors-list"); 
  let alert_box = document.getElementsByClassName("error-box");

  import axios from 'axios';
  export default{
    data: function(){
      return{
        errors: [],
        shops: [],
        area:  "",
        food:  ""
      }
    },
    methods: {
      exec: function(){
        this.errors.length = 0;
        if(!this.area){
          this.errors.push("エリアを入力してください");
        }
        if(!this.food){
          this.errors.push("食べたいものを入力してください");
        }
        if(this.errors.length){
          for(let i = 0; i < alert.length; i++){
            alert[i].style.display="block";
          }          
          setTimeout(function(){
             for(let i = 0; i < alert.length; i++){
                alert[i].style.display="none";
              }
          },3000);
          return;
        }

        axios.get('/api/v1/searches',{
          params: {
            area: this.area,
            food: this.food
          },
        })
        .then((response) => {
          this.shops.length = 0;
          if(!response.data.length){
            for(let i = 0; i < alert.length; i++){
              alert[i].style.display="block";
            }    
            this.errors.push("指定された条件の店舗が存在しません");
            setTimeout(function(){
              for(let i = 0; i < alert.length; i++){
                alert[i].style.display="none";
                }
            },3000);
          }
          for(let i = 0; i < response.data.length; i++) {
            this.shops.push(response.data[i]);
          }
         }, (error) => {
           console.log(error);
         });
      }
    }
  };
</script>

<style scoped>
.search-box{
  margin-top:70px;
}

.shop-list{
  margin:15px;
}

.food-form{
  margin-left:10px !important; 
}

.de-style{
  font-size:25px;
}

.errors-list{
  list-style: none;
}

.error-box{
  position: absolute;
  top: 65px;
  left: 25%;
  color:red;
  font-size: 1.2rem;
}

.errors-list{
  animation : fadeOut 3s;
  animation-fill-mode: both;
}
@keyframes fadeOut {
  0% {
    opacity: 1;
  }
  100% {
    opacity: 0;
  }
}
</style>
```

バリデーション発生時の表示方法を少しこだわり、fadeoutするようにしてます。
※一部、APIのerrorsを拾って表示させてます。
また、バリデーションを複数回発生させてもちゃんと表示されるよう、要素をCSSで「block,none」を使い操作しています。

また、別途、gem 'dotenv-rails'などを使って、環境変数の設定をする。

## 6,Herokuへのデプロイ

```
$ heroku buildpacks:add --index 1 heroku/nodejs
$ heroku buildpacks:add --index 2 heroku/ruby
```
通常のデプロイと違うところは、上記のアドオンを入れるところ