【Rails】Google Books APIsで本を検索して、DBに保存する方法

## 概要

Google Books APIsを使用してユーザーが本を検索して、検索結果から「よみたいに追加」ボタンを押すと検索結果をDBに保存するアプリを作成しました。試行錯誤しながら実装した実装方法を記録します。

完成版のイメージ

<img src="https://gyazo.com/4d18921a5568f73a3f59e1f31ebd64ca.gif">

## 実行環境
この記事は以下環境で動作を確認しました。
Ruby 3.1.2
Rails 6.1.7

## Google Books APIsとは？
名前の通りGoogleブックスにある情報を取得できるAPIです。
[開発者向けサイト](https://developers.google.com/books)

## Google Books APIsの使い方
Google Books APIsの使い方はシンプルです。

```
https://www.googleapis.com/books/v1/volumes?q=検索条件
```
「検索条件」に検索するための条件を入れてGETリクエストすると、JSON形式で情報が入手できます。

検索の最大件数（区切り）を設定したい場合はmaxResultsを追加します。
※「はらぺこあおむし」で最大10件とする場合。
```
https://www.googleapis.com/books/v1/volumes?q=はらぺこあおむし&maxResults=10
```
上記を実際にgetリクエストして取得した1件目のレスポンスが以下となります。
<details><summary>レスポンス</summary>

```
{
  "kind": "books#volumes",
  "totalItems": 654,
  "items": [
    {
      "kind": "books#volume",
      "id": "BhXmAQAACAAJ",
      "etag": "FY76Xe3RDuE",
      "selfLink": "https://www.googleapis.com/books/v1/volumes/BhXmAQAACAAJ",
      "volumeInfo": {
        "title": "はらぺこあおむし",
        "authors": [
          "Eric Carle"
        ],
        "publisher": "Kaiseisha/Tsai Fong Books",
        "publishedDate": "1976",
        "description": "Japanese edition of \"A Very Hungry Caterpillar.\" Distributed by Tsai Fong Books, Inc.",
        "industryIdentifiers": [
          {
            "type": "ISBN_10",
            "identifier": "4033280103"
          },
          {
            "type": "ISBN_13",
            "identifier": "9784033280103"
          }
        ],
        "readingModes": {
          "text": false,
          "image": false
        },
        "pageCount": 25,
        "printType": "BOOK",
        "categories": [
          "Butterflies"
        ],
        "averageRating": 4,
        "ratingsCount": 6,
        "maturityRating": "NOT_MATURE",
        "allowAnonLogging": false,
        "contentVersion": "preview-1.0.0",
        "panelizationSummary": {
          "containsEpubBubbles": false,
          "containsImageBubbles": false
        },
        "imageLinks": {
          "smallThumbnail": "http://books.google.com/books/content?id=BhXmAQAACAAJ&printsec=frontcover&img=1&zoom=5&source=gbs_api",
          "thumbnail": "http://books.google.com/books/content?id=BhXmAQAACAAJ&printsec=frontcover&img=1&zoom=1&source=gbs_api"
        },
        "language": "ja",
        "previewLink": "http://books.google.co.jp/books?id=BhXmAQAACAAJ&dq=%E3%81%AF%E3%82%89%E3%81%BA%E3%81%93%E3%81%82%E3%81%8A%E3%82%80%E3%81%97&hl=&cd=1&source=gbs_api",
        "infoLink": "http://books.google.co.jp/books?id=BhXmAQAACAAJ&dq=%E3%81%AF%E3%82%89%E3%81%BA%E3%81%93%E3%81%82%E3%81%8A%E3%82%80%E3%81%97&hl=&source=gbs_api",
        "canonicalVolumeLink": "https://books.google.com/books/about/%E3%81%AF%E3%82%89%E3%81%BA%E3%81%93%E3%81%82%E3%81%8A%E3%82%80%E3%81%97.html?hl=&id=BhXmAQAACAAJ"
      },
      "saleInfo": {
        "country": "JP",
        "saleability": "NOT_FOR_SALE",
        "isEbook": false
      },
      "accessInfo": {
        "country": "JP",
        "viewability": "NO_PAGES",
        "embeddable": false,
        "publicDomain": false,
        "textToSpeechPermission": "ALLOWED",
        "epub": {
          "isAvailable": false
        },
        "pdf": {
          "isAvailable": false
        },
        "webReaderLink": "http://play.google.com/books/reader?id=BhXmAQAACAAJ&hl=&source=gbs_api",
        "accessViewStatus": "NONE",
        "quoteSharingAllowed": false
      },
      "searchInfo": {
        "textSnippet": "Japanese edition of &quot;A Very Hungry Caterpillar.&quot; Distributed by Tsai Fong Books, Inc."
      }
    },
    {
      "kind": "books#volume",
      "id": "4-BgYgEACAAJ",
      "etag": "AO67oWMOtDk",
      "selfLink": "https://www.googleapis.com/books/v1/volumes/4-BgYgEACAAJ",
      "volumeInfo": {
        "title": "はらぺこあおむし",
        "subtitle": "ビッグブック",
        "authors": [
          "カール,E.(エリック)"
        ],
        "publishedDate": "1994",
        "industryIdentifiers": [
          {
            "type": "ISBN_10",
            "identifier": "4033211004"
          },
          {
            "type": "ISBN_13",
            "identifier": "9784033211008"
          }
        ],
        "readingModes": {
          "text": false,
          "image": false
        },
        "pageCount": 23,
        "printType": "BOOK",
        "categories": [
          "Butterflies"
        ],
        "maturityRating": "NOT_MATURE",
        "allowAnonLogging": false,
        "contentVersion": "preview-1.0.0",
        "language": "ja",
        "previewLink": "http://books.google.co.jp/books?id=4-BgYgEACAAJ&dq=%E3%81%AF%E3%82%89%E3%81%BA%E3%81%93%E3%81%82%E3%81%8A%E3%82%80%E3%81%97&hl=&cd=2&source=gbs_api",
        "infoLink": "http://books.google.co.jp/books?id=4-BgYgEACAAJ&dq=%E3%81%AF%E3%82%89%E3%81%BA%E3%81%93%E3%81%82%E3%81%8A%E3%82%80%E3%81%97&hl=&source=gbs_api",
        "canonicalVolumeLink": "https://books.google.com/books/about/%E3%81%AF%E3%82%89%E3%81%BA%E3%81%93%E3%81%82%E3%81%8A%E3%82%80%E3%81%97.html?hl=&id=4-BgYgEACAAJ"
      },
      "saleInfo": {
        "country": "JP",
        "saleability": "NOT_FOR_SALE",
        "isEbook": false
      },
      "accessInfo": {
        "country": "JP",
        "viewability": "NO_PAGES",
        "embeddable": false,
        "publicDomain": false,
        "textToSpeechPermission": "ALLOWED",
        "epub": {
          "isAvailable": false
        },
        "pdf": {
          "isAvailable": false
        },
        "webReaderLink": "http://play.google.com/books/reader?id=4-BgYgEACAAJ&hl=&source=gbs_api",
        "accessViewStatus": "NONE",
        "quoteSharingAllowed": false
      }
    },
    {

```
</details>

## 必要なJSONデータの確認

今回は取得できたJSONの内、書籍タイトル・著者・出版日・サムネ画像のURL・詳細リンクのURL・ISBNの6個を使用してDBにデータを保存します。必要なデータとJSONの格納場所が次の通りであることをJSONを見て確認しました。

| 必要な情報 | 格納場所 |
|:-:|:-:|
|書籍タイトル|items->volumeInfo->title|
|著者※1|items->volumeInfo->authors|
|出版日|items->volumeInfo->publishedDate|
|サムネ画像のURL|items->volumeInfo->imageLinks->thumbnail|
|詳細リンクのURL|items->volumeInfo->canonicalVolumeLink|
|ISBN※2|items->volumeInfo->industryIdentifiers|

※1　著者は複数の場合があるので以下のような形で配列で格納されています。

        "authors": [
          "Eric Carle"
        ],
※2 ISBNは10桁と13桁の2つがあるので以下のように格納されています。ISBNは[図書館API（カーリル）](https://calil.jp/doc/api_ref.html)を使用して図書館情報を表示するために使用します。こちらの実装については後日、別記事で記載します。

        "industryIdentifiers": [
          {
            "type": "ISBN_10",
            "identifier": "4033280103"
          },
          {
            "type": "ISBN_13",
            "identifier": "9784033280103"
          }

##  アプリの作成
このようなER図となるようモデル、コントローラーを作成します。（作成方法は割愛します）
<img src="https://gyazo.com/0b767fbdca1aa11f33825c928002e67a.png">
検索結果の内、著者は複数存在する場合があるので、authoursテーブルに保存して
bookテーブルは中間テーブルのbook_authorsauthoursを通して著者を参照します。
著者以外の検索結果は全てbooksテーブルに保存する方針としました。


| 格納するデータ |Google Books APIsの格納場所|保存場所|
|:-:|:-:|:-:|
|書籍タイトル|items->volumeInfo->title|books->title|
|著者|items->volumeInfo->authors|authors->name|
|出版日|items->volumeInfo->publishedDate|books->publishd_date|
|サムネ画像のURL|items->volumeInfo->imageLinks->thumbnail|books->image_link|
|詳細リンクのURL|items->volumeInfo->canonicalVolumeLink|books->info_link|
|ISBN|items->volumeInfo->industryIdentifiers|books->systemid|

##  gem Faradayの導入
Net::HTTPを使用してコードが煩雑になることを防ぐため、より簡潔にコードが記載できる[gemFaraday](https://github.com/lostisland/faraday)を使用してAPI通信をする方針としたため、Faradayを導入します。

Gemfileに以下を追加して
```ruby:Gemfile
gem 'faraday'
```

インストールします。
```
bundle install
```

## API通信の作成
ユーザーが入力した検索条件を`prams[:seach]`で受け取りnilまたはblankでなかった場合にリクエストを送るようにしています。
レスポンスはインスタンス変更の`@google_books`に代入してviewで使用できるようにします。
※検索フォームは後ほど作成します。
```ruby:app/controllers/books_controller.rb
def search 
    if params[:search].nil?
      return
    elsif params[:search].blank?
      flash.now[:danger] = '検索キーワードが入力されていません'
      return
    else
      url = "https://www.googleapis.com/books/v1/volumes"
      text = params[:search]
      res = Faraday.get(url, q: text, langRestrict: 'ja', maxResults: 20)
      @google_books = JSON.parse(res.body)
    end
  end
```
##  検索フォームの作成
ユーザーが検索条件を入力する検索フォームを作成します。

```ruby:app/views/books/search.html.erb
 <%= form_with url: search_books_path, local: true, method: :get do |f| %>
  <%= f.search_field :search, class: "form-control", value: params[:search] %>
  <%= f.submit %>
<% end %>
```
ルーティングの追加
```ruby:routes.rb
  resources :books do
    collection { get :search }#このルーティングを追加
  end
```

これで検索をすることはできるようになったので、取得した値を加工してVIEWに表示させ、ユーザーが登録ボタンを押した場合にDBに登録するよう実装していきます。

## application_helperで取得した値を加工する
インスタンス変数の`@google_books`にはGoogle Books APIsから取得した値がそのまま入っているため、アプリの形式に合わせたデータとなるようapplication_helperにメソッドを作り加工します。

```ruby:app/helpers/application_helper.rb
  def google_book_thumbnail(google_book)
    google_book['volumeInfo']['imageLinks'].nil? ? 'sample.jpg' : google_book['volumeInfo']['imageLinks']['thumbnail']
  end

  #thumbnailはネストしている配置となっているのでdigを使って取り出す
  #また画像のリンクがhttpとなっているためgsubを使いhttpsに変更する。変更した値をbookImageに代入する
  def set_google_book_params(google_book)
    google_book['volumeInfo']['bookImage'] = google_book.dig('volumeInfo', 'imageLinks', 'thumbnail')&.gsub("http", "https")

    #ISBNは13桁と10桁があり、どちら1つを取得できればよいので、最初に検索した値をsystemidに代入する
    if google_book['volumeInfo']['industryIdentifiers']&.select { |h| h["type"].include?("ISBN") }.present?
      google_book['volumeInfo']['systemid'] = google_book['volumeInfo']['industryIdentifiers']&.select { |h| h["type"].include?("ISBN") }.first["identifier"]
    end
     #volumeInfoの中が必要な項目のみになるようsliceを使って絞りこむ
    google_book['volumeInfo'].slice('title', 'authors', 'publishedDate', 'infoLink', 'bookImage', 'systemid', 'canonicalVolumeLink')
  end
```
- google_book_thumbnailメソッド
取得した値にサムネイル画像がなかった場合はsample.jpgを付与しています。
- set_google_book_paramsメソッド
必要な形式となるようデータを加工します。後ほどこのデータを使ってユーザーが登録ボタンを押したらコントローラーのcreateメソッドに値を送信するように実装します。

## 投稿フォームの作成
検索した本の情報を表示させ、ユーザーが「よみたいに追加」ボタンを押した場合に、先ほど作成した`set_google_book_params`の値を使用してコントローラーのcreateメソッドに値を送信するように実装します。

```ruby:app/views/books/search.html.erb

<% if @google_books.present? %>
    #検索した本の情報を表示する
    <% @google_books['items']&.each do |google_book| %>
        <%= image_tag google_book_thumbnail(google_book) %>
        <%= google_book['volumeInfo']['title'] %>
        <%= google_book['volumeInfo']['authors']　%>
        <%= google_book['volumeInfo']['publishedDate'] %>

        #よみたいに追加ボタンを押したらhidden_fieldを使用してbooksコントローラーのcreateに値が送信される
        <%= form_with model: @book, local: true, id: "new_book" do |f| %>
            <%= f.hidden_field :title, value: set_google_book_params(google_book)["title"] %>
            <%= f.hidden_field :published_date, value: set_google_book_params(google_book)["publishedDate"] %>
            <%= f.hidden_field :info_link, value: set_google_book_params(google_book)["canonicalVolumeLink"] %>
            <%= f.hidden_field :image_link, value: set_google_book_params(google_book)["bookImage"] %>
            <%= f.hidden_field :systemid, value: set_google_book_params(google_book)["systemid"] %>
            <% set_google_book_params(google_book)["authors"]&.each do |author| %>
                <%= hidden_field_tag 'book[authors][]', author %>
            <% end %>
            <%= button_tag type: "f.submit" do %>
               <i class="fa-regular fa-square-plus"></i>よみたいに追加
            <% end %>
        <% end %>
    <% end %>
<% end %>
```

## bookモデルに著者が保存されるメソッドを作成する
著者は配列で渡ってくるので、booksコントローラーのcreate時に合わせて保存されるようメソッドを作ります。このメソッドは次に作成するbooks_controllerのcreateメソッドで使用します。

```ruby:app/models/book.rb
  def save_with_author(authors)
    ActiveRecord::Base.transaction do
      self.save!
      self.authors = authors.uniq.reject(&:blank?).map { |name| Author.find_or_initialize_by(name: name.strip) } unless authors.nil?
    end
    true
    rescue StandardError
      false
  end
```
- save_with_authorメソッド
POSTコントローラーのceateメソッドを実行する時に
同時に実行してauthorsを保存するためのメソッド。

- uniq.reject(&:blank?)
一覧から重複削除してnilを除去。

- find_or_initialize_by
条件を指定して初めの1件を取得し1件もなければ作成する。
[Railsドキュメント](https://railsdoc.com/page/find_or_initialize_by)

- name.strip
文字列先頭と末尾の空白文字を全て取り除いた文字列を生成して返す。

## booksコントローラーの作成
ユーザーが「よみたいリストに追加」をした本の情報を受け取り保存します。

```ruby:app/controllers/books_controller.rb
  def create
    @book = current_user.books.build(book_params)
    #モデルに書いたsave_with_authorメソッドを実行する
    if @book.save_with_author(authors_params[:authors])
      redirect_to books_path, success: t('.success')
    else
      flash.now[:danger] = t('.fail')
    end
  end

  private  

  def book_params
    params.require(:book).permit(:title, :image_link, :info_link, :published_date, :systemid)
  end
  
  #authorsは配列で渡ってくるので、配受け取れるように記載
  def authors_params
    params.require(:book).permit(authors: [])
  end

```
これでDBに保存されるようになります。
保存した一覧を表示する場合はコントローラーにindexメソッドを作成して、VIEWで表示するようにします。

## 最後に
この記事の内容で実装した、絵本を検索して図書館情報を表示するサービス[「Kariteyomu」](https://www.kariteyomu.net/)を以下で公開しています。
よろしければこちらのアプリにもアクセスしてください！

https://www.kariteyomu.net/

https://github.com/gentaroooo/kariteyomu