LoginSignup
12
0

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

Last updated at Posted at 2023-12-11

はじめに

この記事は Elixirアドベントカレンダーのシリーズ4の11日目の記事です

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

Google Books APIについて

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

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

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
をそれぞれ実行しています

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通信部分を書きます

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を第1引数、リクエストパラメータをparamsをキーにキーワードリストで渡します

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

%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を開いてサービスを有効化しましょう

%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=-----

キーを追加したリクエストは以下のようになります

Req.get!(endpoint, params: [q: keyword, maxResults: 40, key: [your_api_key]])

成功すると以下のようなレスポンスが返ってきますので、必要なデータだけ抜き出すようにします

%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を作成します

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オプションを入れないと前のデータが残ったままで、差し替えがされません

  @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コンポーネントはこちらを参考にします

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を追加します

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構造体に流し込みます

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をもとに削除します

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から保存した本のデータを検索結果の一覧から削除します

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

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

最後に

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

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

12
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
0