—— How to create a Rails portfolio for books --How to create a Rails portfolio using the Google Books API
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
=> "Practice Rails"
pry(main)> @google_books.last.authors
=> ["Sam Ruby", "Dave Thomas", "David Heinemeier Hansson"]
pry(main)> @google_books.map { |google_book| google_book.title }
=> ["Practice Rails",
"Self-study Ruby on Rails",
"Rails recipe",
"Ajax on Rails",
"Ruby on Rails 4 Application Programming",
"How to use Ruby on Rails 5 Practical method of Rails application development taught by field engineers",
"Ruby on Rails 5 Super Primer",
"Rails Agile Web Application Development 3rd Edition",
"JRuby on Rails Practical Development Guide",
"Rails Agile Web Application Development 4th Edition"]
pry(main)> @google_books.class
=> Array
pry(main)> @google_book = GoogleBook.new_from_id('wlNHDwAAQBAJ')
pry(main)> @google_book.title
=> "How to use Ruby on Rails 5 Practical method of Rails application development taught by field engineers"
pry(main)> @google_book.authors
=> ["Tomoaki Ota", "Shota Terashita", "Ryo Tezuka", "Munakata Ayumi", "Recruit Technologies Co., Ltd."]
pry(main)> @google_book.save
=> true
pry(main)> @book = Book.last
pry(main)> @book.title
=> "How to use Ruby on Rails 5 Practical method of Rails application development taught by field engineers"
pry(main)> @book.authors[0].name
=> "Tomoaki Ota"
pry(main)> @book.authors.size
=> 5
pry(main)> @book.authors.class
=> Author::ActiveRecord_Associations_CollectionProxy
pry(main)> @author = Author.last
pry(main)> @author.name
=> "Recruit Technologies Co., Ltd."
Create a Google Book model that stores information from the Google Books API. There are two major advantages:
--The information received from the Google Books API can be handled intuitively with Controller and View. --Logics such as acquiring information / storing and organizing information / saving across multiple tables can be written separately, and each can be tested.
This article also describes usage examples and tests with Controller.
The database stores the following information about the book:
--"Title" --"Author" --"Image URL" --"Publishing date" --"Google Books API ID"
Of course, any other information found in the Google Books API can be retrieved and saved. For the sake of explanation in this article, the amount of information to be acquired is small.
Prepare the Books table and the Authors table. Since there may be multiple authors for one book, we will use the relationship of book has_many authors.
However, you may want to show the representative authors instead of all the authors on a page like "List books". Therefore, prepare the ʻis_representative` column in the authors table.
You might also wonder, "If you want to get information from the Google Books API, you don't need to have it in your own database?" The story of the design and failure is listed below. Designed to acquire this resource only with GoogleBooks API and failed
In summary, the conclusion is that the information in the book should be kept in its own database as well.
If you create a migration file, it will look like the following.
db/migrate/20202020202020_create_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
The Google Books API ID is always required, so specify null: false
.
Also, since it is unlikely that the Google Books API ID will be duplicated, give it a unique key.
Books that do not have a title are considered non-existent, so specify null: false
.
Conversely, be careful when specifying null: false
for other information.
This is because the information comes from an external API, and some books may not have that information, which can cause a situation where "cannot be registered in the DB".
db/migrate/20202020202021_create_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
First, add a module that hits the API under ʻapp / lib /`.
app/lib/google_books_api.rb
module GoogleBooksApi
def url_of_creating_from_id(googlebooksapi_id)
"https://www.googleapis.com/books/v1/volumes/#{googlebooksapi_id}"
end
#Get the API URL from the Google Books API ID
def url_of_searching_from_keyword(keyword)
"https://www.googleapis.com/books/v1/volumes?q=#{keyword}&country=JP"
end
#Get the URL of the API to search from the keyword
def get_json_from_url(url)
JSON.parse(Net::HTTP.get(URI.parse(Addressable::URI.encode(url))))
end
#Get the JSON string from the URL and build the JSON object
end
There are two types of Google Books API used in this article.
--Returns information about one book from ID
https://www.googleapis.com/books/v1/volumes/:ID
You can get it by the URL.
The following URL is an example.
https://www.googleapis.com/books/v1/volumes/aB4B13xGEv4C
If you don't know what information you can get from the Google Books API, check out the URL above. You can see that you can get titles, publication dates, purchase links, ISBNs, and more.
--Returns search results from keywords
https://www.googleapis.com/books/v1/volumes?q=search ?: Keyword
You can get it by the URL.
The following URL is an example.
https://www.googleapis.com/books/v1/volumes?q=search?Rails
If you want to know other specifications of Google Books API, please refer to the official document. Getting Started
I'm using a gem called ʻaddressableto escape the URL. Please add the following gem to Gemfile and
bundle install`.
Gemfile
gem 'addressable'
By the way, Rails automatically loads ʻapp / ** / **. Rb. Therefore, you can use the above three methods in the class as long as you do ʻinclude GoogleBooks Api
and ʻinclude` in the class you want to use.
Create a model that stores information from the Google Books API as an object as follows.
app/models/google_book.rb
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
It's been long lol I will explain each one.
ActiveModel
include ActiveModel::Model
include ActiveModel::Attributes
include ActiveModel::Validations
ActiveModel is like "ActiveRecord that doesn't work with database".
Active Model Basics-Rails Guide
attribute :googlebooksapi_id, :string
attribute :authors
(The following is omitted)
ʻActiveModel :: Attributes is ʻinclude
, so it can be used.
ʻAuthors` is supposed to contain an array, but it seems that there is no attribute type corresponding to the array, so I write it this way.
ActiveModel :: Attributes is too good.
validates :googlebooksapi_id, presence: true
validates :title, presence: true
There is no book without an ID of Google Books API and a book without a title (it seems), so put validetes
in it.
If a book without a Google Books API ID is stored as an object, you can return false
when using thevalid?
Method. (As described later, it is used in the save
method)
class << self
new_from_id
is the method you want to use as a class method, likeGoogleBook.new_from_id ('c1L4IzmUzicC')
. There are other ways to define class methods, but the class << slef
method seems to be recommended.
Reason for defining Ruby class method with class << self (translation)
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
The order is a little different, but I will explain new_from_id
before new_from_item
.
By using the GoogleBooksApi
module ʻurl_of_creating_from_id and
get_json_from_url mentioned above, you can get the information (JSON) of one book as ʻitem
.
Pass that item to new_from_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
As you can see by observing the contents of the Google Books API, a lot of information is in ʻitem ['volumeInfo']. Define the information you want to retrieve as appropriate as above, and use
new () to create an instance. Only ʻimage_url
is implemented in the following way.
private
def image_url
@volume_info['imageLinks']['smallThumbnail'] if @volume_info['imageLinks'].present?
end
Some books do not contain volume_info ['imageLinks']
, so if you try to use it with just volume_info ['imageLinks'] ['small Thumbnail']
, you will get an error of ʻundefind. there is. Implement as above to avoid issuing ʻundefind
.
Also, this ʻimage_url method is unlikely to be used outside the class, so define it under
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
If you use the Google Books API that returns search results, you will get multiple ʻitems. ʻItems
is in the form of[item1, item2, item3, ...]
as an array.
By doing new_from_item (item)
one by one in the map
method, it can be returned in the form of an array[googlebook1, googlebook2, googlebook3, ...]
.
If the keyword is inappropriate, the API that returns the search result group does not return ʻitems, so even in that case, the error ʻundefind
will occur.
Therefore, by inserting a line of return [] unless items
, if ʻitems` does not exist, an empty array will be returned.
The explanation of the save
method and the find_book_or_save
method still remains, but I think it is easier to understand how to use it in the Controller first, so I would like to move on to the explanation of the Controller.
I think there are two ways to use the GoogleBook class: "search screen" and "resource registration (create)".
config/routes.rb
resources :books, only: %i[create show], shallow: true do
collection do
get :search
end
end
Set the above routing in advance. I will omit the explanation because it will be out of sync with the main line.
app/controllers/books_controller.rb
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
Excuse me for this article, but I will implement the search form in the manner of the following article. [Ruby on Rails] Send search keywords to controller using form object
Pass the received search keyword (@ search_form.keyword
) to GoogleBook.search
and receive the search results as an array of instances generated from the GoogleBook
class.
After that, it's optional, but I wanted to display the search results with kaminari pagination, so I passed it to Kaminari.paginate_array
and used it.
Here's what I want to do.
--Restore the information that you had in the Google Book
model in the Book
model or the ʻAuthor` model
--Validation
--Save to DB when validation is successful
app/models/google_book.rb
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: 'Failed to display the page'
end
end
private
def create_book_params
params.permit(:googlebooksapi_id)
end
end
However, there are various problems with the above implementation.
--It has become a Fat Controller --I haven't played duplicate books --I can't use ʻActiveRecord :: Base.transaction` even though I'm registering data to multiple resources
Especially the problem of Fat Controller has to be dealt with.
In the first place, I think that the process related to saving to this DB is the process that the GoogleBook
model is responsible for.
Therefore, we will define the save logic as an instance method of GoogleBook
.
Save
method that realizes saving to multiple tablesapp/models/google_book.rb
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
I want to return true
if the save succeeds and false
if it fails so that it looks like the save
method of ʻActiveRecord. Therefore, in one line of
return false unless valid?,
false is returned when it fails with
valid? . You can also return
true on success by inserting a line of
true` at the end of success.
By enclosing it in ʻActiveRecord :: Base.transaction, you can run rollback when multiple resources are registered and fail in the middle. In this case, even if something goes wrong with ʻauthor.save
in the second half, you can cancel the book.save
in the first half.
book.remote_image_url = image if image.present?
is the logic for uploading images with Carrierwave. Since it deviates from the main line, I will omit the explanation this time.
ʻAuthor.is_representative = index.zero? Is a line to make the author in the first index of the ʻauthors
array the" representative author ".
This is also the reason why we use ʻeach.with_index` to rotate the array.
app/controllers/books_controller.rb
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: 'Failed to display the page'
end
end
private
def create_book_params
params.permit(:googlebooksapi_id)
end
end
It's pretty refreshing, but there are still problems. There is a possibility that the Google Books API ID will be duplicated, that is, the same book will be registered.
find_or_create_by
flavorful implementationHere's what we want to achieve:
--If the book is already in the books table, return the model corresponding to that record
--If not, execute the save
method and return the model corresponding to the newly created record.
--Returns false
if the books are not in the books table and the save
method fails
In terms of ʻActiveRecord, you want to achieve behavior similar to
find_or_create_by. As an instance method of
GoogleBook, implement it as
find_book_or_save` method as follows.
app/models/google_book.rb
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
app/controllers/books_controller.rb
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: 'Failed to display the page'
end
end
private
def create_book_params
params.permit(:googlebooksapi_id)
end
end
There are three types of tests to be dealt with this time.
--Testing the method of hitting the Google Books API
--Model test of Google Book
--Request specifications when registering a book
I'm a little unsure about how to write the test, so I'm looking forward to pointing out lol
spec/lib/google_books_api_spec.rb
require 'rails_helper'
describe GoogleBooksApi do
let(:test_class) { Struct.new(:google_books_api) { include GoogleBooksApi } }
let(:google_books_api) { test_class.new }
it 'You can get a kind that returns multiple data by hitting the API to search' 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 'You can get a kind that returns specific data by hitting the API to get book information from ID' 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
For how to test the module, I referred to the following site.
[Ruby on Rails] Even beginners want to unit test methods! !!
Google Book
model testFirst, define FactoryBot.
spec/factories/google_book.rb
FactoryBot.define do
factory :google_book do
googlebooksapi_id { 'wlNHDwAAQBAJ' }
authors do
[
'Tomoaki Ota',
'Shota Terashita',
'Ryo Tezuka',
'Munakata Ayumi',
'Recruit Technologies Co., Ltd.'
]
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 { 'How to use Ruby on Rails 5 Practical method of Rails application development taught by field engineers' }
end
end
On-site Rails. Next is the main body of the model test.
app/models/google_book_spec.rb
require 'rails_helper'
RSpec.describe GoogleBook, type: :model do
it 'Have a valid factory' do
google_book = build(:google_book)
expect(google_book).to be_valid
end
it 'Invalid when the Google Books API ID does not exist' do
google_book = build(:google_book, googlebooksapi_id: nil)
google_book.valid?
expect(google_book.errors.messages[:googlebooksapi_id]).to include('Please enter')
end
it 'Invalid when the title does not exist' do
google_book = build(:google_book, title: nil)
google_book.valid?
expect(google_book.errors.messages[:title]).to include('Please enter')
end
it 'Being able to generate the desired instance from the ID of the Google Books API' do
googlebooksapi_id = 'YEfUBgAAQBAJ'
google_book = GoogleBook.new_from_id(googlebooksapi_id)
expect(google_book.title).to eq '2D game programming starting with SpriteKit Swift support'
expect(google_book.googlebooksapi_id).to eq googlebooksapi_id
expect(google_book.authors).to eq %w[Yoshitaka Yamashita Tomotsune Murata Tomoai Hara Hidehiko Kondo]
expect(google_book.author).to eq 'Yoshitaka Yamashita'
end
it 'Returns multiple search results from the appropriate keywords and the title contains the keywords' do
keyword = 'Ruby'
keyword_count = 0
google_books = GoogleBook.search(keyword)
expect(google_books.size).to be >= 5 #Can return 5 or more search results
google_books.each do |google_book|
if google_book.title.include?(keyword)
keyword_count += 1
end
end
expect(keyword_count).to be >= 5 #Can return 5 or more titles including the keyword Ruby
end
it 'Do not return search results from inappropriate keywords' do
keyword = 'bbvjnaovnaov' #suitable
google_books = GoogleBook.search(keyword)
expect(google_books.size).to be 0
end
describe 'When saving' do
context 'When you have only inappropriate information' do
let(:google_book) { build(:google_book, googlebooksapi_id: nil) }
it 'Failed to save' do
expect { google_book.save }.to change { Book.count }.by(0).and change { Author.count }.by(0)
end
it 'Returning false' do
expect(google_book.save).not_to be_truthy
end
end
context 'When you have the right information' do
let(:google_book) { build(:google_book, authors: [
'Tomoaki Ota',
'Shota Terashita',
'Ryo Tezuka',
'Munakata Ayumi',
'Recruit Technologies Co., Ltd.'
])
}
it 'What can be saved' do
expect { google_book.save }.to change { Book.count }.by(1).and change { Author.count }.by(5)
end
it 'Returning true' do
expect(google_book.save).to be_truthy
end
end
context 'Even when you don't have only the author's information' do
let(:google_book) { build(:google_book, authors: nil) }
it 'What can be saved' do
expect { google_book.save }.to change { Book.count }.by(1).and change { Author.count }.by(0)
end
it 'Returning true' do
expect(google_book.save).to be_truthy
end
end
end
end
I've just tested the validation and each method, so I don't think it needs much explanation.
spec/requests/books_spec.rb
require 'rails_helper'
RSpec.describe 'Books', type: :request do
it 'Being able to register a book with the appropriate 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 'Failed to register a book that has already been registered and transition to the details screen.' 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
The corresponding Controller is that of "Resource Registration Controller ver.3 (Completed)". ~~ The test is easy and easy to explain ~~
That's it. I would like to improve it, so I would be grateful if you could point out any mistakes or areas that need improvement.
Recommended Posts