Edited at

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

More than 1 year has passed since last update.

この記事はruby_on_railsアドベントカレンダー2016の20日目の記事です。


はじめに

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

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

みなさんどういう解決策を取っていますか?

単にcontrollerでmoduleをincludeするだけで、基本的なAPIが出来上がる。

そんなgemをrubygemsに公開したのでそのお話です。


作ったもの

baseapi GitHub

baseapi rubygems


インストール

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の詳細についてはこちら


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さんです。