はじめに
この記事は Elixirアドベントカレンダーのシリーズ4の11日目の記事です
10日目でGoogleBookAPIのデータ使用して保存する下準備をしたので、今度は実際にデータを使う所を解説します
Google Books APIについて
APIはGoogle Books APIを使用します
本来はGoogleのアカウントに紐づいた自分の本棚を操作するAPIですが、
書籍検索に限ってAPI Key等が必要なく使用できるのでこちらを使用します
APIを叩くライブラリはReqを使用します
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
をそれぞれ実行しています
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通信部分を書きます
@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を作成します
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コンポーネントはこちらを参考にします
@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
を追加します
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
構造体に流し込みます
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をもとに削除します
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から保存した本のデータを検索結果の一覧から削除します
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
これでAPI通信から保存まで一通りできました
最後に
GoogleBooksAPIからデータを取得し、加工して保存するところまでを作成しました
LiveViewのStreamを使うことで、データの削除・追加が簡単にできるようになりました
次は保存した本の表示とフローティングアクションボタンを実装します