この記事は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 | |
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さんです。