対象読者
- 本に関するRailsポートフォリオを作る方
- Google Books APIを用いたRailsポートフォリオを作る方
本記事の到達物
Google Books APIのIDから情報を取得し、クラスに格納する
pry(main)> @google_book = GoogleBook.new_from_id('c1L4IzmUzicC')
pry(main)> @google_book.title
=> "Practical Rails Plugins"
pry(main)> @google_book.authors
=> ["Nick Plante", "David Berube"]
pry(main)> @google_book.image
=> "http://books.google.com/books/content?id=c1L4IzmUzicC&printsec=frontcover&img=1&zoom=5&edge=curl&imgtk=AFLRE73ENsMYFOfY27vluLqgI1cO-b80lA7enoeZzzcDGEhA5NWIj3djHvd6gvP1zlKoMoC4V0_7fKVuIjWQDYVs4FrDjHvxoqtRUcxHZ9L7isRtsHc2Cs5iS6DPAQQcTT20Oseo9gq_&source=gbs_api"
キーワードから検索し、複数のオブジェクトを返す
pry(main)> @google_books = GoogleBook.search('Rails')
pry(main)> @google_books[0].title
=> "実践Rails"
pry(main)> @google_books.last.authors
=> ["Sam Ruby", "Dave Thomas", "David Heinemeier Hansson"]
pry(main)> @google_books.map { |google_book| google_book.title }
=> ["実践Rails",
"独習Ruby on Rails",
"Railsレシピ",
"Ajax on Rails",
"Ruby on Rails 4アプリケーションプログラミング",
"Ruby on Rails 5の上手な使い方 現場のエンジニアが教えるRailsアプリケーション開発の実践手法",
"Ruby on Rails 5 超入門",
"RailsによるアジャイルWebアプリケーション開発第3版",
"JRuby on Rails 実践開発ガイド",
"RailsによるアジャイルWebアプリケーション開発第4版"]
pry(main)> @google_books.class
=> Array
格納された情報を、複数のテーブルに保存できる
pry(main)> @google_book = GoogleBook.new_from_id('wlNHDwAAQBAJ')
pry(main)> @google_book.title
=> "Ruby on Rails 5の上手な使い方 現場のエンジニアが教えるRailsアプリケーション開発の実践手法"
pry(main)> @google_book.authors
=> ["太田 智彬", "寺下 翔太", "手塚 亮", "宗像 亜由美", "株式会社リクルートテクノロジーズ"]
pry(main)> @google_book.save
=> true
pry(main)> @book = Book.last
pry(main)> @book.title
=> "Ruby on Rails 5の上手な使い方 現場のエンジニアが教えるRailsアプリケーション開発の実践手法"
pry(main)> @book.authors[0].name
=> "太田 智彬"
pry(main)> @book.authors.size
=> 5
pry(main)> @book.authors.class
=> Author::ActiveRecord_Associations_CollectionProxy
pry(main)> @author = Author.last
pry(main)> @author.name
=> "株式会社リクルートテクノロジーズ"
#### 本記事で書くこと
Google Books APIからの情報を格納する、GoogleBookモデルを作成します。
大きなメリットとしては、以下の2つがあります。
- Google Books APIから受け取った情報をControllerやViewで直感的に扱える。
- 情報を取得する / 情報を格納して整理する / 複数テーブルに跨いで保存をする、などのロジックを分離して書くことができ、それぞれテストを行える。
本記事ではControllerでの使用例、テストについても記載します。
DB設計
データベースには、本の以下の情報を保存します。
- 「タイトル」
- 「著者」
- 「画像URL」
- 「出版日」
- 「Google Books APIのID」
もちろん、Google Books APIにある情報であれば、これ以外も取得し保存することができます。
本記事では説明のために、取得する情報としては少なめにしてあります。
ER図
BooksテーブルとAuthorsテーブルを用意します。
一つの本に対し、著者は複数人いることがあるので、book has_many authorsの関係にします。
ただし、「本を一覧で表示する」ようなページで、著者全員でなく代表著者を表示したい場合があると思います。
そのため、authorsテーブルにはis_representative
カラムを用意しておきます。
また、「Google Books APIから情報を取得するなら、自前のデータベースに情報を持つ必要は無いのでは?」と思われるかもしれません。
その設計を行い、失敗した話を以下に載せておきます。
GoogleBooksAPIだけで本リソースの取得をする設計を行い、失敗した話
要約すると、本の情報は自前のデータベースでも情報を持っておいた方が良い、という結論になります。
マイグレーションファイル
マイグレーションファイルを作るなら、以下のようになるかと思います。
##### Booksテーブル
class CreateBooks < ActiveRecord::Migration[5.2]
def change
create_table :books do |t|
t.string :google_books_api_id, null: false
t.string :title, null: false
t.string :image
t.date :published_at
t.timestamps
end
add_index :books, :googlebooksapi_id, unique: true
end
end
Google Books APIのIDは必ず必要なため、null: false
を指定しておきます。
また、Google Books APIのIDが重複することは考えられないため、ユニークキーも付与します。
タイトルが存在しない本は、存在しないと考えられるため、null: false
を指定しておきます。
逆に、他の情報にnull: false
を指定するのには注意が必要です。
外部APIからの情報なわけで、本によってはその情報が無いことがあり、「DBに登録できない」という事態を発生させうるからです。
##### Authorsテーブル
class CreateAuthors < ActiveRecord::Migration[5.2]
def change
create_table :authors do |t|
t.references :book, foreign_key: true
t.string :name, null: false
t.boolean :is_representative, null: false
t.timestamps
end
end
end
Google Books APIを叩くメソッド
まずはAPIを叩くモジュールをapp/lib/
配下に追加します。
module GoogleBooksApi
def url_of_creating_from_id(googlebooksapi_id)
"https://www.googleapis.com/books/v1/volumes/#{googlebooksapi_id}"
end
# Google Books APIのIDから、APIのURLを取得する
def url_of_searching_from_keyword(keyword)
"https://www.googleapis.com/books/v1/volumes?q=#{keyword}&country=JP"
end
# キーワードから、検索するAPIのURLを取得する
def get_json_from_url(url)
JSON.parse(Net::HTTP.get(URI.parse(Addressable::URI.encode(url))))
end
# URLから、JSON文字列を取得し、JSONオブジェクトを構築する
end
本記事で使用するGoogle Books APIは2種類です。
- IDから一つの本の情報を返してくれる
https://www.googleapis.com/books/v1/volumes/:ID
というURLによって取得できます。
以下のURLがその例です。
https://www.googleapis.com/books/v1/volumes/aB4B13xGEv4C
Google Books APIから取得できる情報がどんなものか知らない場合、上のURLを見て確認してみて下さい。
タイトル、出版日、購入リンク、ISBN、など色々取得できることが分かると思います。
- キーワードから検索結果群を返してくれる
https://www.googleapis.com/books/v1/volumes?q=search?:キーワード
というURLによって取得できます。
以下のURLがその例です。
https://www.googleapis.com/books/v1/volumes?q=search?Rails
Google Books APIの他仕様について知りたい方は、公式ドキュメントをご参照ください。
Getting Started
URLをエスケープするために、addressable
というgemを使っています。
以下gemをGemfileに追記しbundle install
してください。
gem 'addressable'
ちなみに、Railsでは、app/**/**.rb
を自動で読み込んでくれます。
よって使いたいクラス内でinclude GoogleBooksApi
とinclude
さえすれば、以上3つのメソッドをクラス内で使用できます。
情報を格納するモデル
Google Books APIからの情報を、オブジェクトとして格納するモデルを以下のように作ります。
class GoogleBook
include ActiveModel::Model
include ActiveModel::Attributes
include ActiveModel::Validations
attribute :googlebooksapi_id, :string
attribute :authors
attribute :image, :string
attribute :published_at, :date
attribute :title, :string
validates :googlebooksapi_id, presence: true
validates :title, presence: true
class << self
include GoogleBooksApi
def new_from_item(item)
@item = item
@volume_info = @item['volumeInfo']
new(
googlebooksapi_id: @item['id'],
authors: @volume_info['authors'],
image: image_url,
published_at: @volume_info['publishedDate'],
title: @volume_info['title'],
)
end
def new_from_id(googlebooksapi_id)
url = url_of_creating_from_id(googlebooksapi_id)
item = get_json_from_url(url)
new_from_item(item)
end
def search(keyword)
url = url_of_searching_from_keyword(keyword)
json = get_json_from_url(url)
items = json['items']
return [] unless items
items.map do |item|
GoogleBook.new_from_item(item)
end
end
private
def image_url
@volume_info['imageLinks']['smallThumbnail'] if @volume_info['imageLinks'].present?
end
end
def save
return false unless valid?
book = build_book
return false unless book.valid?
ActiveRecord::Base.transaction do
book.remote_image_url = image if image.present?
book.save
authors.each.with_index do |author, index|
author = book.authors.build(name: author)
author.is_representation = index.zero?
author.save
end
end
true
end
def find_book_or_save
if Book.find_by(googlebooksapi_id: googlebooksapi_id) || save
Book.find_by(googlebooksapi_id: googlebooksapi_id)
else
false
end
end
private
def build_book
Book.new(
googlebooksapi_id: googlebooksapi_id,
published_at: published_at,
title: title,
)
end
end
長くなりました笑
一つ一つ解説していきます。
ActiveModel
include ActiveModel::Model
include ActiveModel::Attributes
include ActiveModel::Validations
ActiveModelとは「データベースと連携しないActiveRecord」みたいなものです。
ActiveModelのattribute
attribute :googlebooksapi_id, :string
attribute :authors
(以下略)
ActiveModel::Attributes
をinclude
したので使えます。
authors
には配列を入れることを想定していますが、配列に対応するattributeの型は無いようなので、この書き方にしています。
ActiveModel::Attributes が最高すぎるんだよな。
ActiveModelのvalidates
validates :googlebooksapi_id, presence: true
validates :title, presence: true
Google Books APIのIDが無い本、タイトルの存在しない本は存在しない(と思われる)ので、validetes
を入れておきます。
仮にGoogle Books APIのIDが無い本がオブジェクトとして格納された場合、valid?
メソッドを使ったときにfalse
を返すことができます。(後述しますが、save
メソッドにて使用します)
クラスメソッドの定義方法
class << self
new_from_id
はGoogleBook.new_from_id('c1L4IzmUzicC')
のようにクラスメソッドとして使いたいメソッドです。クラスメソッドを定義する方法は他にもありますが、class << slef
によるやり方が推奨なようです。
Rubyのクラスメソッドをclass << selfで定義している理由(翻訳)
IDからインスタンスを生成する
def new_from_id(googlebooksapi_id)
url = url_of_creating_from_id(googlebooksapi_id)
item = get_json_from_url(url)
new_from_item(item)
end
順番がちょっと前後しますが、new_from_item
より先にnew_from_id
を説明します。
前述のGoogleBooksApi
モジュールのurl_of_creating_from_id
およびget_json_from_url
を使用することで、一つの本の情報(JSON)をitem
として取得します。
そのitemをnew_from_item
に渡します。
item
からインスタンスを生成する
def new_from_item(item)
@item = item
@volume_info = @item['volumeInfo']
new(
googlebooksapi_id: @item['id'],
authors: @volume_info['authors'],
image: image_url,
published_at: @volume_info['publishedDate'],
title: @volume_info['title'],
)
end
Google Books APIの中身を観察すると分かりますが、多くの情報はitem['volumeInfo']
に入っています。
取り出したい情報を上記のように適宜定義し、new()
を使ってインスタンスを生成する、というロジックにします。
image_url
だけ下記のやり方で実装しています。
image_url
の実装
private
def image_url
@volume_info['imageLinks']['smallThumbnail'] if @volume_info['imageLinks'].present?
end
本によってはvolume_info['imageLinks']
が入っていない本が存在するため、そのままvolume_info['imageLinks']['smallThumbnail']
だけで使おうとするとundefind
のエラーが出てしまうことがあります。
undefind
を出さないようにするため、上記のように実装します。
また、このimage_url
メソッドはクラス外で使用することは考えられないので、private
下で定義します。
検索結果をインスタンス群として返すクラスメソッド
def search(keyword)
url = url_of_searching_from_keyword(keyword)
json = get_json_from_url(url)
items = json['items']
return [] unless items
items.map do |item|
GoogleBook.new_from_item(item)
end
end
Google Books APIのうち、検索結果群を返してくれるAPIを使った場合、返ってくるのは複数のitems
となります。
items
は配列として[item1, item2, item3, ...]
という形になっています。
map
メソッド内で一つ一つnew_from_item(item)
をすることで、[googlebook1, googlebook2, googlebook3, ...]
という配列の形で返すことができます。
不適切なキーワードだと検索結果群を返してくれるAPIでitems
が返ってこないため、その場合もundefind
のエラーが発生してしまいます。
そこでreturn [] unless items
の一行を入れることで、items
が無い場合は空の配列を返すようにします。
まだsave
メソッド、find_book_or_save
メソッドの説明が残っていますが、先にControllerでの使い方を見るほうが分かりやすいと思うので、Controllerの説明に移りたいと思います。
Controllerでの使用例
GoogleBookクラスを使うのは、「検索画面」と「リソース登録(create)」の2つかと思います。
ルーティング
resources :books, only: %i[create show], shallow: true do
collection do
get :search
end
end
あらかじめ、上記のようなルーティングは設定しておきます。
本筋とズレてしまうので説明は省きます。
検索画面のController
class BooksController < ApplicationController
def search
@search_form = SearchBooksForm.new(search_books_params)
books = GoogleBook.search(@search_form.keyword)
@books = Kaminari.paginate_array(books).page(params[:page])
end
private
def search_books_params
params.fetch(:q, keyword: '').permit(:keyword)
end
end
弊記事で恐縮ですが、検索フォームは以下の記事のやり方で実装します。
【Ruby on Rails】フォームオブジェクトを使って検索キーワードをcontrollerに送る
受け取ることのできた検索キーワード(@search_form.keyword
)をGoogleBook.search
に渡して、検索結果をGoogleBook
クラスから生成したインスタンス群の配列として受け取ります。
以降は任意ですが、私は検索結果をkaminariのページネーションで表示したかったため、Kaminari.paginate_array
に渡して使用しました。
リソース登録のController ver.1(今までのメソッドのみで書くNGな例)
やりたいこととしては以下の通りです。
-
GoogleBook
モデルに持っていた情報をBook
モデルやAuthor
モデルに格納し直す - ヴァリデーションをかける
- ヴァリデーション成功時にはDBに保存する
class BooksController < ApplicationController
def create
google_book = GoogleBook.new_from_id(create_book_params[:googlebooksapi_id])
@book = Book.build(
googlebooksapi_id: google_book.googlebooksapi_id,
published_at: google_book.published_at,
title: google_book.title,
)
if @book.valid?
@book.remote_image_url = google_book.image if google_book.image.present?
@book.save
google_book.authors.each.with_index do |author, index|
@author = @book.authors.build(name: author)
@author.is_representation = index.zero?
@author.save
end
redirect_to @book
else
redirect_to search_books_path, danger: 'ページの表示に失敗しました'
end
end
private
def create_book_params
params.permit(:googlebooksapi_id)
end
end
しかし、上記の実装には色々な問題が発生しています。
- Fat Controllerになってしまっている
- 重複した本を弾けていない
- 複数リソースへのデータ登録なのに、
ActiveRecord::Base.transaction
を使えていない
特にFat Controllerの問題はなんとかしなくてはいけないです。
そもそも、このDBへの保存に関する処理はGoogleBook
モデルが責務を持つ処理かと思います。
よって、保存に関するロジックはGoogleBook
のインスタンスメソッドとして定義することにします。
複数テーブルへの保存を実現する、save
メソッド
def save
return false unless valid?
book = build_book
ActiveRecord::Base.transaction do
book.remote_image_url = image if image.present?
book.save
if authors.present?
authors.each.with_index do |author, index|
author = book.authors.build(name: author)
author.is_representative = index.zero?
author.save
end
end
end
true
end
private
def build_book
Book.new(
googlebooksapi_id: googlebooksapi_id,
published_at: published_at,
title: title,
)
end
ActiveRecord
のsave
メソッド風に仕上げるため、保存に成功すればtrue
、失敗すればfalse
を返すようにしたいです。
そこで、return false unless valid?
の一行で、valid?
で失敗したときにはfalse
を返すようにしました。
また、成功時の最後にtrue
の一行を入れることで、成功時にはtrue
を返すことができます。
ActiveRecord::Base.transaction
で囲うことで、複数リソース登録時で途中で失敗したときにロールバックを走らせることができます。
この場合では、何かの不具合で後半のauthor.save
で失敗したとしても、前半のbook.save
を取り止めにすることができます。
book.remote_image_url = image if image.present?
はCarrierwaveで画像のアップロードを行うロジックになります。本筋とズレるので、今回は説明は省きます。
author.is_representative = index.zero?
は、authors
配列のうち、最初のインデックスにある著者を「代表著者」とするための一行です。
each.with_index
を使って配列を回してるのもこれが理由です。
リソース登録のController ver.2(重複の登録をしてしまう)
class BooksController < ApplicationController
def create
google_book = GoogleBook.new_from_id(create_book_params[:googlebooksapi_id])
if google_book.save
@book = Book.find_by(googlebooksapi_id: google_book.googlebooksapi_id)
redirect_to @book
else
redirect_to search_books_path, danger: 'ページの表示に失敗しました'
end
end
private
def create_book_params
params.permit(:googlebooksapi_id)
end
end
かなりスッキリしましたが、まだ問題が残っています。
Google Books APIのIDが重複した、すなわち同じ本を登録してしまう可能性があります。
find_or_create_by
風味な実装
実現したいのは以下のことです。
- すでにbooksテーブルにその本があるなら、そのレコードに対応したモデルを返す
- なければ、
save
メソッドを実行して、新しく作ったレコードに対応したモデルを返す - booksテーブルにその本が無く、
save
メソッドが失敗するなら、false
を返す
ActiveRecord
で言えば、find_or_create_by
に近い挙動を実現したいことになります。
GoogleBook
のインスタンスメソッドとして、以下のようにfind_book_or_save
メソッドとして実装します。
def find_book_or_save
if Book.find_by(googlebooksapi_id: googlebooksapi_id) || save
Book.find_by(googlebooksapi_id: googlebooksapi_id)
else
false
end
end
リソース登録のController ver.3(完成)
class BooksController < ApplicationController
def create
google_book = GoogleBook.new_from_id(create_book_params[:googlebooksapi_id])
if (@book = google_book.find_book_or_save)
redirect_to @book
else
redirect_to search_books_path, danger: 'ページの表示に失敗しました'
end
end
private
def create_book_params
params.permit(:googlebooksapi_id)
end
end
RSpecでテストを書く
今回扱うテストは3種類とします。
- Google Books APIを叩くメソッドのテスト
-
GoogleBook
のモデルテスト - 本の登録時のリクエストスペック
テストの書き方については若干自信が無いため、ご指摘お待ちしております笑
Google Books APIを叩くメソッドのテスト
require 'rails_helper'
describe GoogleBooksApi do
let(:test_class) { Struct.new(:google_books_api) { include GoogleBooksApi } }
let(:google_books_api) { test_class.new }
it '検索するAPIを叩き、複数のデータを返すkindが取得できること' do
url = google_books_api.url_of_searching_from_keyword('Rails')
expect(google_books_api.get_json_from_url(url)['kind']).to eq 'books#volumes'
end
it 'IDから本の情報を取得するAPIを叩き、特定データを返すkindが取得できること' do
GOOGLE_BOOKS_API_ID_SAMPLE = 'aB4B13xGEv4C'.freeze
url = google_books_api.url_of_creating_from_id(GOOGLE_BOOKS_API_ID_SAMPLE)
expect(google_books_api.get_json_from_url(url)['kind']).to eq 'books#volume'
expect(google_books_api.get_json_from_url(url)['id']).to eq GOOGLE_BOOKS_API_ID_SAMPLE
end
end
モジュールのテストをするやり方に関しては以下サイトを参考にさせて頂きました。
【Ruby on Rails】初心者でもメソッドの単体テストがしたい!!
GoogleBook
のモデルテスト
まずはFactoryBotを定義しておきます。
FactoryBot.define do
factory :google_book do
googlebooksapi_id { 'wlNHDwAAQBAJ' }
authors do
[
'太田 智彬',
'寺下 翔太',
'手塚 亮',
'宗像 亜由美',
'株式会社リクルートテクノロジーズ'
]
end
image { 'http://books.google.com/books/content?id=wlNHDwAAQBAJ&printsec=frontcover&img=1&zoom=5&edge=curl&imgtk=AFLRE70j5lrdzOYN-iUu8w-G_JJKpEhnpUGAgqyZd7rj4jHu59NcAU48eQ75T4fkdyyZD6dMlwjjw0sAdQSKY_HiEdNBMMeyDn4DUmOcY-oLHFRAnxPXocc_T_PA7NYdSlZdwKckhCMy&source=gbs_api' }
published_at { '2018-01-24' }
title { 'Ruby on Rails 5の上手な使い方 現場のエンジニアが教えるRailsアプリケーション開発の実践手法' }
end
end
現場Railsです。
次にモデルテストの本体です。
require 'rails_helper'
RSpec.describe GoogleBook, type: :model do
it '有効なファクトリを持つこと' do
google_book = build(:google_book)
expect(google_book).to be_valid
end
it 'Google Books APIのIDが存在しないときに無効なこと' do
google_book = build(:google_book, googlebooksapi_id: nil)
google_book.valid?
expect(google_book.errors.messages[:googlebooksapi_id]).to include('を入力してください')
end
it 'タイトルが存在しないときに無効なこと' do
google_book = build(:google_book, title: nil)
google_book.valid?
expect(google_book.errors.messages[:title]).to include('を入力してください')
end
it 'Google Books APIのIDから目的のインスタンスを生成できること' do
googlebooksapi_id = 'YEfUBgAAQBAJ'
google_book = GoogleBook.new_from_id(googlebooksapi_id)
expect(google_book.title).to eq 'SpriteKitではじめる2Dゲームプログラミング Swift対応'
expect(google_book.googlebooksapi_id).to eq googlebooksapi_id
expect(google_book.authors).to eq %w[山下佳隆 村田知常 原知愛 近藤秀彦]
expect(google_book.author).to eq '山下佳隆'
end
it '適切なキーワードから複数の検索結果を返し、そのタイトルにキーワードが含まれていること' do
keyword = 'Ruby'
keyword_count = 0
google_books = GoogleBook.search(keyword)
expect(google_books.size).to be >= 5 # 検索結果を5個以上は返せる
google_books.each do |google_book|
if google_book.title.include?(keyword)
keyword_count += 1
end
end
expect(keyword_count).to be >= 5 # キーワードのRubyを含むタイトルが5個以上は返せる
end
it '不適切なキーワードからは検索結果を返さないこと' do
keyword = 'bbvjnaovnaov' # 適当
google_books = GoogleBook.search(keyword)
expect(google_books.size).to be 0
end
describe '保存時に' do
context '不適切な情報しか持たないときは' do
let(:google_book) { build(:google_book, googlebooksapi_id: nil) }
it '保存に失敗すること' do
expect { google_book.save }.to change { Book.count }.by(0).and change { Author.count }.by(0)
end
it 'falseを返すこと' do
expect(google_book.save).not_to be_truthy
end
end
context '適切な情報を持っているときは' do
let(:google_book) { build(:google_book, authors: [
'太田 智彬',
'寺下 翔太',
'手塚 亮',
'宗像 亜由美',
'株式会社リクルートテクノロジーズ'
])
}
it '保存できること' do
expect { google_book.save }.to change { Book.count }.by(1).and change { Author.count }.by(5)
end
it 'trueを返すこと' do
expect(google_book.save).to be_truthy
end
end
context '著者の情報だけを持っていないときにも' do
let(:google_book) { build(:google_book, authors: nil) }
it '保存できること' do
expect { google_book.save }.to change { Book.count }.by(1).and change { Author.count }.by(0)
end
it 'trueを返すこと' do
expect(google_book.save).to be_truthy
end
end
end
end
ヴァリデーションと、それぞれのメソッドをテストしただけなので、あまり説明は要らないかと思います。
本の登録時のリクエストスペック
require 'rails_helper'
RSpec.describe 'Books', type: :request do
it '適切なGoogle Books APIのIDである本が登録できること' do
expect {
post '/books', params: { googlebooksapi_id: 'xPbRxgEACAAJ' }
}.to change { Book.count }.by(1)
expect(response).to redirect_to book_path(Book.last.id)
end
it '既に登録されている本の登録に失敗し、その詳細画面に遷移すること' do
same_google_books_api_id = 'xPbRxgEACAAJ'
create(:book, googlebooksapi_id: same_google_books_api_id)
expect {
post '/books', params: { googlebooksapi_id: same_google_books_api_id }
}.to change { Book.count }.by(0)
expect(response).to redirect_to book_path(Book.find_by(googlebooksapi_id: same_google_books_api_id))
end
end
対応するControllerは「リソース登録のController ver.3(完成)」のものです。
テストは説明することが少なくて楽で良いですね
以上になります。
改善していければと思っておりますので、間違ってる箇所や改善すべき箇所があればご指摘いただけると嬉しいです。