ElixirDesktop GoogleBooksAPIからの書籍データ取得、加工、保存

# はじめに
この記事は Elixirアドベントカレンダーのシリーズ４の11日目の記事です

10日目でGoogleBookAPIのデータ使用して保存する下準備をしたので、今度は実際にデータを使う所を解説します

# Google Books APIについて


APIはGoogle Books APIを使用します
本来はGoogleのアカウントに紐づいた自分の本棚を操作するAPIですが、
書籍検索に限ってAPI Key等が必要なく使用できるのでこちらを使用します

https://developers.google.com/books/docs/v1/using?hl=ja#WorkingVolumes

APIを叩くライブラリはReqを使用します

https://hexdocs.pm/req/readme.html

```diff_elixir:mix.exs
defmodule Bookshelf.MixProject do
  use Mix.Project

  ...
  defp deps do
    [
       ...
-      {:wx, "~> 1.1", hex: :bridge, targets: [:android, :ios]}
+      {:wx, "~> 1.1", hex: :bridge, targets: [:android, :ios]},
+      {:req, "~> 0.4.0"} 
    ]
  end
end
```

ライブラリを追加したら以下のコマンドを実行します

```
mix deps.get
```

# 検索フォームの追加

検索フォームを作成します、通常のformタグを使用して、phx-submitで`search`イベントを発火させます

初期値に取得したデータをいれるのに `assign :books`
表示制御用に `stream :books`
をそれぞれ実行しています

```diff_elixir:lib/bookshelf_web/live/search_live/index.ex
defmodule BookshelfWeb.SearchLive.Index do
  use BookshelfWeb, :live_view

  @impl true
  def render(assigns) do
    ~H"""
    <.gheader title="Search" />
-    <.link patch={~p"/search/new"}>
-      <.button>New Book</.button>
-    </.link>

+    <div class="min-h-[120px]">
+      <form id="book-form" class="flex w-full" phx-submit="search">
+        <div class="w-3/4">
+          <.input name="keyword" value="" type="text" />
+        </div>
+        <.button class="w-1/4 mt-2 ml-2 h-10" phx-disable-with="Searching...">Search</.button>
+      </form>
+    </div>
    <.bottom_tab title="Search" />
    """
  end

  @impl true
  def mount(_params, _session, socket) do
-   {:ok, socket}
+   {:ok,
+   socket
+   |> assign(:page_title, "Search Books")
+   |> assign(:books,[])
+   |> stream(:books, [])}
  end

+  @impl true
+  def handle_event("search", %{"keyword" => keyword}, socket) do
+    IO.inspect(keyword)
+    {:noreply, socket}
+  end
...
end
```

Searchボタンを押して入力したキーワードが取得できるのを確認できたら、API通信部分を書きます


```diff_elixir:lib/ebookworm_web/live/search_live/index.ex
  @impl true
  def handle_event("search", %{"keyword" => keyword}, socket) do
-   IO.inspect(keyword)
+   endpoint = "https://www.googleapis.com/books/v1/volumes"
+   data =
+     Req.get!(endpoint, params: [q: keyword, maxResults: 40])
+     |> IO.inspect()
+   {:noreply, socket}
  end
```

Reqの引数は、URLを第１引数、リクエストパラメータをparamsをキーにキーワードリストで渡します

APIの使用回数が増えてきたら、以下のような上限ですよとエラーが返ってきますのでparamsに`google developer console`のAPIキーを追加して再度実行します

```elixir
%Req.Response{
  status: 429,
  headers: %{...},
  body: %{
    "error" => %{
      "code" => 429,
      "details" => [
        %{
          "@type" => "type.googleapis.com/google.rpc.ErrorInfo",
          "domain" => "googleapis.com",
          "metadata" => %{
            "consumer" => "----",
            "quota_limit" => "defaultPerDayPerProject",
            "quota_limit_value" => "20000000",
            "quota_location" => "global",
            "quota_metric" => "books.googleapis.com/default",
            "service" => "books.googleapis.com"
          },
          "reason" => "RATE_LIMIT_EXCEEDED"
        },
        %{
          "@type" => "type.googleapis.com/google.rpc.Help",
          "links" => [
            %{
              "description" => "Request a higher quota limit.",
              "url" => "https://cloud.google.com/docs/quota#requesting_higher_quota"
            }
          ]
        }
      ],
      "errors" => [
        %{
          "domain" => "global",
          "message" => "Quota exceeded for quota metric 'Queries' and limit 'Queries per day' of service 'books.googleapis.com' for consumer 'project_number:----'.",
          "reason" => "rateLimitExceeded"
        }
      ],
      "message" => "Quota exceeded for quota metric 'Queries' and limit 'Queries per day' of service 'books.googleapis.com' for consumer 'project_number:---'.",
      "status" => "RESOURCE_EXHAUSTED"
    }
  },
  trailers: %{},
  private: %{}
}
```

