Rails 5の --apiの環境をCloud9で作るメモ

https://qiita.com/YusukeIwaki/items/2936b2b79a113c9b495d のAPI版。
完全に自分用メモ。

------

## リポジトリを作る

<img src="https://qiita-image-store.s3.amazonaws.com/0/74571/e5711d44-340a-c2b4-1ab5-380f99c5133d.png" width=240/>

----

![image.png](https://qiita-image-store.s3.amazonaws.com/0/74571/21da0ab7-0678-cb38-1dc4-046fec097ae4.png)

------

## rails new の前に

### 既存のワークスペースは消す

```
yusukeiwaki:~/workspace $ cd ..
yusukeiwaki:~ $ rm -rf workspace/
yusukeiwaki:~ $ mkdir workspace
yusukeiwaki:~ $ cd workspace/
```

### 最新のrailsをインストール

```
yusukeiwaki:~/workspace $ bundle init
Writing new Gemfile to /home/ubuntu/workspace/Gemfile
```

```ruby:Gemfile
# frozen_string_literal: true
source "https://rubygems.org"

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

gem "rails"
```

のように編集(gem rails のコメントアウトを外す)して、

```
yusukeiwaki:~/workspace $ bundle install
Fetching gem metadata from https://rubygems.org/..........
Fetching version metadata from https://rubygems.org/..
Fetching dependency metadata from https://rubygems.org/.
Resolving dependencies...
Fetching rake 12.3.1
Installing rake 12.3.1
Using concurrent-ruby 1.0.5
Fetching minitest 5.11.3
Installing minitest 5.11.3
Using thread_safe 0.3.6
Using builder 3.2.3
Fetching erubi 1.7.1
Installing erubi 1.7.1
Fetching mini_portile2 2.3.0
Installing mini_portile2 2.3.0
　(省略)
```

## rails new

今回はAPIなので、Rails 5から導入された[APIモード](http://guides.rubyonrails.org/api_app.html)を使う。

```
yusukeiwaki:~/workspace $ bundle exec rails new --api --database mysql --skip-test --skip-action-cable --skip-keeps --skip-yarn .
       exist  
      create  README.md
      create  Rakefile
      create  .ruby-version
      create  config.ru
      create  .gitignore
    conflict  Gemfile
Overwrite /home/ubuntu/workspace/Gemfile? (enter "h" for help) [Ynaqdh] y
       force  Gemfile
         run  git init from "."
Initialized empty Git repository in /home/ubuntu/workspace/.git/
      create  app
      create  app/assets/config/manifest.js
      create  app/assets/javascripts/application.js
      create  app/assets/javascripts/cable.js
      create  app/assets/stylesheets/application.css
      create  app/channels/application_cable/channel.rb
      create  app/channels/application_cable/connection.rb
      create  app/controllers/application_controller.rb
      create  app/helpers/application_helper.rb
      create  app/jobs/application_job.rb
      create  app/mailers/application_mailer.rb
      create  app/models/application_record.rb
      create  app/views/layouts/application.html.erb
      create  app/views/layouts/mailer.html.erb
      create  app/views/layouts/mailer.text.erb
      create  bin
      create  bin/bundle
      create  bin/rails
      create  bin/rake
      create  bin/setup
      create  bin/update
      create  bin/yarn
      create  config
      create  config/routes.rb
      create  config/application.rb
      create  config/environment.rb
      create  config/puma.rb
      create  config/spring.rb
      create  config/storage.yml
      create  config/environments
      create  config/environments/development.rb
      create  config/environments/production.rb
      create  config/environments/test.rb
      create  config/initializers
      create  config/initializers/application_controller_renderer.rb
      create  config/initializers/assets.rb
      create  config/initializers/backtrace_silencers.rb
      create  config/initializers/content_security_policy.rb
      create  config/initializers/cookies_serializer.rb
      create  config/initializers/cors.rb
      create  config/initializers/filter_parameter_logging.rb
      create  config/initializers/inflections.rb
      create  config/initializers/mime_types.rb
      create  config/initializers/new_framework_defaults_5_2.rb
      create  config/initializers/wrap_parameters.rb
      create  config/locales
      create  config/locales/en.yml
      create  config/master.key
      append  .gitignore
      create  config/boot.rb
      create  config/database.yml
      create  db
      create  db/seeds.rb
      create  lib
      create  lib/tasks
      create  lib/assets
      create  log
      create  public
      create  public/404.html
      create  public/422.html
      create  public/500.html
      create  public/apple-touch-icon-precomposed.png
      create  public/apple-touch-icon.png
      create  public/favicon.ico
      create  public/robots.txt
      create  tmp
      create  tmp/cache
      create  tmp/cache/assets
      create  vendor
      create  storage
      create  tmp/storage
      remove  app/assets
      remove  lib/assets
      remove  tmp/cache/assets
      remove  app/helpers
      remove  test/helpers
      remove  app/views/layouts/application.html.erb
      remove  public/404.html
      remove  public/422.html
      remove  public/500.html
      remove  public/apple-touch-icon-precomposed.png
      remove  public/apple-touch-icon.png
      remove  public/favicon.ico
      remove  app/assets/javascripts
      remove  config/initializers/assets.rb
      remove  app/assets/javascripts/cable.js
      remove  app/channels
      remove  config/initializers/cookies_serializer.rb
      remove  config/initializers/content_security_policy.rb
      remove  config/initializers/new_framework_defaults_5_2.rb
      remove  bin/yarn
         run  bundle install
The dependency tzinfo-data (>= 0) will be unused by any of the platforms Bundler is installing for. Bundler is installing for ruby but the dependency is only for x86-mingw32, x86-mswin32, x64-mingw32, java. To add those platforms to the bundle, run `bundle lock --add-platform x86-mingw32 x86-mswin32 x64-mingw32 java`.
Fetching gem metadata from https://rubygems.org/.........
Fetching gem metadata from https://rubygems.org/.
Resolving dependencies...
Using rake 12.3.1
Using concurrent-ruby 1.0.5
Using i18n 1.0.1
Using minitest 5.11.3
Using thread_safe 0.3.6
Using tzinfo 1.2.5
Using activesupport 5.2.0
Using builder 3.2.3
Using erubi 1.7.1
Using mini_portile2 2.3.0
Using nokogiri 1.8.2
Using rails-dom-testing 2.0.3
Using crass 1.0.4
Using loofah 2.2.2
Using rails-html-sanitizer 1.0.4
Using actionview 5.2.0
Using rack 2.0.5
Using rack-test 1.0.0
Using actionpack 5.2.0
Using nio4r 2.3.1
Using websocket-extensions 0.1.3
Using websocket-driver 0.7.0
Using actioncable 5.2.0
Using globalid 0.4.1
Using activejob 5.2.0
Using mini_mime 1.0.0
Using mail 2.7.0
Using actionmailer 5.2.0
Using activemodel 5.2.0
Using arel 9.0.0
Using activerecord 5.2.0
Using mimemagic 0.3.2
Using marcel 0.3.2
Using activestorage 5.2.0
Using msgpack 1.2.4
Using bootsnap 1.3.0
Using bundler 1.16.1
Using byebug 10.0.2
Using ffi 1.9.23
Using rb-fsevent 0.10.3
Using rb-inotify 0.9.10
Using ruby_dep 1.5.0
Using listen 3.1.5
Using method_source 0.9.0
Using mysql2 0.5.1
Using puma 3.11.4
Using thor 0.20.0
Using railties 5.2.0
Using sprockets 3.7.1
Using sprockets-rails 3.2.1
Using rails 5.2.0
Using spring 2.0.2
Using spring-watcher-listen 2.0.1
Bundle complete! 9 Gemfile dependencies, 53 gems now installed.
Use `bundle info [gemname]` to see where a bundled gem is installed.
         run  bundle exec spring binstub --all
* bin/rake: spring inserted
* bin/rails: spring inserted
```

めも：

* `--skip-javascript` は指定しても多分きかない。
* `--skip-action-cable` はAPIモードだと効かない？ →[issue](https://github.com/rails/rails/issues/22868#issuecomment-168183688)

## なんとなくバージョン管理

```
yusukeiwaki:~/workspace (master) $ git add -A
yusukeiwaki:~/workspace (master) $ git commit -a -m "initial commit"
```

## config/database.ymlの編集

Cloud9環境にあわせて、以下のようにする

```yaml:config/database.yml
default: &default
  adapter: mysql2
  charset: utf8mb4
  encoding: utf8mb4
  collation: utf8mb4_unicode_ci
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  username: root
  password:
  socket: /var/run/mysqld/mysqld.sock

development:
  <<: *default
  database: c9
  username: <%=ENV['C9_USER']%>
  host: <%=ENV['IP']%>

test:
  <<: *default
  database: c9_test
  username: <%=ENV['C9_USER']%>
  host: <%=ENV['IP']%>
```

ちなみに、Cloud9環境かどうかに関係なく、 `charset: utf8` のデフォルト設定は良くないので、特別な理由がない限り

```
  charset: utf8mb4
  encoding: utf8mb4
  collation: utf8mb4_unicode_ci
```

に変更する。

## ためしに起動する

### MySQLサーバの起動

Cloud9特有。

```
yusukeiwaki:~/workspace (master) $ mysql-ctl start
Installing MySQL
 * Stopping MySQL database server mysqld
   ...done.
 * Starting MySQL database server mysqld
   ...done.
 * Checking for tables which need an upgrade, are corrupt or were 
not closed cleanly.

MySQL 5.5 database added.  Please make note of these credentials:

       Root User: yusukeiwaki
   Database Name: c9

 * Starting MySQL database server mysqld
   ...done.
```

### Railsサーバの起動

<img src="https://qiita-image-store.s3.amazonaws.com/0/74571/22d4a84b-8fdc-2ab2-2160-80c8ebb53d35.png" width=180/>

![image.png](https://qiita-image-store.s3.amazonaws.com/0/74571/e39d679c-c6b0-3165-479d-5c15104d044e.png)

うん、まったくAPIっぽくないｗ

## てきとうなAPIをつくる

Memoモデル（title/body）の追加、一覧、削除APIをつくる。

### モデルをつくる

```
yusukeiwaki:~/workspace (master) $ bundle exec rails g model Memo title:string body:text
Running via Spring preloader in process 19767
      invoke  active_record
      create    db/migrate/20180512131906_create_memos.rb
      create    app/models/memo.rb
yusukeiwaki:~/workspace (master) $ bundle exec rake db:migrate
== 20180512131906 CreateMemos: migrating ======================================
-- create_table(:memos)
   -> 0.0291s
== 20180512131906 CreateMemos: migrated (0.0292s) =============================
```

### ルートを書く

```ruby:config/routes.rb
Rails.application.routes.draw do
  # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
  namespace :api, defaults: { format: :json } do
    resources :memos, only: [:index, :create, :destroy]
  end
end
```

```
yusukeiwaki:~/workspace (master) $ bundle exec rake routes
                   Prefix Verb   URI Pattern                                                                              Controller#Action
                api_memos GET    /api/memos(.:format)                                                                     api/memos#index {:format=>:json}
                          POST   /api/memos(.:format)                                                                     api/memos#create {:format=>:json}
                 api_memo DELETE /api/memos/:id(.:format)                                                                 api/memos#destroy {:format=>:json}
```

### コントローラを書く

```ruby:api/application_controller.rb
class Api::ApplicationController < ::ApplicationController
  rescue_from ActiveRecord::RecordInvalid, with: :handle_validation_error
  rescue_from ActionController::ParameterMissing, with: :handle_bad_request
  rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found

  private

  def handle_validation_error(err)
    render_error(400, err.record.errors.full_messages.join("\n"))
  end

  def handle_bad_request(err)
    render_error(400, err.message)
  end

  def handle_not_found(err)
    render_error(404, "Not Found")
  end

  def render_error(status, message)
    render json: { error: message }, status: status
  end
end
```

```ruby:api/memos_controller.rb
class Api::MemosController < Api::ApplicationController
  def index
    render json: Memo.all.map{ |memo| memo.attributes.slice("id", "title", "body") }
  end

  def create
    memo = Memo.create!(params.require(:memo).permit(:title, :body))
    render json: memo.attributes.slice("id", "title", "body")
  end

  def destroy
    memo = Memo.destroy(params[:id])
    render json: memo.attributes.slice("id", "title", "body")
  end
end
```

app/views にjbuilderで書くのはあとでやる。

```
yusukeiwaki:~/workspace (master) $ jo memo=$(jo title=title1 body=bodybodybody) | http POST http://localhost:8080/api/memos                                                             
HTTP/1.1 200 OK
Cache-Control: max-age=0, private, must-revalidate
Content-Type: application/json; charset=utf-8
ETag: W/"8d623642bb179d19ecda3be45134d104"
Transfer-Encoding: chunked
X-Request-Id: 41ac3276-b89b-400c-a107-ee134d9c3c21
X-Runtime: 0.050641

{
    "body": "bodybodybody", 
    "id": 1, 
    "title": "title1"
}

yusukeiwaki:~/workspace (master) $ jo memo=$(jo title=title2 body=bodybody222222) | http POST http://localhost:8080/api/memos                                                           
HTTP/1.1 200 OK
Cache-Control: max-age=0, private, must-revalidate
Content-Type: application/json; charset=utf-8
ETag: W/"7a9ae0e7984992ce75b982487740e4c7"
Transfer-Encoding: chunked
X-Request-Id: 1444f298-0c29-4d33-854a-5185590b2246
X-Runtime: 0.019321

{
    "body": "bodybody222222", 
    "id": 2, 
    "title": "title2"
}

yusukeiwaki:~/workspace (master) $ http http://localhost:8080/api/memos
HTTP/1.1 200 OK
Cache-Control: max-age=0, private, must-revalidate
Content-Type: application/json; charset=utf-8
ETag: W/"f43cf704215a7e9ef462e1a5eb5c39ad"
Transfer-Encoding: chunked
X-Request-Id: c43f496e-d342-4d69-8741-6245a52f41cf
X-Runtime: 0.030159

[
    {
        "body": "bodybodybody", 
        "id": 1, 
        "title": "title1"
    }, 
    {
        "body": "bodybody222222", 
        "id": 2, 
        "title": "title2"
    }
]

yusukeiwaki:~/workspace (master) $ http DELETE http://localhost:8080/api/memos/1
HTTP/1.1 200 OK
Cache-Control: max-age=0, private, must-revalidate
Content-Type: application/json; charset=utf-8
ETag: W/"8d623642bb179d19ecda3be45134d104"
Transfer-Encoding: chunked
X-Request-Id: d87cc67b-5c4c-433d-bbb6-51fd84c356f7
X-Runtime: 0.021260

{
    "body": "bodybodybody", 
    "id": 1, 
    "title": "title1"
}

yusukeiwaki:~/workspace (master) $ http http://localhost:8080/api/memos
HTTP/1.1 200 OK
Cache-Control: max-age=0, private, must-revalidate
Content-Type: application/json; charset=utf-8
ETag: W/"511bd49242014136687a70c928d57a57"
Transfer-Encoding: chunked
X-Request-Id: d668aeb1-f45a-4f63-8268-e0d6f2a0ad1e
X-Runtime: 0.007397

[
    {
        "body": "bodybody222222", 
        "id": 2, 
        "title": "title2"
    }
]
```

## もうすこしいろいろまともにする

### モデルのバリデーション

```ruby:app/models/memo.rb
class Memo < ApplicationRecord
  validates :title,
    presence: true

  validates :body,
    presence: true
end
```

```
yusukeiwaki:~/workspace (master) $ jo memo=$(jo body=タイトルがないの) | http POST http://localhost:8080/api/memos                                                                    
HTTP/1.1 400 Bad Request
Cache-Control: no-cache
Content-Type: application/json; charset=utf-8
Transfer-Encoding: chunked
X-Request-Id: 8a1694d4-457c-4886-b796-2b90e1945c68
X-Runtime: 0.134392

{
    "error": "Title can't be blank"
}
```

### jbuilder

コントローラにJSONレンダリングロジックを書くのはあまりにひどいので、app/viewsにjbuilderで書こう。

```ruby:Gemfile
# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
gem 'jbuilder', '~> 2.5'
```

```
yusukeiwaki:~/workspace (master) $ bundle install
```

```ruby:app/controllers/api/memos_controller.rb
class Api::MemosController < Api::ApplicationController
  def index
    @memos = Memo.all
  end

  def create
    @memo = Memo.create!(params.require(:memo).permit(:title, :body))
    render action: :show
  end

  def destroy
    @memo = Memo.destroy(params[:id])
    render action: :show
  end
end
```

```ruby:app/views/api/memos/index.json.jbuilder
json.memos @memos do |memo|
  json.partial! 'memo', memo: memo
end
```

```ruby:app/views/api/memos/show.json.jbuilder
json.memo do
  json.partial! 'memo', memo: @memo
end
```

```ruby:app/views/api/memos/_memo.json.jbuilder
json.extract! memo, :id, :title, :body
```


```
yusukeiwaki:~/workspace (master) $ jo memo=$(jo title=hoge body=bodybody) | http POST http://localhost:8080/api/memos                                                                   
HTTP/1.1 200 OK
Cache-Control: max-age=0, private, must-revalidate
Content-Type: application/json; charset=utf-8
ETag: W/"fc357235fdc57ac2da064861d82be141"
Transfer-Encoding: chunked
X-Request-Id: 024d313c-6dbf-492a-b96b-d425042eded9
X-Runtime: 0.037340

{
    "memo": {
        "body": "bodybody", 
        "id": 3, 
        "title": "hoge"
    }
}

yusukeiwaki:~/workspace (master) $ http DELETE http://localhost:8080/api/memos/2
HTTP/1.1 200 OK
Cache-Control: max-age=0, private, must-revalidate
Content-Type: application/json; charset=utf-8
ETag: W/"7d5d68be0f2e4981e1dcd5a51cdde183"
Transfer-Encoding: chunked
X-Request-Id: 6f7b6d66-47db-42dc-9011-44db9cccde2f
X-Runtime: 0.041892

{
    "memo": {
        "body": "bodybody222222", 
        "id": 2, 
        "title": "title2"
    }
}

yusukeiwaki:~/workspace (master) $ http http://localhost:8080/api/memos                                                                   
HTTP/1.1 200 OK
Cache-Control: max-age=0, private, must-revalidate
Content-Type: application/json; charset=utf-8
ETag: W/"97eb4b999de3668ab7e126ae2604ae5e"
Transfer-Encoding: chunked
X-Request-Id: d21c707d-c53c-4157-9e3e-25c8a75a649c
X-Runtime: 0.008422

{
    "memos": [
        {
            "body": "bodybody", 
            "id": 3, 
            "title": "hoge"
        }
    ]
}
```

### RSpec

specはてきとうでもいいから書いておこう

#### インストール

```Gemfile
group :development, :test do
  gem 'rspec-rails', '~> 3.7'
  gem 'json_spec', '~> 1.1', '>= 1.1.4'
end
```

```
yusukeiwaki:~/workspace (master) $ bundle install
yusukeiwaki:~/workspace (master) $ bundle exec rails g rspec:install
Running via Spring preloader in process 27995
      create  .rspec
      create  spec
      create  spec/spec_helper.rb
      create  spec/rails_helper.rb
```

#### json_specの設定

spec/rails_helper.rb の

```ruby:spec/rails_helper.rb
Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f }
```

をコメントアウトを外して、以下のファイルを作成。

```ruby:spec/support/json_spec.rb
RSpec.configure do |config|
  config.include JsonSpec::Helpers
end
```

#### RAILS_ENV=test でdb:create, db:migrate

```
yusukeiwaki:~/workspace (master) $ bundle exec rake db:create RAILS_ENV=test
Created database 'c9_test'
yusukeiwaki:~/workspace (master) $ bundle exec rake db:migrate RAILS_ENV=test                                                                                                           
== 20180512131906 CreateMemos: migrating ======================================
-- create_table(:memos)
   -> 0.0193s
== 20180512131906 CreateMemos: migrated (0.0215s) =============================
```

#### request specを実装

```ruby:spec/requests/api/memos_spec.rb
require 'rails_helper'
describe '/api/memos', type: :request do
  describe 'GET /api/memos' do
    subject { get api_memos_path }

    before {
      2.times { |i| Memo.create!(title: "memo#{i}", body: "body#{i}") }
    }

    it {
      subject
      expect(response).to have_http_status(200)
      expect(response.body).to have_json_size(2).at_path("memos")
    }
  end
  describe 'POST /api/memos' do
    subject { post api_memos_path, params: { memo: memo_params } }

    context 'titleが空のとき' do
      let(:memo_params) { { body: "titleがないよ"} }

      it {
        subject
        expect(response).to have_http_status(400)
      }
    end

    context 'bodyが空のとき' do
      let(:memo_params) { { title: "bodyがないよー！"} }

      it {
        subject
        expect(response).to have_http_status(400)
      }
    end

    context 'title, bodyが指定されているとき' do
      let(:memo_params) { { title: "titletitletitle", body: "bodybodybody" } }

      it {
        expect{ subject }.to change{ Memo.count }.by(1)
        expect(response).to have_http_status(200)
        memo = Memo.last
        expect(response.body).to be_json_eql(memo.id).at_path("memo/id")
        expect(response.body).to be_json_eql(memo.title.to_json).at_path("memo/title")
        expect(response.body).to be_json_eql(memo.body.to_json).at_path("memo/body")
      }
    end
  end
  describe 'DELETE /api/memos' do
    let(:memo) { Memo.last }
    subject { delete api_memo_path(memo) }

    before {
      2.times { |i| Memo.create!(title: "memo#{i}", body: "body#{i}") }
    }

    it {
      expect{ subject }.to change{ Memo.count }.by(-1)
      expect(response).to have_http_status(200)
      expect(response.body).to be_json_eql(memo.id).at_path("memo/id")
      expect{ Memo.find(memo.id) }.to raise_error(ActiveRecord::RecordNotFound)
    }
  end
end
```

factory_girlは面倒なので使っていない。

#### RSpecを実行

```
yusukeiwaki:~/workspace (master) $ bundle exec rspec spec/requests/api/memos_spec.rb 
.....

Finished in 0.32657 seconds (files took 1.51 seconds to load)
5 examples, 0 failures
```