ActiveRecordからJSON Schema(swagger)ドキュメントを作る

# モチベーション

swagger便利！APIドキュメントやクライアントのmodelを自動生成したい！
しかし、JSONのAPIドキュメントを作るためには型情報が必要であり、rubyには型記述が無い。
`ActiveRecord::Migration`ではカラムと型を記述していて、ActiveRecordオブジェクトにはマッピング情報があるのに、だ。
であれば、JSONの構造体記述をActiveRecord情報から生成できれば幸せになれるのでは？という記事です。

# 前提

- Rails5系のAPIモード
- JSONレスポンスには[ActiveModelSerializers](https://github.com/rails-api/active_model_serializers)を利用
- JSON schemaには[swagger](https://swagger.io/)を利用
- swaggerの記述には[swaggard](https://github.com/adrian-gomez/swaggard) gemを利用

# 完成形

JSON　API情報を追記したRailsオブジェクトである。
コメント部分がswaggardのための記述となり、[YARD](https://yardoc.org/)形式のドキュメントになる。

メインコードとAPI情報が統合されていて見やすい点が気に入っている。

```ruby:app/controllers/api/user_controller.rb
# @tag Users
class Api::Users::UsersController < ApplicationController 

  # Returns list of users
  #
  # @response_status 200
  # @response_class Array<Rest::UserSerializer>
  def show
    @users = User.all
    render json: @users, each_serializer: Rest::UserSerializer
  end

  # Creates a user
  #
  # @body_parameter [Params::User] user
  # @response_status 200
  # @response_class Rest::UserSerializer
  def create
    @user = User.new(user_params)
    @user.save!
    render json: @user, serializer: Rest::UserSerializer, sort: :session
  rescue
    render json: { error: @user.errors.full_messages }, status: :unprocessable_entity
  end
end
```
```ruby:app/controllers/params/user.rb
# @attr [string] email
# @attr [string] name
class Params::User; end
```
```ruby:app/serializers/rest/user_serializer.rb
# @attr [integer] id
# @attr [string] email
# @attr [string] name
# @attr [string] accessToken
class Rest::UserSerializer < ActiveModel::Serializer

  attributes :id, :email, :name,
  attribute :access_token if :session?

  def session?
    instance_options[:sort] == :session
  end
end
```

これにより生成されるswaggerドキュメントの詳細は省略する。swagger-uiなどの他の記事を参照してください。

# アプローチ

YARD形式で記載しているJSON API情報を、rakeタスクで自動生成する。
タスク内でActiveRecordにアクセスして、migrationなどで生成したスキーマ情報を利用する。

- requestのbodyパラメーターを定義している`Params::Hoge`を対象にする。
- `Params::Hoge`はドキュメント用に作っている自作クラスである。
- controllerはActiveRecordの情報を使わないこと、serializerは小細工がいろいろ必要になるため対象外にする。

# ActiveRecord APIでJSON記述に利用できるもの

AcitiveRecordのクラスメソッドで役に立ちそうなものとして２つピックアップした。

## columns

`ActiveRecord::ConnectionAdapters::Column`のArrayが取得できる。
migrationで記載したDB情報とrailsオブジェクトにcastした情報がマッピングされている。

主な属性

- **name** 列名
- **type** railsの型定義`ActiveRecord::Type`
- **sql_type** DBの型定義
- **table_name** DBテーブル名
- **null** NotNull制約の有無
- **default** デフォルト値の設定

## attribute_types

keyが列名、valueが`ActiveModel::Type`となるhashセットが取得できる。
Rails5のattributesAPIで上書きcastしてる場合も、こちらには反映されている。

主な属性

- **key** 列名
- **type** railsの型定義`ActiveModel::Type`

参考情報
[ActiveModel::Typeの一覧](https://github.com/rails/rails/tree/ddb798acaab48bc0ed278c9f3c1ace78722e4217/activemodel/lib/active_model/type)

# JSON転換で調整しなければならないこと

ActiveRecordからスキーマ情報を引っ張る方法はわかったが、rakeタスクにするにはいくつかの追加処理が必要になる。

## 一部の型をcastする

ActiveRecordとJSON schemaの型定義が一部違うのでマッピングする。

例）

|ケース|rails|JSON schema|
|:------------------:|:------------------:|:------------------:|
|存在しない|text|string|
|名称が異なる|datetime|date-time|

## 除外するカラムを設定する

そのままループを回すと全カラムを出力してしまうので、除外カラムリストを設定する。

## serializeしているカラムを解決する

やっぱりserializeしたいカラムがあるんですよ！ということで、textを任意の型で指定する。

# rakeタスクの例

以上で条件が揃ったので、rakeタスクを作成する。筆者はattributesAPIでcastをしているので、attribute_typesを利用する。

1. ActiveRecordオブジェクトと出力したいファイルパスを指定する。
2. ActiveRecordオブジェクトから情報を抽出する。
3. 抽出した情報をヒアドキュメントで展開する。
4. ファイルに書き込む。

```ruby:json_params.rake
  task :generate => :environment do
    create(
      # ActiveRecord Objectの指定
      Chat,
      # 出力ファイルの指定
      "#{Rails.root}/app/controllers/params/chat.rb",
      # 除外するカラムのリスト
      [:id, :user_id, :created_at, :updated_at],
      # serializeしているカラムを解決する
      {item_list: 'hash'}
    )
  end

  private

    def create(object, file_path, exclusion_list, special_case_list=nil)
      val = ''
      object.attribute_types.map do |key, active_model_type|
        next if exclusion_list.include? key
        type = special_case_list && special_case_list.keys.include?(key.to_sym) ?
          special_case_list[key.to_sym] : adjust(active_model_type)
        val << "# @attr [#{type}] #{key.camelize(:lower)}\n"
      end

      str = <<EOS
#{val.chomp}
class Params::#{object}; end
EOS
      
      File.open(file_path,"w") {|file| file.puts str}
    end

    def adjust(model)
      case model.type
      when :text
        type = 'string'
      when :datetime
        type = 'date-time'
      else
        type = model.type
      end
    end
```

[gist]
https://gist.github.com/studioTeaTwo/799fdae46c5e22bb8a8169352eda2846
注：ActiveRecordから引っ張るだけでなく、カスタム作成も作っている。