APIキーを追加しても、Google Books APIが有効でない場合は以下のようなエラーが返ってきますので、該当のURLを開いてサービスを有効化しましょう


```elixir
%Req.Response{
  status: 403,
  headers: %{...},
  body: %{
    "error" => %{
      "code" => 403,
      "details" => [
        %{
          "@type" => "type.googleapis.com/google.rpc.Help",
          "links" => [
            %{
              "description" => "Google developers console API activation",
              "url" => "https://console.developers.google.com/apis/api/books.googleapis.com/overview?project=-----"
            }
          ]
        },
      ....
      "status" => "PERMISSION_DENIED"
    }
  },
  trailers: %{},
  private: %{}
}
```

このリンクをブラウザに貼り付けると自動的に有効化画面を開いてくれます

`https://console.developers.google.com/apis/api/books.googleapis.com/overview?project=-----`

キーを追加したリクエストは以下のようになります
```elixir
Req.get!(endpoint, params: [q: keyword, maxResults: 40, key: [your_api_key]])
```
成功すると以下のようなレスポンスが返ってきますので、必要なデータだけ抜き出すようにします

```elixir
%Req.Response{
  status: 200,
  headers: %{
    "alt-svc" => ["h3=\":443\"; ma=2592000,h3-29=\":443\"; ma=2592000"],
    "cache-control" => ["private"],
    "content-length" => ["134964"],
    "content-type" => ["application/json; charset=UTF-8"],
    "date" => ["Tue, 12 Sep 2023 06:48:18 GMT"],
    "server" => ["ESF"],
    "transfer-encoding" => ["chunked"],
    "vary" => ["Origin", "X-Origin", "Referer"],
    "x-content-type-options" => ["nosniff"],
    "x-frame-options" => ["SAMEORIGIN"],
    "x-xss-protection" => ["0"]
  },
  body: %{
    "items" => [
      %{
        "accessInfo" => %{
          "accessViewStatus" => "NONE",
          "country" => "JP",
          "embeddable" => false,
          "epub" => %{"isAvailable" => false},
          "pdf" => %{"isAvailable" => false},
          "publicDomain" => false,
          "quoteSharingAllowed" => false,
          "textToSpeechPermission" => "ALLOWED",
          "viewability" => "NO_PAGES",
          "webReaderLink" => "http://play.google.com/books/reader?id=UMV0tAEACAAJ&hl=&source=gbs_api"
        },
        "etag" => "+Dyt8/61Gqo",
        "id" => "UMV0tAEACAAJ",
        "kind" => "books#volume",
        "saleInfo" => %{
          "country" => "JP",
          "isEbook" => false,
          "saleability" => "NOT_FOR_SALE"
        },
        "searchInfo" => %{
          "textSnippet" => "眠り鬼・魘夢にヒノカミ神楽「碧羅の天」を放った炭治郎の戦いの顛末は!? さらに、炭治郎一行の下に現れたものの正体とは!? ..."
        },
        "selfLink" => "https://www.googleapis.com/books/v1/volumes/UMV0tAEACAAJ",
        "volumeInfo" => %{
          "allowAnonLogging" => false,
          "authors" => ["吾峠呼世晴"],
          "canonicalVolumeLink" => "https://books.google.com/books/about/%E9%AC%BC%E6%BB%85%E3%81%AE%E5%88%83_8.html?hl=&id=UMV0tAEACAAJ",
          "categories" => ["Brothers and sisters"],
          "contentVersion" => "preview-1.0.0",
          "description" => "In Taisho-era Japan, Tanjiro Kamado is a kindhearted boy who makes a living selling charcoal until his peaceful life is shattered when a demon slaughters his family and turns his sister into another demon, forcing Tanjiro on a dangerous journey to destroy the demon and save his sister.",
          "imageLinks" => %{
            "smallThumbnail" => "http://books.google.com/books/content?id=UMV0tAEACAAJ&printsec=frontcover&img=1&zoom=5&source=gbs_api",
            "thumbnail" => "http://books.google.com/books/content?id=UMV0tAEACAAJ&printsec=frontcover&img=1&zoom=1&source=gbs_api"
          },
          "industryIdentifiers" => [
            %{"identifier" => "4088812123", "type" => "ISBN_10"},
            %{"identifier" => "9784088812120", "type" => "ISBN_13"}
          ],
          "infoLink" => "http://books.google.co.jp/books?id=UMV0tAEACAAJ&dq=%E9%AC%BC%E6%BB%85%E3%81%AE%E5%88%83&hl=&source=gbs_api",
          "language" => "ja",
          "maturityRating" => "NOT_MATURE",
          "pageCount" => 192,
          "panelizationSummary" => %{
            "containsEpubBubbles" => false,
            "containsImageBubbles" => false
          },
          "previewLink" => "http://books.google.co.jp/books?id=UMV0tAEACAAJ&dq=%E9%AC%BC%E6%BB%85%E3%81%AE%E5%88%83&hl=&cd=1&source=gbs_api",
          "printType" => "BOOK",
          "publishedDate" => "2017-10",
          "readingModes" => %{"image" => false, "text" => false},
          "title" => "鬼滅の刃 8"
        }
      },
      ....
    ]
  }
}
```

