defmodule CobblemonUi.PokeApi do @moduledoc """ Fetches and caches Pokémon type data from PokeAPI. Uses an ETS table for fast lookups. Data is fetched once per species and cached for the lifetime of the process. """ use GenServer require Logger @table :poke_api_types_cache # --------------------------------------------------------------------------- # Client API # --------------------------------------------------------------------------- def start_link(_opts) do GenServer.start_link(__MODULE__, [], name: __MODULE__) end @doc """ Returns a list of type names (lowercase strings) for the given species. Example: `get_types("bulbasaur")` → `["grass", "poison"]` """ @spec get_types(String.t()) :: [String.t()] def get_types(species) when is_binary(species) do key = String.downcase(species) case :ets.lookup(@table, key) do [{^key, types}] -> types [] -> GenServer.call(__MODULE__, {:fetch_types, key}, 15_000) end end # --------------------------------------------------------------------------- # Server callbacks # --------------------------------------------------------------------------- @impl true def init(_opts) do :ets.new(@table, [:named_table, :set, :public, read_concurrency: true]) {:ok, %{}} end @impl true def handle_call({:fetch_types, species}, _from, state) do result = case :ets.lookup(@table, species) do [{^species, types}] -> types [] -> types = fetch_types(species) :ets.insert(@table, {species, types}) types end {:reply, result, state} end # --------------------------------------------------------------------------- # Private helpers # --------------------------------------------------------------------------- defp fetch_types(species) do api_name = CobblemonUi.PokeApiNames.normalize(species) case fetch_types_direct(api_name) do {:ok, types} -> types :not_found -> # The /pokemon/ endpoint requires the specific form name (e.g. # "aegislash-shield"). Fall back through the /pokemon-species/ # endpoint to resolve the default variety. fetch_types_via_species(api_name) end end defp fetch_types_direct(api_name) do url = "https://pokeapi.co/api/v2/pokemon/#{api_name}" case Req.get(url) do {:ok, %Req.Response{status: 200, body: %{"types" => types}}} -> parsed = types |> Enum.sort_by(fn t -> t["slot"] end) |> Enum.map(fn t -> get_in(t, ["type", "name"]) end) |> Enum.reject(&is_nil/1) {:ok, parsed} {:ok, %Req.Response{status: 404}} -> :not_found {:ok, %Req.Response{status: status}} -> Logger.warning("[PokeApi] Type lookup failed for #{api_name}: HTTP #{status}") {:ok, []} {:error, reason} -> Logger.warning("[PokeApi] Type lookup failed for #{api_name}: #{inspect(reason)}") {:ok, []} end end defp fetch_types_via_species(api_name) do url = "https://pokeapi.co/api/v2/pokemon-species/#{api_name}" with {:ok, %Req.Response{status: 200, body: body}} <- Req.get(url), %{"varieties" => varieties} <- body, %{"pokemon" => %{"url" => pokemon_url}} <- Enum.find(varieties, fn v -> v["is_default"] end) do case Req.get(pokemon_url) do {:ok, %Req.Response{status: 200, body: %{"types" => types}}} -> types |> Enum.sort_by(fn t -> t["slot"] end) |> Enum.map(fn t -> get_in(t, ["type", "name"]) end) |> Enum.reject(&is_nil/1) _ -> Logger.warning("[PokeApi] Fallback type lookup failed for #{api_name}") [] end else _ -> Logger.warning("[PokeApi] Species fallback failed for #{api_name}") [] end end end