RailsでAPIを作る際にハマった点について備忘録も兼ねて投稿したいと思います。
Formオブジェクトとは
Hiroki Zenigamiさんのブログでは下のように説明されています。
Formオブジェクトはフォームの責務をカプセル化し、コントローラやビューを疎結合に保つために必要なデザインパターンです。
ユーザの入力の整形や永続化をコントローラだけで行うと、コントローラが肥大化してしまいます。 この原因はコントローラがモデル層の知識をもちすぎるためにあります。 このときビューもフォームを表示するための知識をもつことになるため、コントローラと同じような問題が起こってしまいます。 このことは単一責任の原則に反し、モデル層の変更がコントローラやビューに影響を及ぼすことになります。
逆にActiveRecordモデルにこういった責務をもたせると、今度はActiveRecordモデルがフォームの知識を持ちすぎてしまいます。 フォームという独立した責務があるのであれば、これをひとつのクラスにカプセル化する、というのがFormオブジェクトの役割です。
簡単にざっくりとでいうとユーザの入力した値に対してバリデーションをかけたい時、コントローラーに書くと冗長になり、モデルに書いてもスマートじゃないので、バリデーション用のクラスを作ってカプセル化しておきましょうという話です。
要件
booksテーブル
|カラム名|型|制約|
|:-:|:-:|:-:|:-:|
|title|string|NOT NULL|
|category_id|integer|NOT NULL|
categoriesテーブル
|カラム名|型|制約|
|:-:|:-:|:-:|:-:|
|name|string|NOT NULL|
- リクエストはcategory_name = ~~, title = ~~ という形で送られる。
- リクエストのカテゴリー名がcategoriesテーブルにある場合は、そのidを利用して保存する。ない場合はcategoriesテーブルを新規作成し保存する。
- category_nameまたはtitleが送られない場合は、422エラーを返す。
実装
完成形がこちらです。
def create
book = bookForm.new(book_params)
if book.save
render status: 201, json: { status: 201 }
else
render status: 422, json: { status: 422 }
end
end
class BookForm
include ActiveModel::Model
attr_accessor :body, :category_name
validates :title, presence: true
validates :category_name, presence: true
def save
return if invalid?
ActiveRecord::Base.transaction do
category = Category.find_or_create_by!(name: category_name)
book = Book.new(title: title, category: category)
book.save!
end
rescue ActiveRecord::RecordInvalid
false
end
end
ActiveModel::Model
ActiveModel::ModelをIncludeしています。これにより、バリデーションを行うモジュールなど様々なモジュールが使えるようになります。
ActiveModel::AttributeAssignment
# attr_accessor で定義されたキーに対して、値を設定することができる機能を提供する
# 例) Book.new(title: "7つの習慣", category_id: 1)
# これができるのはActiveModel::AttributeAssignmentの機能を使っているから
ActiveModel::Validations
# モデルの検証用の機能を提供する(バリデーションなど)
ActiveModel::Conversion
attr_accessor
ActiveModel::Modelの#initializeでは、書き込みメソッド(#title=(value)など)を用いて値を代入しています。 つまり、Formオブジェクトで用いる値は書き込みメソッドを定義する必要があります。
attr_accessorがない場合
ActiveModel::UnknownAttributeError: unknown attribute 'category_name' for BookForm.
忘れた人のために
attr_accessor :category_name, :titleは下記の省略形です。ビギナーはこう書いたほうがイメージしやすいんです。
def title
@title
end
def title=(title)
@body = value
end
def category_name
@category_name
end
def category_name=(value)
@category_name = value
end
書き込みメソッドがないと、book = BookForm.new(book_params)のところでエラーになります。
def title=(value)
@title = value
end
読み込みメソッドがないと、少し下のbook = Book.new(title: title, category: category)のところでbodyとcategoryが読み込めずにエラーになります。
def title
@title
end
バリデーションの検証を行う
validates :title, presence: true
validates :category_name, presence: true
これにより、ユーザが入力したtitleとcategory_nameのバリューに対してバリデーションが貼られます。これは繰り返しになりますが、ActiveModel::Modelをincludeしているため使えます。(メイン! これがやりたくてわざわざクラス作ってます)
return if invalid?
呼び出し元のインスタンスに対してバリデーションチェックを行い、falseならメソッドを中断しnilを返します。
ActiveRecord::Base.transaction do ~ end
トランザクションとは、一連の処理すべてが成功した場合にのみデータベースが更新される(コミットする)仕組みのことで、ActiveRecordでは、以下のように書くことができます。
ActiveRecord::Base.transaction do
david.withdrawal(100)
mary.deposit(100)
end
エラーが起きた際、rescueする
rescue ActiveRecord::RecordInvalid
false
saveするときにバリデーションエラーが発生した場合はrescueしてfalseを返すようにします。現段階ではBookモデルにバリデーションをかけてないためなくても問題はないですが、後にBookモデル側のバリデーションを変更した際にエラーにならないよう入れています。
以上
参考
https://product-development.io/posts/rails-design-pattern-form-objects
https://api.rubyonrails.org/classes/ActiveModel/AttributeAssignment.html
https://api.rubyonrails.org/classes/ActiveRecord/Transactions/ClassMethods.html
https://product-development.io/posts/rails-design-pattern-form-objects
https://naokirin.hatenablog.com/entry/2019/02/20/231317
https://gist.github.com/kyuma-git/093ac7c801df92a735e27b125112237a