タイトル、著者、ISBN、サムネイル、出版日を抜き出してMapを作成します

```diff_elixir:lib/bookshelf_web/live/search_live/index.ex
  def handle_event("search", %{"keyword" => keyword}, socket) do
    endpoint = "https://www.googleapis.com/books/v1/volumes"
    data =
      Req.get!(endpoint, params: [q: keyword, maxResults: 40])
+     |> Map.get(:body)
+     |> Map.get("items")
+     |> Enum.map(fn book ->
+       info = Map.get(book, "volumeInfo")
+
+       %{
+         id: book["id"],
+         title: info["title"],
+         authors: info["authors"],
+         isbn: info["industryIdentifiers"],
+         thumb: info["imageLinks"]["thumbnail"],
+         published_date: info["publishedDate"]
+       }
+     end)
-     |> IO.inspect()

    {:noreply, socket}
  end
```

データ不備があるものをEnum.filterで除外し、出版日でソートしてからstreamとassignでsocketにアサインします
streamには`reset: true`オプションを入れないと前のデータが残ったままで、差し替えがされません


```diff_elixir
  @impl true
  def handle_event("search", %{"keyword" => keyword}, socket) do
    endpoint = "https://www.googleapis.com/books/v1/volumes"

    data =
      Req.get!(endpoint, params: [q: keyword, maxResults: 40])
      |> Map.get(:body)
      |> Map.get("items")
      |> Enum.map(fn book -> 
        ...
      end) 
+     |> Enum.filter(&(!is_nil(&1.thumb)))
+     |> Enum.filter(&(!is_nil(&1.authors)))
+     |> Enum.filter(&(!is_nil(&1.published_date)))
+     |> Enum.sort_by(& &1.published_date)

-    {:noreply, socket}
+    {:noreply,
+      socket
+      |> assign(:books, data)
+      |> stream(:books, data, reset: true)}
  end
```

# カードUIで検索結果を表示

取得したデータをアサインしたのでそのデータからカードUIを作成していきます

UIコンポーネントはこちらを参考にします

https://daisyui.com/components/card/#compact-card-less-padding-for-card-body

```diff_elixir:lib/bookshelf_web/live/search_live/index.ex
  @impl true
  def render(assigns) do
    ~H"""
    <.gheader title="Search" />

    <div class="min-h-[120px]">
      <form id="book-form" class="flex w-full" phx-submit="search">
        <div class="w-3/4">
          <.input name="keyword" value="" type="text" />
        </div>
        <.button class="w-1/4 mt-2 ml-2 h-10" phx-disable-with="Searching...">Search</.button>
      </form>
+     <div
+       id="books_list"
+       class="flex flex-wrap gap-4 mt-4"
+       phx-update={match?(%Phoenix.LiveView.LiveStream{}, @streams.books) && "stream"}
+     >
+     <%= for {id, book} <- @streams.books do %>
+       <div
+         id={id}
+         class="card bg-base-100 shadow-xl w-44 select-none"
+         phx-click={JS.patch(~p"/search/new?book_id=#{book.id}")}
+       >
+         <figure><img class="mt-1" src={book.thumb} /></figure>
+         <div class="p-2">
+           <p class="text-xs"><%= book.title %></p>
+           <p class="text-xs mt-2"><%= Enum.join(book.authors, ",") %></p>
+         </div>
+       </div>
+     <% end %>
+     </div>
    </div>
    <.bottom_tab title="Search" />
    """
  end
```

