ESM アジャイル事業部 開発者ブログ

永和システムマネジメント アジャイル事業部の開発者ブログです。

ぼくがかんがえたさいきょうの Input object

こんにちは。最近筋トレにはまっている wat-aro です。

blog.agile.esm.co.jp で Input object が紹介されていますが、実際に使っていくとネストしたパラメータの扱いに困ったためその解決方法を紹介します。
この記事のコードはすべて https://github.com/wat-aro/input_object にあります。

ここでは題材として GitHub REST API の

POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews

を扱います。
API の定義は以下です。
https://docs.github.com/ja/rest/reference/pulls#create-a-review-for-a-pull-request

type RequestBody = {
  commit_id?: string;
  body?: string;
  event?: string;
  comments?: Array<{
    path: string;
    position?: number;
    body: string;
    line?: number;
    side?: string;
    start_line?: number;
    start_side?: string
  }>
}

ネストしたパラメータ

題材にしたAPI定義の comments のようにリクエストパラメータがネストしている場合がよくあります。
Input object で API のリクエストパラメータを受け取るようにしていると、このネストしたパラメータの扱いに困ります。
先の記事に紹介されたように、 AtiveModel::Attributes API を使うと ActiveRecord と同じようにキャストした値が扱えるのですが、 ActiveModel::Type にはネストしたパラメータを扱えるようなものがありません。

class ReviewInput
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :commit_id, :string
  attribute :body, :string
  attribute :event, :string, default: 'COMMENT'
  # attribute :comments, ここでネストしたパラメータを扱いたい
end

これを解決するためにハッシュや配列から別オブジェクトにキャストできるクラスを作成します。
まずは comment 用の Input object を用意します。

class Review::CommentInput
  include ActiveModel::Model
  include ActiveModel::Attributes

  attribute :path, :string
  attribute :position, :integer
  attribute :body, :string
  attribute :line, :integer
  attribute :side, :string
  attribute :start_line, :integer
  attribute :start_side, :string
end

この Review::CommentInputReviewInputattribute に指定できるようにします。
Input クラスを引数にとって initialize できる ActiveModel::Type を作成します。

class InputType < ActiveModel::Type::Value
  def initialize(input_class, array: false)
    @input_class = input_class
    @array = array
  end

  def type
    :"#{@input_class.to_s.tableize.singularize}"
  end

  private

  def cast_value(value)
    if array?
      if value.is_a?(Array)
        value.map {|v| to_input(v) }
      else
        []
      end
    else
      to_input(value)
    end
  end

  def array?
    @array
  end

  def to_input(value)
    @input_class.new(value)
  rescue ArgumentError
    nil
  end
end

InputType を使うと Review::CommentInputattribute で指定できるようになります。

  attribute :comments, InputType.new(Review::CommentInput, array: true), default: []

これでネストしたパラメータや配列になっているパラメータを Input object として扱えるようになりました。

ネストしたパラメータのバリデーション

ネストしたパラメータを扱えるようになりましたが、今のままだとバリデーションが少し不便です。
ReviewInput#valid? を呼び出してもネストした comments のバリデーションが実行されません。
バリデーションメソッドを使って comments のバリデーションを実行したとしても、そのままでは ReviewInput#errors に追加されませんし、どのフィールドでエラーがでているかを翻訳できる形で表示するには一工夫必要です。

ここではカスタムバリデータを作成してこの問題を解決します。
このカスタムバリデータは以下のように書いて呼び出します。

  validates :comments, input: true

これは、comments に対して valid? を呼び出し、errors に翻訳できる形でエラーを追加します。

class InputValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, input)
    if input.respond_to?(:each)
      input.each do |i|
        validate_input(record, attribute, i)
      end
    else
      validate_input(record, attribute, input)
    end
  end

  private

  def validate_input(record, attribute, input)
    if input.respond_to?(:valid?)
      return if input.valid?

      input.errors.messages.each do |key, values|
        values.each do |value|
          record.errors.add("#{record.class.to_s.tableize.singularize}/#{attribute}.#{key}", value)
        end
      end
    else
      record.errors.add(attribute, :invalid)
    end
  end
end

翻訳ファイルについては

---
ja:
  activemodel:
    attributes:
      'review_input/comments':
        body: コメントの本文
        path: コメントのパス

のようにしておくとエラーがあった場合に

irb(#<Repositories::PullRequests::ReviewsController:0x00005634fe0e8a38>):003:0> e.record.errors.messages
=> {:"review_input/comments.body"=>["を入力してください"]}
irb(#<Repositories::PullRequests::ReviewsController:0x00005634fe0e8a38>):004:0> e.record.errors.full_messages
=> ["コメントの本文を入力してください"]

のようにエラーメッセージを出せるようになります。

さいごに

Input object を実際に扱う上で困るネストしたパラメータに対応する方法を紹介しました。
この記事がいいと思った方はチャンネル登録とグッドボタンをよろしくおねがいします。

agile.esm.co.jp