RestFulなAPIを作る上で生産性をあげる

この記事は[ruby_on_railsアドベントカレンダー2016](http://qiita.com/advent-calendar/2016/ruby_on_rails)の20日目の記事です。

# はじめに

TEISHISEIです！語弊を恐れず書きます!お手柔らかにー

仕事で何度かRoRをAPIサーバーとして構築してサービスを作らせてもらいましたが、だいたい同じような処理をcontrollerやmodelに毎回書くのもだるいし、サービス毎にパラメータが若干違ったりするのも嫌だなと。
みなさんどういう解決策を取っていますか？

単にcontrollerでmoduleをincludeするだけで、基本的なAPIが出来上がる。
そんなgemをrubygemsに公開したのでそのお話です。

# 作ったもの

[baseapi GitHub](https://github.com/arakawamoriyuki/baseapi)
[baseapi rubygems](https://rubygems.org/gems/baseapi)

# インストール

Gemfileに下記を記述して

```
gem 'baseapi'
```

インストール

```
$ bundle install
# または
$ gem install baseapi
```

# 使い方

jsonの作成にjbuilderを使う方は以下のコマンドでデフォルトで使用されるjbuilderを作成します。

```
$ bundle exec baseapi setup
```

モデルを作成します。例としてuserモデルを作成しました。 (app/models/user.rb)

    class User < ActiveRecord::Base
    end

コントローラーで、BaseApi moduleをincludeします。 (app/controlers/users_controller.rb)

    class UsersController < ApplicationController
      include BaseApi
    end

ルーティングはこんな感じで。 (config/routes.rb)

    constraints(:format => /json/) do
      resources :users, only:[:index, :show, :create, :update, :destroy]
    end

下記のエンドポイントを持つAPIができました。

| url              | action  | method      |
|------------------|---------|-------------|
| /users.json      | index   | GET         |
| /users/{id}.json | show    | GET         |
| /users.json      | create  | POST        |
| /users/{id}.json | update  | PATCH / PUT |
| /users/{id}.json | destroy | DELETE      |

# どんなことができるの？

下記のmodelを定義したとします。

```
class User < ActiveRecord::Base
  belongs_to :company
end

class Company < ActiveRecord::Base
  has_many :users
end
```

データベースはこんな感じで。

Users table data

| id | name     | company_id |
|----|----------|------------|
| 1  | hoge     | 1          |
| 2  | huga     | 2          |

Company table data

| id | name     |
|----|----------|
| 1  | Google   |
| 2  | Apple    |

## action index

全て取得

```
GET   /users.json
SQL   =>  SELECT DISTINCT `users`.* FROM `users`
```

取得個数の指定

```
GET   /users.json?count=10
SQL   =>  SELECT DISTINCT `users`.* FROM `users` LIMIT 10 OFFSET 0
```

ページの指定

```
GET   /users.json?count=10&page=2
SQL   =>  SELECT DISTINCT `users`.* FROM `users` LIMIT 10 OFFSET 10
```

並び替え

```
GET   /users.json?order=desc&orderby=name
SQL   =>  SELECT DISTINCT `users`.* FROM `users` ORDER BY `users`.`name` DESC
```

複数条件で並び替え

```
GET   /users.json?order[]=desc&order[]=asc&orderby[]=name&orderby[]=company_id
SQL   =>  SELECT DISTINCT `users`.* FROM `users` ORDER BY `users`.`name` DESC, `users`.`company_id` ASC
```

関連テーブルで並び替え

```
GET   /users.json?order=asc&orderby=company.name
SQL   =>  SELECT DISTINCT `users`.* FROM `users` ...JOIN... ORDER BY `companies`.`name` ASC
```

名前を指定して取得

```
GET   /users.json?name=hoge
SQL   =>  SELECT DISTINCT `users`.* FROM `users` WHERE ( users.name = 'hoge')
```

複数の名前を指定して取得

```
GET   /users.json?name[]=hoge&name[]=huga
SQL   =>  SELECT DISTINCT `users`.* FROM `users` WHERE ( users.name = 'hoge' or users.name = 'huga')
```

指定以外を取得(否定)

```
GET   /users.json?id=!1
SQL   =>  SELECT DISTINCT `users`.* FROM `users` WHERE (NOT users.id = '1')
```

もちろん文字列でも可

```
GET   /users.json?name=!hoge
SQL   =>  SELECT DISTINCT `users`.* FROM `users` WHERE (NOT users.name = 'hoge')
```

nullであるレコードを取得

```
GET   /users.json?name=null
SQL   =>  SELECT DISTINCT `users`.* FROM `users` WHERE ( users.name IS NULL)
```

nullではないレコードを取得

```
GET   /users.json?name=!null
SQL   =>  SELECT DISTINCT `users`.* FROM `users` WHERE (NOT users.name IS NULL)
```

nullだけではなく空文字も取得したい場合はこちら

```
GET   /users.json?name=empty
SQL   =>  SELECT DISTINCT `users`.* FROM `users` WHERE ( users.name IS NULL OR users.name = '')
```

nullでも空文字でもないレコードを取得

```
GET   /users.json?name=!empty
SQL   =>  SELECT DISTINCT `users`.* FROM `users` WHERE (NOT users.name IS NULL AND NOT users.name = '')
```

クォートかで囲うと文字列として検索します

```
GET   /users.json?name='empty'
SQL   =>  SELECT DISTINCT `users`.* FROM `users` WHERE ( workers.name = 'empty')
```

関連テーブル(belongs_to)のカラムで検索(userから見てcompanyは単数形なのに注意して)

```
GET   /users.json?company[name]=Google
SQL   =>  SELECT DISTINCT `users`.* FROM `users` ...JOIN... WHERE ( companies.name = 'Google')
```

関連テーブル(has_many)のカラムで検索(companyから見てuserは複数形なのに注意して)

```
GET   /companies.json?users[name]=hoge
SQL   =>  SELECT DISTINCT `companies`.* FROM `companies` ...JOIN... WHERE ( users.name = 'hoge')
```

もし、companyテーブルが部署テーブル(units)を持っていて、その部署名で検索したい場合も大丈夫。
何階層でもネストできます。

```
GET   /users.json?company[units][name]=development
SQL   =>  SELECT DISTINCT `users`.* FROM `users` ...JOIN... WHERE ( units.name = 'development')
```

指定数値以上の検索もできます。

```
GET   /users.json?age=>=20
SQL   =>  SELECT DISTINCT `users`.* FROM `users` WHERE ( users.age >= 20)
```

超過も

```
GET   /users.json?age=>20
SQL   =>  SELECT DISTINCT `users`.* FROM `users` WHERE ( users.age > 20)
```

以下も

```
GET   /users.json?age=<=20
SQL   =>  SELECT DISTINCT `users`.* FROM `users` WHERE ( users.age <= 20)
```

未満も。

```
GET   /users.json?age=<20
SQL   =>  SELECT DISTINCT `users`.* FROM `users` WHERE ( users.age < 20)
```

日付も検索できて、組み合わせたら期間指定ができます。
でもデフォルトでは同じカラムに複数の条件があるとor検索されてしまうので、

```
GET   /users.json?created_at[]=>=20150901&created_at[]=<=20150931
SQL   =>  SELECT DISTINCT `users`.* FROM `users` WHERE ( users.created_at >= 20150901 and users.created_at <= 20150931)
```

下記のように、検索処理をoverrideしてand検索できるように修正する必要があります。(書き方キモいんで近いうち治すかも)
詳細は後述する**override**の項にて。

```
class User < ActiveRecord::Base
  def self._where_created_at(models, column, values)
    column_match(models, column, values, operator:'and')
  end
end
```

## action show

単純にid指定で単数レコードを取ります

```
GET   /users/1.json
SQL   =>  SELECT `users`.* FROM `users` WHERE `users`.`id` = 1
```

## action create

単純に値を指定して作成できます。

```
POST   /users.json?name=hoge
SQL   =>  INSERT INTO `example`.`users` (`id`, `name`) VALUES (NULL, 'hoge')
```

## action update

idを指定してカラムを更新できます。(patchかputを使えます)

```
PATCH  /users/1.json?name=huga
PUT    /users/1.json?name=huga
SQL   =>  UPDATE `example`.`users` SET `name` = 'huga' WHERE `users`.`id` = 1
```

## action delete

単純にidを指定して削除できます

```
DELETE /users/1.json
SQL   =>  DELETE FROM `example`.`users` WHERE `users`.`id` = 1
```

# 中で何をしているか

ControllerでBaseApiをincludeするとパラメータに応じて下記プロパティ必要な値が入ってきます。
キー名がそのテーブルのカラム名になければ無視されます。

- @models (indexアクション時にモデル配列がセットされる)
- @model (showやupdate,create時は取得したモデルが、createアクション時には未保存のモデルがセットされる)
- @Model (モデルクラスがセットされる)


```
include BaseApi
```


# デフォルトで返すjson

1階層奥にデータがあるんですが、view側でどうにでもなるので、
/view/users/model.json.jbuilder とか
/view/users/models.json.jbuilder とか
を作成してください。
詳細は後述する**jbuilder**の項にて。

## action index

```
{
  error: false,
  message: "",
  data: [
    {
      id: 1,
      name: "hoge"
    },
    {
      id: 2,
      name: "huga"
    }
  ]
}
```

## action show, create, update, destroy

```
{
  error: false,
  message: "",
  data: {
    id: 1,
    name: "hoge"
  }
}
```


# htmlも返したい

APIサーバーだけの用途でRoRを使っている人はいんですが、
htmlも返したい方は以下のような分岐で対応できます。

```
class UsersController < ApplicationController

  # baseapi module
  include BaseApi

  def index
    if request.format.json?
      # json request
      return render 'models.json.jbuilder'
    else
      # html request
    end
  end

  def show
    if request.format.json?
      # json request
      return render 'model.json.jbuilder'
    else
      # html request
    end
  end

  # html request
  def new
  end

  # html request
  def edit
  end
end
```

# 予約語

下記のパラメータは予約語としてbaseapiが使っています。

- count
- page
- order
- orderby

データベースのカラム名としてすでに上記が含まれている場合は、下記のように接頭辞を設定できます。

```
class User < ActiveRecord::Base
  @reserved_word_prefix = '_'
end
```

設定した接頭辞をつけてリクエストしてください。

```
GET   /users.json?_count=10&_page=2
```

# Override

(実際の実装はOverrideではないんですがそう呼ばせてください。)
baseapiの挙動を変更したい場合、modelに下記のメソッドを付けることで書き換えできます。
例えば、
デフォルトでは同じカラムに複数の条件があるとor検索されてしまいますがand検索に書き換えたり、
論理削除を実装した場合、baseapiは_deleteメソッドを呼び出すので、そこを書き換えて臨んだ挙動にする事ができます。


すべて取得

```
class User < ActiveRecord::Base
  def self._all
    self.all # default
  end
end
```

削除

```
class User < ActiveRecord::Base
  def _destroy
    self.destroy # default
  end
end
```

検索リクエストがあった場合によばれます。

```
class User < ActiveRecord::Base
  def self._where(models, column, values)
    column_match(models, column, values) # default
  end
end
```

nameを指定したリクエストがあった場合によばれます。

```
class User < ActiveRecord::Base
  def self._where_name(models, column, values)
    column_match(models, column, values) # default
  end
end
```

belongs_to関連のあるリクエストがあった場合によばれます。

```
class User < ActiveRecord::Base
  def self._belongs_to(models, table, hash)
    relation_match(models, table, hash) # default
  end
end
```

belongs_to関連のあるcompanyのカラムを指定したリクエストがあった場合によばれます。

```
class User < ActiveRecord::Base
  def self._belongs_to_company(models, table, hash)
    relation_match(models, table, hash) # default
  end
end
```

以降も同様にbelongs_to関連のある指定したリクエストがあった場合に、その指定をアンダースコアで区切った関数がよばれます。

```
class User < ActiveRecord::Base
  def self._belongs_to_company_units_...(models, table, hash)
    relation_match(models, table, hash) # default
  end
end
```

has_many関連のあるリクエストがあった場合によばれます。

```
class Company < ActiveRecord::Base
  def self._has_many(models, table, hash)
    relation_match(models, table, hash) # default
  end
end
```

has_many関連のあるusersのカラムを指定したリクエストがあった場合によばれます。

```
class Company < ActiveRecord::Base
  def self._has_many_users(models, table, hash)
    relation_match(models, table, hash) # default
  end
end
```

以降も同様にhas_many関連のある指定したリクエストがあった場合に、その指定をアンダースコアで区切った関数がよばれます。

```
class Company < ActiveRecord::Base
  def self._has_many_users_families_...(models, table, hash)
    relation_match(models, table, hash) # default
  end
end
```

baseapiのデフォルトの挙動は完全一致のor検索です。
and検索、like検索に変更したい場合、下記のような関数を読んであげると変更できます。

column_***関数とoperator引数で変更する。

```
class User < ActiveRecord::Base
  def self._where_name(models, column, values)
    column_match(models, column, values, operator:'or') # default is match OR
    # column_like(models, column, values, operator:'or') # LIKE OR
    # column_like(models, column, values, operator:'and') # LIKE AND
  end
end
```

関連系はrelation_***

```
class User < ActiveRecord::Base
  def self._belongs_to_company(models, table, hash)
    relation_match(models, table, hash, operator:'or') # default is match OR
    # relation_like(models, table, hash, operator:'or') # LIKE OR
    # relation_like(models, table, hash, operator:'and') # LIKE AND
  end
end
```




# hook action

作成前後や削除前後などに呼ばれるメソッド内でraiseすると返すjsonにメッセージがセットされるんですが、railsのmodel validationをちゃんと使ってやるように変更したい。。。

```
class CompaniesController < BaseApiController
  # Name Required items
  def before_create
    if params['name'].blank?
      raise 'Please enter your name'
    end
  end
end
```

```
{
  error: true,
  message: "Please enter your name",
}
```

### jbuilder

デフォルトのjbuilder作成コマンドでjbuilderを作成できます。

```
$ bundle exec baseapi setup users companies ...
```

デフォルトでは下記テンプレートを使いますが、

```
/app/views/application/xxx.json.jbuilder
```

model名以下に置くとそのjbuilderを使います。

```
/app/views/{models}/xxx.json.jbuilder
```

返すものが単数のアクション(action: show, create, delete, update)であれば下記jbuilderを

```
model.json.jbuilder
```

複数のアクション(action: index)であれば下記jbuilderを使います。

```
models.json.jbuilder
```

エラーがあった場合は下記jbuilderを使います。

```
error.json.jbuilder
```

jbuilderの詳細については[こちら](https://github.com/rails/jbuilder)


# Angular 1使ってる方(javascriptの話ですが)

こんなfactory定義して

```
window.angularApp.config([
  '$httpProvider', function($httpProvider) {
    return $httpProvider.defaults.paramSerializer = '$httpParamSerializerJQLike';
  }
]);

window.angularApp.factory('Users', [
  '$resource', function($resource) {
    return $resource('/users.json', {
      id: '@id'
    }, {
      'query': {
        method: 'GET',
        url: '/users.json',
        transformResponse: function(data, headers) {
          data = angular.fromJson(data);
          if (data.error) {
            throw data.message;
          } else {
            return data;
          }
        }
      },
      'get': {
        method: 'GET',
        url: '/users/:id.json',
        transformResponse: function(data, headers) {
          data = angular.fromJson(data);
          if (data.error) {
            throw data.message;
          } else {
            return data.data;
          }
        }
      },
      'create': {
        method: 'POST',
        url: '/users/:id.json',
        transformResponse: function(data, headers) {
          data = angular.fromJson(data);
          if (data.error) {
            throw data.message;
          } else {
            return data.data;
          }
        }
      },
      'save': {
        method: 'PUT',
        url: '/users/:id.json',
        transformResponse: function(data, headers) {
          data = angular.fromJson(data);
          if (data.error) {
            throw data.message;
          } else {
            return data.data;
          }
        }
      },
      'delete': {
        method: 'DELETE',
        url: '/users/:id.json',
        transformResponse: function(data, headers) {
          data = angular.fromJson(data);
          if (data.error) {
            throw data.message;
          } else {
            return data.data;
          }
        }
      }
    });
  }
]);
```

こういう風にクライアントサイドでrailsのmodelを扱っているように書けます。

```
// 複数取得
Users.query()
Users.query({name:'name'})
Users.query({'company[name]':'Google'})

// 単数取得
Users.get({id:1})

// 作成
Users.create({name:'name'})
user.$create()

// 更新
Users.save({id:1, name:'name'})
user.$save()

// 削除
Users.delete({id:1})
users.$delete()
```

angularはGETパラメータで配列やオブジェクト渡せるのは正しくないと考えているようで、
jquery likeなパラメータシリアライズ方法を提供する$httpParamSerializerJQLikeを設定しているのが肝です。


# さいごに

脆弱性診断もしていただき、最近のversionで修正しました。
最近RoRをAPIサーバーとして使う事が多いので駆け足でプロジェクトを進める事が出来るようになりました。
最後まで見ていただきありがとうございます！

次の記事は[tkosuga@github](http://qiita.com/tkosuga@github)さんです。