このコードはsteam関連のイベント(insert,delete)が発生したときに検知できるようにする設定です
保存した本のデータを検索結果の一覧から消すのに使用します
`phx-update={match?(%Phoenix.LiveView.LiveStream{}, @streams.books) && "stream"}`

カードをクリックしたら前回作成したフォームにデータを流し込みます

# 仮想フィールドの追加
fieldに`virtual: true`オプションをつけることで、データベースに保存する訳では無いが、保存する構造体と一緒にデータを渡したいfieldを追加することができます

保存後に検索結果一覧から削除するデータを参照するために`book_id`を追加します

```diff_elixir:lib/bookshelf/books/book.ex
defmodule Bookshelf.Books.Book do
  use Ecto.Schema
  import Ecto.Changeset

  schema "books" do
    field :author, :string
    field :isbn, :string
    field :published_date, :date
    field :thumb, :string
    field :title, :string
+   field :book_id, :string, virtual: true

    belongs_to :shelf, Bookshelf.Shelves.Shelf

    timestamps()
  end

  @doc false
  def changeset(book, attrs) do
    book
-   |> cast(attrs, [:title, :author, :thumb, :isbn, :published_date, :shelf_id])
+   |> cast(attrs, [:title, :author, :thumb, :isbn, :published_date, :shelf_id, :book_id])
    |> validate_required([:title, :author, :thumb, :isbn, :published_date, :shelf_id])
  end
end
```

# 入力フォームへのデータ流し込み

パラメーターとして渡された`book_id`で該当のデータを`books`から探し、`Book`構造体に流し込みます


```diff_elixir:lib/bookshelf_web/live/search_live/index.ex
defmodule BookshelfWeb.SearchLive.Index do
  use BookshelfWeb, :live_view

  ...
- defp apply_action(socket, :new, _params) do
+ defp apply_action(socket, :new, %{"book_id" => book_id}) do
+   book =
+     Enum.find(socket.assigns.books, &(&1.id == book_id))
+     |> then(
+       &%Book{
+         book_id: &1.id,
+         title: &1.title,
+         author: Enum.join(&1.authors, ","),
+         thumb: &1.thumb,
+         isbn: &1.isbn |> List.first() |> Map.get("identifier"),
+         published_date: date_normalization(&1.published_date)
+       }
+     )

    socket
    |> assign(:page_title, "New Book")
-   |> assign(:book, %Book{})
+   |> assign(:book, book) 
  end
  
+ def date_normalization(date) do
+   date
+   |> String.split("-")
+   |> length()
+   |> case do
+     2 -> date <> "-01"
+     3 -> date
+     _ -> nil
+   end
+ end
end
```

# 保存後にStreamsからデータを削除

保存後にnotify_parentが実行され、保存した本の情報をbook_idをもとに削除します

```diff_elixir:lib/bookshelf_web/live/book_live/form_component.ex
defmodule BookshelfWeb.BookLive.FormComponent do
  use BookshelfWeb, :live_component

  alias Bookshelf.Books.Book 
  alias Bookshelf.Shelves

  ...

  defp save_book(socket, :new, book_params) do
    case Books.create_book(book_params) do
-      {:ok, book} ->
-              notify_parent({:saved, book})
+      {:ok, _book} ->
+        notify_parent({:saved, socket.assigns.book})

        {:noreply,
         socket
         |> put_flash(:info, "Book created successfully")
         |> push_patch(to: socket.assigns.patch)}

      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign_form(socket, changeset)}
    end
  end
end
```

form_componentからのnotify_parentをhandle_infoで受け取ってstreamから保存した本のデータを検索結果の一覧から削除します

```diff_elixir:lib/bookshelf_web/live/search_live/index.ex
defmodule BookshelfWeb.SearchLive.Index do
  use BookshelfWeb, :live_view

  alias Bookshelf.Books.Book
  ...
+ @impl true
+ def handle_info({BookshelfWeb.BookLive.FormComponent, {:saved, book}}, socket) do
+   socket
+   |> stream_delete(:books, %{id: book.book_id})
+   |> assign(:book, nil)
+   |> then(&{:noreply, &1})
+ end
  ...
end
```

![0b9ba42a52f5349976ffa3098aeeaab5.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/64198/8282fb4e-49b9-ac56-b5cd-4e53f4d6f118.gif)

これでAPI通信から保存まで一通りできました

# 最後に
GoogleBooksAPIからデータを取得し、加工して保存するところまでを作成しました
LiveViewのStreamを使うことで、データの削除・追加が簡単にできるようになりました

次は保存した本の表示とフローティングアクションボタンを実装します