LoginSignup
10
4

More than 5 years have passed since last update.

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

Last updated at Posted at 2016-12-19

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

10
4
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
4