diff --git a/README.md b/README.md index eff71b7c1..2761d32e6 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,35 @@ If you get an :eacces error that mentions `$PROJECT_DIR/_build/tailwind-linux-x6 ## API +### Developer API + +The versioned Developer API exposes the public aggregate data used by the Meta, +Archetype, and Decks pages. Sign in with Battle.net and create a key from +`/profile/settings`, then read the complete documentation at +`/api-docs`. + +```text +Authorization: Bearer hsg_live_. +``` + +The API also accepts the key in `X-API-Key`. Plaintext keys are shown once and +are never stored. Each key is independently rate limited; the default is 60 +requests per minute. + +Available endpoints: + +- `GET /api/v1/meta` +- `GET /api/v1/archetypes` +- `GET /api/v1/archetypes/:archetype` +- `GET /api/v1/decks` +- `GET /api/v1/streamers` +- `GET /api/v1/streamers/:twitch_login/decks` +- `GET /api/v1/streamer-decks` +- `GET /api/v1/streams/live` + +Only filters backed by public aggregate tables are available. Personal games, +`region`, and `force_fresh` remain outside the public v1 contract. + ### Resources #### Deck Info `archetype`: the deck archetype, without runes or XL diff --git a/config/config.exs b/config/config.exs index 97a35c918..6d0281601 100644 --- a/config/config.exs +++ b/config/config.exs @@ -70,6 +70,10 @@ config :backend, Backend.UserManager.Guardian, ttl: {60 * 60 * 24 * 365, :seconds}, secret_key: "CyjJAVTbtJgJwS+NbkbTpVTPDJeMKqcn+GakxrO4E5j/kB3SgcgF3CqfsxpxzQKM" +config :backend, :developer_api, + rate_limit: 60, + window_ms: :timer.minutes(1) + config :kaffy, otp_app: :backend, ecto_repo: Backend.Repo, diff --git a/lib/backend/api.ex b/lib/backend/api.ex index 3c620524f..6376794e7 100644 --- a/lib/backend/api.ex +++ b/lib/backend/api.ex @@ -5,10 +5,13 @@ defmodule Backend.Api do import Ecto.Query, warn: false alias Backend.Repo + alias Ecto.Multi import Torch.Helpers, only: [sort: 1, paginate: 4] import Filtrex.Type.Config alias Backend.Api.ApiUser + alias Backend.Api.DeveloperApiKey + alias Backend.UserManager.User @pagination [page_size: 15] @pagination_distance 5 @@ -177,4 +180,118 @@ defmodule Backend.Api do _ -> {:error, :unknown_error} end end + + @doc """ + Creates a new developer API key and revokes the user's previous key. + + The plaintext token is returned once and is never persisted. + """ + @spec create_developer_api_key(User.t()) :: + {:ok, %{api_key: DeveloperApiKey.t(), token: String.t()}} | {:error, term()} + def create_developer_api_key(%User{id: user_id}) do + token_prefix = "hsg_live_" <> random_token(9) + secret = random_token(32) + token = token_prefix <> "." <> secret + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + + attrs = %{ + user_id: user_id, + token_prefix: token_prefix, + token_digest: token_digest(secret) + } + + Multi.new() + |> Multi.run(:user, fn repo, _changes -> + case repo.one(from u in User, where: u.id == ^user_id, lock: "FOR UPDATE") do + %User{} = user -> {:ok, user} + nil -> {:error, :user_not_found} + end + end) + |> Multi.update_all( + :revoked_keys, + active_developer_api_keys_query(user_id), + set: [revoked_at: now, updated_at: now] + ) + |> Multi.insert(:api_key, DeveloperApiKey.changeset(%DeveloperApiKey{}, attrs)) + |> Repo.transaction() + |> case do + {:ok, %{api_key: api_key}} -> {:ok, %{api_key: api_key, token: token}} + {:error, _operation, reason, _changes} -> {:error, reason} + end + end + + @doc "Returns the user's active developer API key, if one exists." + @spec get_active_developer_api_key(User.t()) :: DeveloperApiKey.t() | nil + def get_active_developer_api_key(%User{id: user_id}) do + user_id + |> active_developer_api_keys_query() + |> Repo.one() + end + + @doc "Revokes the developer API key owned by the given user." + @spec revoke_developer_api_key(User.t()) :: :ok | {:error, term()} + def revoke_developer_api_key(%User{id: user_id}) do + now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + + Multi.new() + |> Multi.run(:user, fn repo, _changes -> + case repo.one(from u in User, where: u.id == ^user_id, lock: "FOR UPDATE") do + %User{} = user -> {:ok, user} + nil -> {:error, :user_not_found} + end + end) + |> Multi.update_all( + :revoked_keys, + active_developer_api_keys_query(user_id), + set: [revoked_at: now, updated_at: now] + ) + |> Repo.transaction() + |> case do + {:ok, _changes} -> :ok + {:error, _operation, reason, _changes} -> {:error, reason} + end + end + + @doc "Verifies an active developer API key and loads its owner." + @spec verify_developer_api_key(String.t()) :: + {:ok, DeveloperApiKey.t()} | {:error, :invalid_api_key} + def verify_developer_api_key(token) when is_binary(token) do + with {:ok, token_prefix, secret} <- parse_developer_api_key(token), + %DeveloperApiKey{} = api_key <- developer_api_key_by_prefix(token_prefix), + true <- Plug.Crypto.secure_compare(api_key.token_digest, token_digest(secret)) do + {:ok, api_key} + else + _ -> {:error, :invalid_api_key} + end + end + + def verify_developer_api_key(_), do: {:error, :invalid_api_key} + + defp active_developer_api_keys_query(user_id) do + from key in DeveloperApiKey, + where: key.user_id == ^user_id and is_nil(key.revoked_at) + end + + defp developer_api_key_by_prefix(token_prefix) do + from(key in DeveloperApiKey, + join: user in assoc(key, :user), + where: key.token_prefix == ^token_prefix and is_nil(key.revoked_at), + preload: [user: user] + ) + |> Repo.one() + end + + defp parse_developer_api_key("hsg_live_" <> _ = token) do + case String.split(token, ".", parts: 2) do + [token_prefix, secret] when secret != "" -> {:ok, token_prefix, secret} + _ -> {:error, :invalid_api_key} + end + end + + defp parse_developer_api_key(_), do: {:error, :invalid_api_key} + + defp random_token(bytes), + do: bytes |> :crypto.strong_rand_bytes() |> Base.url_encode64(padding: false) + + defp token_digest(secret), do: :crypto.hash(:sha256, secret) end diff --git a/lib/backend/api/decks.ex b/lib/backend/api/decks.ex new file mode 100644 index 000000000..67eeba728 --- /dev/null +++ b/lib/backend/api/decks.ex @@ -0,0 +1,375 @@ +defmodule Backend.Api.Decks do + @moduledoc "Query and serialization layer for the public developer deck feed." + + import Ecto.Query, warn: false + + alias Backend.Hearthstone.Deck + alias Backend.Repo + alias Hearthstone.DeckTracker + + @allowed_params ~w( + cursor format includes_latest_set limit min_games min_winrate opponent_class period + player_class player_deck_archetype player_deck_excludes player_deck_includes + player_has_coin rank + ) + @coin_values ~w(any yes no) + @default_limit 20 + @maximum_limit 100 + @default_min_games 200 + @minimum_min_games 50 + @maximum_list_size 25 + @maximum_archetype_length 100 + @maximum_integer 2_147_483_647 + + @spec latest(map()) :: {:ok, map()} | {:error, term()} + def latest(params) do + with :ok <- reject_unknown_params(params), + {:ok, format} <- format_param(params), + {:ok, period} <- period_param(params, format), + {:ok, rank} <- rank_param(params), + {:ok, limit} <- integer_param(params, "limit", @default_limit, 1, @maximum_limit), + {:ok, min_games} <- + integer_param( + params, + "min_games", + @default_min_games, + @minimum_min_games, + @maximum_integer + ), + {:ok, min_winrate} <- optional_winrate_param(params), + {:ok, player_has_coin} <- enum_param(params, "player_has_coin", "any", @coin_values), + {:ok, player_class} <- class_param(params, "player_class", nil), + {:ok, opponent_class} <- class_param(params, "opponent_class", "any"), + {:ok, archetypes} <- string_list_param(params, "player_deck_archetype"), + {:ok, includes} <- integer_list_param(params, "player_deck_includes"), + {:ok, excludes} <- integer_list_param(params, "player_deck_excludes"), + {:ok, includes_latest_set} <- latest_set_param(params), + {:ok, cursor} <- decode_cursor(Map.get(params, "cursor")) do + filters = %{ + "exclude_bugged_sources" => "yes", + "format" => format, + "limit" => limit, + "min_games" => min_games, + "opponent_class" => opponent_class, + "order_by" => "newest_deck", + "period" => period, + "player_has_coin" => player_has_coin, + "rank" => rank + } + + filters = + filters + |> put_optional("min_winrate", min_winrate) + |> put_optional("player_class", player_class) + |> put_optional("player_deck_archetype", archetypes) + |> put_optional("player_deck_includes", includes) + |> put_optional("player_deck_excludes", excludes) + |> put_optional("includes_latest_set", includes_latest_set) + + query_filters = + filters + |> Map.put("limit", limit + 1) + |> add_cursor(cursor) + + if DeckTracker.fresh_or_agg_deck_stats(query_filters) == :agg do + {:ok, build_page(query_filters, filters, limit)} + else + {:error, :filters_not_available} + end + end + end + + defp build_page(query_filters, filters, limit) do + stats = DeckTracker.deck_stats(query_filters) + has_more = length(stats) > limit + page_stats = Enum.take(stats, limit) + decks_by_id = decks_by_id(page_stats) + + decks = + Enum.flat_map(page_stats, fn stats -> + case Map.get(decks_by_id, value(stats, :deck_id)) do + %Deck{} = deck -> [serialize_deck(deck, stats)] + nil -> [] + end + end) + + next_cursor = + if has_more do + case List.last(decks) do + %{id: deck_id, created_at: inserted_at} -> encode_cursor(inserted_at, deck_id) + _ -> nil + end + end + + %{ + filters: filters, + decks: decks, + pagination: %{limit: limit, next_cursor: next_cursor} + } + end + + defp decks_by_id(stats) do + ids = Enum.map(stats, &value(&1, :deck_id)) + + from(deck in Deck, where: deck.id in ^ids) + |> Repo.all() + |> Map.new(&{&1.id, &1}) + end + + defp serialize_deck(deck, stats) do + archetype = Deck.archetype(deck) + + %{ + id: deck.id, + deckcode: deck.deckcode, + format: %{id: deck.format, name: Deck.format_name(deck.format)}, + class: Deck.class(deck), + archetype: archetype && to_string(archetype), + dust_cost: deck.cost, + cards: serialize_cards(deck.cards), + sideboards: serialize_sideboards(deck.sideboards), + created_at: NaiveDateTime.to_iso8601(deck.inserted_at), + url: Deck.link(deck), + stats: %{ + wins: integer_value(stats, :wins), + losses: integer_value(stats, :losses), + games: integer_value(stats, :total), + winrate: value(stats, :winrate), + average_turns: value(stats, :turns), + average_duration_seconds: value(stats, :duration), + climbing_speed: value(stats, :climbing_speed) + } + } + end + + defp serialize_cards(cards) do + cards + |> Enum.frequencies() + |> Enum.sort_by(&elem(&1, 0)) + |> Enum.map(fn {dbf_id, count} -> %{dbf_id: dbf_id, count: count} end) + end + + defp serialize_sideboards(sideboards) do + Enum.map(List.wrap(sideboards), fn sideboard -> + %{dbf_id: sideboard.card, count: sideboard.count, owner_dbf_id: sideboard.sideboard} + end) + end + + defp reject_unknown_params(params) do + case Map.keys(params) -- @allowed_params do + [] -> :ok + [parameter | _] -> invalid_parameter(parameter, "is not supported") + end + end + + defp format_param(params) do + case Map.get(params, "format", DeckTracker.default_format(:public)) do + format when format in [2, "2", "standard", "Standard"] -> {:ok, 2} + format when format in [1, "1", "wild", "Wild"] -> {:ok, 1} + _ -> invalid_parameter("format", "must be Standard (2) or Wild (1)") + end + end + + defp period_param(params, format) do + period = Map.get(params, "period") || DeckTracker.default_period(format) + + case DeckTracker.get_period_by_slug(period) do + %{include_in_deck_filters: true, formats: formats} -> + if format in List.wrap(formats), + do: {:ok, period}, + else: invalid_parameter("period", "is not available for this format") + + _ -> + invalid_parameter("period", "is not available for this format") + end + end + + defp rank_param(params) do + rank = Map.get(params, "rank") || DeckTracker.default_rank(:public) + + case DeckTracker.get_rank_by_slug(rank) do + %{include_in_deck_filters: true} -> {:ok, rank} + _ -> invalid_parameter("rank", "is not available") + end + end + + defp integer_param(params, key, default, minimum, maximum) do + value = Map.get(params, key, default) + + with {:ok, integer} <- parse_integer(value), + true <- integer >= minimum, + true <- is_nil(maximum) or integer <= maximum do + {:ok, integer} + else + _ -> invalid_parameter(key, integer_range_message(minimum, maximum)) + end + end + + defp integer_range_message(minimum, maximum), do: "must be an integer between #{minimum} and #{maximum}" + + defp parse_integer(value) when is_integer(value), do: {:ok, value} + + defp parse_integer(value) when is_binary(value) do + case Integer.parse(value) do + {integer, ""} -> {:ok, integer} + _ -> :error + end + end + + defp parse_integer(_), do: :error + + defp optional_winrate_param(params) do + case Map.get(params, "min_winrate") do + nil -> {:ok, nil} + raw -> normalize_winrate(raw) + end + end + + defp normalize_winrate(value) when is_integer(value), do: normalize_winrate(value / 1) + + defp normalize_winrate(value) when is_float(value) and value >= 0 and value <= 100 do + {:ok, if(value > 1, do: value / 100, else: value)} + end + + defp normalize_winrate(value) when is_binary(value) do + case Float.parse(value) do + {number, ""} -> normalize_winrate(number) + _ -> invalid_parameter("min_winrate", "must be a number between 0 and 100") + end + end + + defp normalize_winrate(_), + do: invalid_parameter("min_winrate", "must be a number between 0 and 100") + + defp enum_param(params, key, default, allowed) do + value = Map.get(params, key, default) + + if value in allowed, + do: {:ok, value}, + else: invalid_parameter(key, "must be one of: #{Enum.join(allowed, ", ")}") + end + + defp class_param(params, key, default) do + value = Map.get(params, key, default) + + cond do + value == nil -> {:ok, nil} + value == "any" -> {:ok, "any"} + true -> validate_classes(key, List.wrap(value)) + end + end + + defp validate_classes(key, classes) do + valid_input? = + length(classes) in 1..@maximum_list_size and + Enum.all?(classes, &(is_binary(&1) and String.trim(&1) != "")) + + normalized = + if valid_input?, + do: classes |> Enum.map(&(String.trim(&1) |> String.upcase())) |> Enum.uniq(), + else: [] + + if normalized != [] and Enum.all?(normalized, &(&1 in Deck.classes())), + do: {:ok, normalized}, + else: + invalid_parameter( + key, + "must contain 1 to #{@maximum_list_size} valid Hearthstone class names" + ) + end + + defp string_list_param(params, key) do + case Map.get(params, key) do + nil -> + {:ok, nil} + + value -> + values = List.wrap(value) + + if length(values) in 1..@maximum_list_size and + Enum.all?(values, &valid_archetype_name?/1) do + normalized = values |> Enum.map(&String.trim/1) |> Enum.uniq() + {:ok, normalized} + else + invalid_parameter( + key, + "must contain 1 to #{@maximum_list_size} non-empty archetype names" + ) + end + end + end + + defp integer_list_param(params, key) do + case Map.get(params, key) do + nil -> {:ok, nil} + value -> parse_integer_list(key, List.wrap(value)) + end + end + + defp valid_archetype_name?(value) when is_binary(value) do + String.trim(value) != "" and String.length(value) <= @maximum_archetype_length + end + + defp valid_archetype_name?(_value), do: false + + defp parse_integer_list(key, values) do + parsed = Enum.map(values, &parse_integer/1) + + if length(values) in 1..@maximum_list_size and + Enum.all?(parsed, &valid_positive_integer?/1) do + normalized = parsed |> Enum.map(fn {:ok, integer} -> integer end) |> Enum.uniq() + {:ok, normalized} + else + invalid_parameter( + key, + "must contain 1 to #{@maximum_list_size} positive DBF IDs" + ) + end + end + + defp latest_set_param(params) do + case Map.get(params, "includes_latest_set") do + nil -> {:ok, nil} + value when value in ["yes", "true", true] -> {:ok, "yes"} + _ -> invalid_parameter("includes_latest_set", "must be yes or true") + end + end + + defp decode_cursor(nil), do: {:ok, nil} + defp decode_cursor(""), do: {:ok, nil} + + defp decode_cursor(cursor) when is_binary(cursor) and byte_size(cursor) <= 512 do + with {:ok, json} <- Base.url_decode64(cursor, padding: false), + {:ok, %{"inserted_at" => inserted_at, "id" => id}} <- Jason.decode(json), + true <- is_integer(id) and id in 1..@maximum_integer, + {:ok, timestamp} <- NaiveDateTime.from_iso8601(inserted_at) do + {:ok, {timestamp, id}} + else + _ -> invalid_parameter("cursor", "is invalid") + end + end + + defp decode_cursor(_), do: invalid_parameter("cursor", "is invalid") + + defp valid_positive_integer?({:ok, integer}), do: integer in 1..@maximum_integer + defp valid_positive_integer?(_), do: false + + defp encode_cursor(inserted_at, id) do + %{"inserted_at" => inserted_at, "id" => id} + |> Jason.encode!() + |> Base.url_encode64(padding: false) + end + + defp add_cursor(filters, nil), do: filters + defp add_cursor(filters, {inserted_at, id}), do: Map.put(filters, :before_deck, {inserted_at, id}) + + defp put_optional(map, _key, nil), do: map + defp put_optional(map, _key, []), do: map + defp put_optional(map, key, value), do: Map.put(map, key, value) + + defp invalid_parameter(parameter, message), + do: {:error, {:invalid_parameter, parameter, message}} + + defp integer_value(stats, key), do: value(stats, key) || 0 + defp value(stats, key), do: Map.get(stats, key) || Map.get(stats, to_string(key)) +end diff --git a/lib/backend/api/developer_api_key.ex b/lib/backend/api/developer_api_key.ex new file mode 100644 index 000000000..24282477f --- /dev/null +++ b/lib/backend/api/developer_api_key.ex @@ -0,0 +1,34 @@ +defmodule Backend.Api.DeveloperApiKey do + @moduledoc "A revocable API key owned by a Battle.net-authenticated user." + + use Ecto.Schema + import Ecto.Changeset + + alias Backend.UserManager.User + + @type t :: %__MODULE__{} + + schema "developer_api_keys" do + field :token_prefix, :string + field :token_digest, :binary + field :revoked_at, :naive_datetime + + belongs_to :user, User + + timestamps() + end + + @doc false + def changeset(api_key, attrs) do + api_key + |> cast(attrs, [:user_id, :token_prefix, :token_digest, :revoked_at]) + |> validate_required([:user_id, :token_prefix, :token_digest]) + |> foreign_key_constraint(:user_id) + |> unique_constraint(:token_prefix) + |> unique_constraint(:user_id, name: :developer_api_keys_one_active_per_user) + end + + @spec active?(t()) :: boolean() + def active?(%__MODULE__{revoked_at: nil}), do: true + def active?(%__MODULE__{}), do: false +end diff --git a/lib/backend/api/rate_limiter.ex b/lib/backend/api/rate_limiter.ex new file mode 100644 index 000000000..0f4e1271c --- /dev/null +++ b/lib/backend/api/rate_limiter.ex @@ -0,0 +1,83 @@ +defmodule Backend.Api.RateLimiter do + @moduledoc """ + A small, node-local fixed-window rate limiter for developer API keys. + + The application currently runs its in-memory caches per node as well. If the + web app is scaled horizontally, this module can be replaced by a distributed + backend without changing the API plugs. + """ + + use GenServer + + @cleanup_interval_ms :timer.minutes(5) + + def start_link(opts \\ []) do + GenServer.start_link(__MODULE__, opts, name: __MODULE__) + end + + @spec hit(term(), pos_integer(), pos_integer()) :: + {:allow, non_neg_integer(), pos_integer()} | {:deny, pos_integer()} + def hit(key, limit, window_ms) do + GenServer.call(__MODULE__, {:hit, key, limit, window_ms, now_ms()}) + end + + @impl true + def init(_opts) do + schedule_cleanup() + {:ok, %{}} + end + + @impl true + def handle_call({:hit, key, limit, window_ms, now}, _from, buckets) do + {reply, bucket} = update_bucket(Map.get(buckets, key), limit, window_ms, now) + {:reply, reply, Map.put(buckets, key, bucket)} + end + + @impl true + def handle_info(:cleanup, buckets) do + now = now_ms() + + active = + Map.reject(buckets, fn {_key, %{started_at: started_at, window_ms: window_ms}} -> + now - started_at >= window_ms + end) + + schedule_cleanup() + {:noreply, active} + end + + defp update_bucket(nil, limit, window_ms, now) do + {{:allow, limit - 1, window_ms}, bucket(now, window_ms, 1)} + end + + defp update_bucket(%{started_at: started_at}, limit, window_ms, now) + when now - started_at >= window_ms do + {{:allow, limit - 1, window_ms}, bucket(now, window_ms, 1)} + end + + defp update_bucket(%{count: count, started_at: started_at} = bucket, limit, window_ms, now) + when count < limit do + new_count = count + 1 + reset_after_ms = max(window_ms - (now - started_at), 1) + + { + {:allow, limit - new_count, reset_after_ms}, + %{bucket | count: new_count, window_ms: window_ms} + } + end + + defp update_bucket(%{started_at: started_at} = bucket, _limit, window_ms, now) do + retry_after_ms = max(window_ms - (now - started_at), 1) + {{:deny, retry_after_ms}, %{bucket | window_ms: window_ms}} + end + + defp bucket(now, window_ms, count) do + %{count: count, started_at: now, window_ms: window_ms} + end + + defp now_ms, do: System.monotonic_time(:millisecond) + + defp schedule_cleanup do + Process.send_after(self(), :cleanup, @cleanup_interval_ms) + end +end diff --git a/lib/backend/api/stats.ex b/lib/backend/api/stats.ex new file mode 100644 index 000000000..1eb048d47 --- /dev/null +++ b/lib/backend/api/stats.ex @@ -0,0 +1,390 @@ +defmodule Backend.Api.Stats do + @moduledoc "Query and serialization layer for the developer stats API." + + alias Backend.Hearthstone.CardBag + alias Backend.Hearthstone.Deck + alias Hearthstone.DeckTracker + alias Hearthstone.DeckTracker.ArchetypeBag + + @archetype_catalog_params ~w(format) + @meta_params ~w(format period rank opponent_class min_games player_has_coin sort_by) + @archetype_params ~w(format period rank opponent_class player_has_coin min_mull_count min_drawn_count sort_by sort_direction) + @meta_sort_fields ~w(winrate total turns duration climbing_speed) + @card_sort_fields ~w(card mull_impact mull_count drawn_impact drawn_count kept_impact kept_count not_drawn_impact not_drawn_count) + @sort_directions ~w(asc desc) + @coin_values ~w(any yes no) + @maximum_class_filters 25 + @maximum_archetype_length 100 + @maximum_integer 2_147_483_647 + + @default_min_games 1000 + + @spec archetypes(map()) :: {:ok, map()} | {:error, term()} + def archetypes(params) do + with :ok <- reject_unknown_params(params, @archetype_catalog_params), + {:ok, formats} <- catalog_formats(Map.get(params, "format")) do + {:ok, %{formats: Enum.map(formats, &serialize_archetype_catalog/1)}} + end + end + + @spec meta(map()) :: {:ok, map()} | {:error, term()} + def meta(params) do + with :ok <- reject_unknown_params(params, @meta_params), + {:ok, criteria} <- common_criteria(params, allow_multiple_classes: true), + {:ok, min_games} <- integer_param(params, "min_games", @default_min_games, min: 0), + {:ok, sort_by} <- enum_param(params, "sort_by", "winrate", @meta_sort_fields), + criteria = Map.put(criteria, "sort_by", sort_by), + :ok <- ensure_aggregated(criteria) do + stats = DeckTracker.archetype_stats(criteria) + total = Enum.reduce(stats, 0, &(&2 + integer_value(&1, :total))) + + archetypes = + stats + |> Enum.filter(&(integer_value(&1, :total) >= min_games)) + |> Enum.map(&serialize_archetype(&1, total)) + + {:ok, + %{ + filters: Map.put(criteria, "min_games", min_games), + total_games: total, + archetypes: archetypes + }} + end + end + + @spec archetype(String.t(), map()) :: {:ok, map()} | {:error, term()} + def archetype(archetype, params) when is_binary(archetype) do + with {:ok, archetype} <- validate_archetype_name(archetype), + :ok <- reject_unknown_params(params, @archetype_params, ["archetype"]), + {:ok, criteria} <- common_criteria(params), + {:ok, min_mull_count} <- integer_param(params, "min_mull_count", 0, min: 0), + {:ok, min_drawn_count} <- integer_param(params, "min_drawn_count", 0, min: 0), + {:ok, sort_by} <- enum_param(params, "sort_by", "mull_impact", @card_sort_fields), + {:ok, sort_direction} <- enum_param(params, "sort_direction", "desc", @sort_directions), + criteria = Map.put(criteria, "archetype", archetype), + :ok <- ensure_aggregated(criteria), + overall when is_map(overall) <- DeckTracker.card_stats(criteria) do + card_stats = value(overall, :card_stats) || [] + + cards = + card_stats + |> DeckTracker.merge_card_stats() + |> Enum.filter(fn stats -> + integer_value(stats, :mull_total) >= min_mull_count and + integer_value(stats, :drawn_total) >= min_drawn_count + end) + |> Enum.map(&serialize_card_stats/1) + |> sort_cards(sort_by, sort_direction) + + matchups = + {"archetype", archetype} + |> DeckTracker.detailed_stats(matchup_criteria(criteria)) + |> Enum.map(&serialize_matchup/1) + + {:ok, + %{ + archetype: archetype, + filters: + criteria + |> Map.put("min_mull_count", min_mull_count) + |> Map.put("min_drawn_count", min_drawn_count) + |> Map.put("sort_by", sort_by) + |> Map.put("sort_direction", sort_direction), + stats: serialize_summary(overall), + matchups: matchups, + cards: cards + }} + else + {:error, _reason} = error -> error + _ -> {:error, :archetype_not_found} + end + end + + def archetype(_, _), do: {:error, :archetype_not_found} + + defp common_criteria(params, opts \\ []) do + with {:ok, format} <- format_param(params), + {:ok, period} <- period_param(params, format), + {:ok, rank} <- rank_param(params), + {:ok, opponent_class} <- opponent_class_param(params, opts), + {:ok, player_has_coin} <- enum_param(params, "player_has_coin", "any", @coin_values) do + criteria = %{ + "exclude_bugged_sources" => "true", + "format" => format, + "opponent_class" => opponent_class, + "period" => period, + "player_has_coin" => player_has_coin, + "rank" => rank + } + + {:ok, criteria} + end + end + + defp catalog_formats(nil), do: {:ok, [2, 1]} + defp catalog_formats("all"), do: {:ok, [2, 1]} + defp catalog_formats(format) when format in [2, "2", "standard", "Standard"], do: {:ok, [2]} + defp catalog_formats(format) when format in [1, "1", "wild", "Wild"], do: {:ok, [1]} + + defp catalog_formats(_format), + do: invalid_parameter("format", "must be Standard (2) or Wild (1)") + + defp format_param(params) do + case Map.get(params, "format", DeckTracker.default_format(:public)) do + format when format in [2, "2", "standard", "Standard"] -> {:ok, 2} + format when format in [1, "1", "wild", "Wild"] -> {:ok, 1} + _ -> invalid_parameter("format", "must be Standard (2) or Wild (1)") + end + end + + defp period_param(params, format) do + period = Map.get(params, "period") || DeckTracker.default_period(format) + + case DeckTracker.get_period_by_slug(period) do + %{include_in_deck_filters: true, formats: formats} -> + if format in List.wrap(formats), + do: {:ok, period}, + else: invalid_parameter("period", "is not available for this format") + + _ -> + invalid_parameter("period", "is not available for this format") + end + end + + defp rank_param(params) do + rank = Map.get(params, "rank") || DeckTracker.default_rank(:public) + + case DeckTracker.get_rank_by_slug(rank) do + %{include_in_deck_filters: true} -> {:ok, rank} + _ -> invalid_parameter("rank", "is not available") + end + end + + defp ensure_aggregated(criteria) do + case DeckTracker.fresh_or_agg_archetype_stats(criteria) do + :agg -> :ok + :fresh -> {:error, :filters_not_available} + end + end + + defp matchup_criteria(criteria) do + criteria = Map.delete(criteria, "archetype") + + criteria = + if Map.get(criteria, "opponent_class") == "any" do + Map.delete(criteria, "opponent_class") + else + criteria + end + + Enum.to_list(criteria) + end + + defp reject_unknown_params(params, allowed, ignored \\ []) do + unknown = (Map.keys(params) -- allowed) -- ignored + + case unknown do + [] -> :ok + [parameter | _] -> invalid_parameter(parameter, "is not supported") + end + end + + defp integer_param(params, key, default, opts) do + value = Map.get(params, key, default) + minimum = Keyword.get(opts, :min, 0) + maximum = Keyword.get(opts, :max, @maximum_integer) + + with {:ok, integer} <- parse_integer(value), + true <- integer >= minimum and integer <= maximum do + {:ok, integer} + else + _ -> invalid_parameter(key, "must be an integer between #{minimum} and #{maximum}") + end + end + + defp parse_integer(value) when is_integer(value), do: {:ok, value} + + defp parse_integer(value) when is_binary(value) do + case Integer.parse(value) do + {integer, ""} -> {:ok, integer} + _ -> :error + end + end + + defp parse_integer(_), do: :error + + defp enum_param(params, key, default, allowed) do + value = Map.get(params, key, default) + + if value in allowed do + {:ok, value} + else + invalid_parameter(key, "must be one of: #{Enum.join(allowed, ", ")}") + end + end + + defp opponent_class_param(params, opts) do + raw_classes = params |> Map.get("opponent_class", "any") |> List.wrap() + classes = normalize_classes(raw_classes) + allowed = ["any" | Deck.classes()] + multiple_allowed? = Keyword.get(opts, :allow_multiple_classes, false) + valid_count? = length(classes) in 1..@maximum_class_filters + valid_combination? = classes == ["any"] or "any" not in classes + + if valid_count? and valid_combination? and (multiple_allowed? or length(classes) == 1) and + Enum.all?(classes, &(&1 in allowed)) do + {:ok, if(length(classes) == 1, do: hd(classes), else: classes)} + else + invalid_parameter("opponent_class", "must contain valid class names") + end + end + + defp normalize_classes(classes) do + if Enum.all?(classes, &is_binary/1) do + classes + |> Enum.map(&normalize_class/1) + |> Enum.uniq() + else + [] + end + end + + defp normalize_class(class) do + class = String.trim(class) + if String.downcase(class) == "any", do: "any", else: String.upcase(class) + end + + defp validate_archetype_name(archetype) do + archetype = String.trim(archetype) + + if archetype != "" and String.length(archetype) <= @maximum_archetype_length, + do: {:ok, archetype}, + else: + invalid_parameter( + "archetype", + "must be a non-empty string up to #{@maximum_archetype_length} characters" + ) + end + + defp invalid_parameter(parameter, message) do + {:error, {:invalid_parameter, parameter, message}} + end + + defp serialize_archetype(stats, total_games) do + games = integer_value(stats, :total) + + %{ + archetype: value(stats, :archetype), + wins: integer_value(stats, :wins), + losses: integer_value(stats, :losses), + games: games, + winrate: value(stats, :winrate), + popularity: safe_div(games, total_games), + average_turns: value(stats, :turns), + average_duration_seconds: value(stats, :duration), + climbing_speed: value(stats, :climbing_speed) + } + end + + defp serialize_archetype_catalog(format) do + archetypes = + format + |> ArchetypeBag.get_archetypes() + |> Enum.map(&to_string/1) + |> Enum.uniq() + |> Enum.sort() + |> Enum.map(fn archetype -> + %{ + name: archetype, + class: Deck.extract_class(archetype), + stats_path: "/api/v1/archetypes/#{URI.encode(archetype)}?format=#{format}" + } + end) + + %{ + id: format, + slug: if(format == 2, do: "standard", else: "wild"), + name: Deck.format_name(format), + archetypes: archetypes + } + end + + defp serialize_summary(stats) do + %{ + wins: integer_value(stats, :wins), + losses: integer_value(stats, :losses), + games: integer_value(stats, :total), + winrate: value(stats, :winrate) + } + end + + defp serialize_matchup(stats) do + %{ + opponent_class: value(stats, :opponent_class), + wins: integer_value(stats, :wins), + losses: integer_value(stats, :losses), + games: integer_value(stats, :total), + winrate: value(stats, :winrate) + } + end + + defp serialize_card_stats(stats) do + dbf_id = value(stats, :card_id) + card = CardBag.card(dbf_id) + + %{ + dbf_id: dbf_id, + card_id: card && card.card_id, + name: card && card.name, + mulligan: sample(stats, :mull), + drawn: sample(stats, :drawn), + not_drawn: sample(stats, :not_drawn), + kept: sample(stats, :kept), + tossed: sample(stats, :tossed) + } + end + + defp sample(stats, prefix) do + {total_key, impact_key} = sample_keys(prefix) + + %{ + games: integer_value(stats, total_key), + impact: value(stats, impact_key) || 0.0 + } + end + + defp sample_keys(:mull), do: {:mull_total, :mull_impact} + defp sample_keys(:drawn), do: {:drawn_total, :drawn_impact} + defp sample_keys(:not_drawn), do: {:not_drawn_total, :not_drawn_impact} + defp sample_keys(:kept), do: {:kept_total, :kept_impact} + defp sample_keys(:tossed), do: {:tossed_total, :tossed_impact} + + defp sort_cards(cards, "card", direction) do + Enum.sort_by(cards, &(&1.name || ""), sort_direction(direction)) + end + + defp sort_cards(cards, sort_by, direction) do + {sample_key, value_key} = card_sort_path(sort_by) + + Enum.sort_by(cards, &get_in(&1, [sample_key, value_key]), sort_direction(direction)) + end + + defp card_sort_path("mull_impact"), do: {:mulligan, :impact} + defp card_sort_path("mull_count"), do: {:mulligan, :games} + defp card_sort_path("drawn_impact"), do: {:drawn, :impact} + defp card_sort_path("drawn_count"), do: {:drawn, :games} + defp card_sort_path("kept_impact"), do: {:kept, :impact} + defp card_sort_path("kept_count"), do: {:kept, :games} + defp card_sort_path("not_drawn_impact"), do: {:not_drawn, :impact} + defp card_sort_path("not_drawn_count"), do: {:not_drawn, :games} + + defp sort_direction("asc"), do: :asc + defp sort_direction(_), do: :desc + + defp integer_value(stats, key), do: value(stats, key) || 0 + + defp value(stats, key), do: Map.get(stats, key) || Map.get(stats, to_string(key)) + + defp safe_div(_, 0), do: 0.0 + defp safe_div(value, total), do: value / total +end diff --git a/lib/backend/api/streaming.ex b/lib/backend/api/streaming.ex new file mode 100644 index 000000000..1762f106c --- /dev/null +++ b/lib/backend/api/streaming.ex @@ -0,0 +1,537 @@ +defmodule Backend.Api.Streaming do + @moduledoc "Query, validation, and serialization for the developer streaming API." + + alias Backend.Hearthstone.Deck + alias Backend.Streaming, as: StreamingContext + alias Backend.Streaming.Streamer + alias Backend.Streaming.StreamerDeck + alias Backend.Streaming.StreamingNow + alias Hearthstone.Enums.BnetGameType + alias Hearthstone.Enums.Format + + @streamer_params ~w(limit offset search sort_by sort_direction twitch_login) + @streamer_deck_params ~w( + best_legend_rank class deck_id exclude_cards first_played_within_minutes format + include_cards last_played_within_minutes latest_legend_rank limit min_minutes_played + offset sort_by sort_direction twitch_id twitch_login worst_legend_rank + ) + @live_stream_params ~w(deckcode has_deck language legend_rank limit mode sort_by) + + @streamer_sort_fields %{ + "created_at" => :inserted_at, + "display_name" => :display_name, + "login" => :login + } + @streamer_deck_sort_fields %{ + "best_legend_rank" => :best_legend_rank, + "first_played" => :first_played, + "last_played" => :last_played, + "latest_legend_rank" => :latest_legend_rank, + "losses" => :losses, + "minutes_played" => :minutes_played, + "wins" => :wins, + "worst_legend_rank" => :worst_legend_rank + } + @sort_directions ~w(asc desc) + @live_sort_fields ~w(fewest_viewers most_viewers newest oldest) + @has_deck_values ~w(any yes no) + @format_values %{ + "1" => 1, + "wild" => 1, + "2" => 2, + "standard" => 2, + "3" => 3, + "classic" => 3, + "4" => 4, + "twist" => 4 + } + @default_limit 50 + @maximum_limit 100 + @maximum_offset 100_000 + @maximum_list_size 25 + @maximum_integer 2_147_483_647 + + @spec streamers(map()) :: {:ok, map()} | {:error, term()} + def streamers(params) do + with :ok <- reject_unknown_params(params, @streamer_params), + {:ok, limit} <- integer_param(params, "limit", @default_limit, 1, @maximum_limit), + {:ok, offset} <- integer_param(params, "offset", 0, 0, @maximum_offset), + {:ok, search} <- optional_text_param(params, "search", 100), + {:ok, twitch_logins} <- string_list_param(params, "twitch_login"), + {:ok, sort_by} <- mapped_enum_param(params, "sort_by", "display_name", @streamer_sort_fields), + {:ok, sort_direction} <- enum_param(params, "sort_direction", "asc", @sort_directions) do + query_params = + %{ + "limit" => limit + 1, + "offset" => offset, + "order_by" => {direction_atom(sort_direction), sort_by} + } + |> put_optional("search", search) + |> put_optional("twitch_login", twitch_logins) + + results = StreamingContext.streamers_with_stats(query_params) + has_more = length(results) > limit + + {:ok, + %{ + filters: %{ + limit: limit, + offset: offset, + search: search, + sort_by: map_key_for_value(@streamer_sort_fields, sort_by), + sort_direction: sort_direction, + twitch_login: twitch_logins + }, + streamers: results |> Enum.take(limit) |> Enum.map(&serialize_streamer_summary/1), + pagination: offset_pagination(limit, offset, has_more) + }} + end + end + + @spec streamer_decks(map()) :: {:ok, map()} | {:error, term()} + def streamer_decks(params) do + with :ok <- reject_unknown_params(params, @streamer_deck_params), + {:ok, limit} <- integer_param(params, "limit", @default_limit, 1, @maximum_limit), + {:ok, offset} <- integer_param(params, "offset", 0, 0, @maximum_offset), + {:ok, format} <- optional_format_param(params), + {:ok, player_class} <- optional_class_param(params), + {:ok, twitch_logins} <- string_list_param(params, "twitch_login"), + {:ok, twitch_ids} <- positive_integer_list_param(params, "twitch_id"), + {:ok, include_cards} <- positive_integer_list_param(params, "include_cards"), + {:ok, exclude_cards} <- positive_integer_list_param(params, "exclude_cards"), + {:ok, deck_id} <- optional_positive_integer_param(params, "deck_id"), + {:ok, best_legend_rank} <- optional_positive_integer_param(params, "best_legend_rank"), + {:ok, latest_legend_rank} <- optional_positive_integer_param(params, "latest_legend_rank"), + {:ok, worst_legend_rank} <- optional_positive_integer_param(params, "worst_legend_rank"), + {:ok, min_minutes_played} <- optional_non_negative_integer_param(params, "min_minutes_played"), + {:ok, last_played_minutes} <- + optional_positive_integer_param(params, "last_played_within_minutes"), + {:ok, first_played_minutes} <- + optional_positive_integer_param(params, "first_played_within_minutes"), + {:ok, sort_by} <- + mapped_enum_param(params, "sort_by", "last_played", @streamer_deck_sort_fields), + {:ok, sort_direction} <- enum_param(params, "sort_direction", "desc", @sort_directions) do + query_params = + %{ + "limit" => limit + 1, + "offset" => offset, + "order_by" => {direction_atom(sort_direction), sort_by} + } + |> put_optional("format", format) + |> put_optional("class", player_class) + |> put_optional("twitch_login", twitch_logins) + |> put_optional("twitch_id", twitch_ids) + |> put_optional("include_cards", include_cards) + |> put_optional("exclude_cards", exclude_cards) + |> put_optional("deck_id", deck_id) + |> put_optional("best_legend_rank", best_legend_rank) + |> put_optional("latest_legend_rank", latest_legend_rank) + |> put_optional("worst_legend_rank", worst_legend_rank) + |> put_optional("min_minutes_played", min_minutes_played) + |> put_minutes_filter("last_played", last_played_minutes) + |> put_minutes_filter("first_played", first_played_minutes) + + results = StreamingContext.streamer_decks(query_params) + has_more = length(results) > limit + + filters = + params + |> Map.put("limit", limit) + |> Map.put("offset", offset) + |> Map.put("sort_by", map_key_for_value(@streamer_deck_sort_fields, sort_by)) + |> Map.put("sort_direction", sort_direction) + + {:ok, + %{ + filters: filters, + streamer_decks: results |> Enum.take(limit) |> Enum.map(&serialize_streamer_deck/1), + pagination: offset_pagination(limit, offset, has_more) + }} + end + end + + @spec live_streams(map(), list()) :: {:ok, map()} | {:error, term()} + def live_streams(params, streams \\ StreamingNow.streaming_now()) do + with :ok <- reject_unknown_params(params, @live_stream_params), + {:ok, limit} <- integer_param(params, "limit", @default_limit, 1, @maximum_limit), + {:ok, mode} <- optional_text_param(params, "mode", 50), + {:ok, language} <- optional_text_param(params, "language", 20), + {:ok, deckcode} <- optional_text_param(params, "deckcode", 1000), + {:ok, legend_rank} <- optional_positive_integer_param(params, "legend_rank"), + {:ok, has_deck} <- enum_param(params, "has_deck", "any", @has_deck_values), + {:ok, sort_by} <- enum_param(params, "sort_by", "most_viewers", @live_sort_fields) do + filtered = + streams + |> Enum.filter(&matches_live_stream?(&1, mode, language, deckcode, legend_rank, has_deck)) + |> sort_live_streams(sort_by) + + {:ok, + %{ + filters: %{ + deckcode: deckcode, + has_deck: has_deck, + language: language, + legend_rank: legend_rank, + limit: limit, + mode: mode, + sort_by: sort_by + }, + streams: filtered |> Enum.take(limit) |> Enum.map(&serialize_live_stream/1), + total: length(filtered) + }} + end + end + + defp reject_unknown_params(params, allowed) do + case Map.keys(params) -- allowed do + [] -> :ok + [parameter | _] -> invalid_parameter(parameter, "is not supported") + end + end + + defp integer_param(params, key, default, minimum, maximum) do + value = Map.get(params, key, default) + + with {:ok, integer} <- parse_integer(value), + true <- integer >= minimum and integer <= maximum do + {:ok, integer} + else + _ -> invalid_parameter(key, "must be an integer between #{minimum} and #{maximum}") + end + end + + defp optional_positive_integer_param(params, key), + do: optional_integer_param(params, key, 1) + + defp optional_non_negative_integer_param(params, key), + do: optional_integer_param(params, key, 0) + + defp optional_integer_param(params, key, minimum) do + case Map.get(params, key) do + nil -> {:ok, nil} + value -> parse_optional_integer(key, value, minimum) + end + end + + defp parse_optional_integer(key, value, minimum) do + with {:ok, integer} <- parse_integer(value), + true <- integer >= minimum and integer <= @maximum_integer do + {:ok, integer} + else + _ -> invalid_parameter(key, "must be an integer between #{minimum} and #{@maximum_integer}") + end + end + + defp parse_integer(value) when is_integer(value), do: {:ok, value} + + defp parse_integer(value) when is_binary(value) do + case Integer.parse(value) do + {integer, ""} -> {:ok, integer} + _ -> :error + end + end + + defp parse_integer(_), do: :error + + defp optional_format_param(params) do + case Map.get(params, "format") do + nil -> {:ok, nil} + value -> normalize_format(value) + end + end + + defp normalize_format(value) when is_integer(value) and value in [1, 2, 3, 4], do: {:ok, value} + + defp normalize_format(value) when is_binary(value) do + case Map.fetch(@format_values, String.downcase(value)) do + {:ok, format} -> {:ok, format} + :error -> invalid_parameter("format", "must be Wild, Standard, Classic, or Twist") + end + end + + defp normalize_format(_), + do: invalid_parameter("format", "must be Wild, Standard, Classic, or Twist") + + defp optional_class_param(params) do + case Map.get(params, "class") do + nil -> + {:ok, nil} + + value when is_binary(value) -> + normalized = String.upcase(value) + + if normalized in Deck.classes(), + do: {:ok, normalized}, + else: invalid_parameter("class", "must be a valid Hearthstone class") + + _ -> + invalid_parameter("class", "must be a valid Hearthstone class") + end + end + + defp optional_text_param(params, key, maximum_length) do + case Map.get(params, key) do + nil -> + {:ok, nil} + + value when is_binary(value) -> + trimmed = String.trim(value) + + if trimmed != "" and String.length(trimmed) <= maximum_length, + do: {:ok, trimmed}, + else: invalid_parameter(key, "must be a non-empty string up to #{maximum_length} characters") + + _ -> + invalid_parameter(key, "must be a non-empty string up to #{maximum_length} characters") + end + end + + defp string_list_param(params, key) do + case Map.get(params, key) do + nil -> + {:ok, nil} + + value -> + values = List.wrap(value) + + if length(values) in 1..@maximum_list_size and + Enum.all?(values, &(is_binary(&1) and String.trim(&1) != "" and String.length(&1) <= 100)) do + normalized = values |> Enum.map(&(String.trim(&1) |> String.downcase())) |> Enum.uniq() + {:ok, normalized} + else + invalid_parameter(key, "must contain 1 to #{@maximum_list_size} non-empty values") + end + end + end + + defp positive_integer_list_param(params, key) do + case Map.get(params, key) do + nil -> + {:ok, nil} + + value -> + values = List.wrap(value) + parsed = Enum.map(values, &parse_integer/1) + normalized = for {:ok, integer} <- parsed, uniq: true, do: integer + + if length(values) in 1..@maximum_list_size and + Enum.all?(parsed, &valid_positive_integer?/1) do + {:ok, normalized} + else + invalid_parameter(key, "must contain 1 to #{@maximum_list_size} positive integers") + end + end + end + + defp mapped_enum_param(params, key, default, values) do + raw = Map.get(params, key, default) + + case Map.fetch(values, raw) do + {:ok, value} -> {:ok, value} + :error -> invalid_parameter(key, "must be one of: #{values |> Map.keys() |> Enum.sort() |> Enum.join(", ")}") + end + end + + defp valid_positive_integer?({:ok, integer}), do: integer in 1..@maximum_integer + defp valid_positive_integer?(_), do: false + + defp enum_param(params, key, default, values) do + value = Map.get(params, key, default) + + if value in values, + do: {:ok, value}, + else: invalid_parameter(key, "must be one of: #{Enum.join(values, ", ")}") + end + + defp direction_atom("asc"), do: :asc + defp direction_atom(_), do: :desc + + defp map_key_for_value(map, value) do + Enum.find_value(map, fn {key, mapped_value} -> if mapped_value == value, do: key end) + end + + defp put_optional(map, _key, nil), do: map + defp put_optional(map, _key, []), do: map + defp put_optional(map, key, value), do: Map.put(map, key, value) + + defp put_minutes_filter(map, _key, nil), do: map + defp put_minutes_filter(map, key, minutes), do: Map.put(map, key, "min_ago_#{minutes}") + + defp offset_pagination(limit, offset, has_more) do + %{ + limit: limit, + offset: offset, + next_offset: if(has_more, do: offset + limit, else: nil) + } + end + + defp serialize_streamer(streamer) do + login = Streamer.twitch_login(streamer) + + %{ + twitch_id: to_string(streamer.twitch_id), + login: login, + display_name: Streamer.twitch_display(streamer), + twitch_url: twitch_url(login) + } + end + + defp serialize_streamer_summary(%{streamer: streamer} = summary) do + recorded_games = summary.wins + summary.losses + + streamer + |> serialize_streamer() + |> Map.put(:stats, %{ + deck_count: summary.deck_count, + recorded_games: recorded_games, + wins: summary.wins, + losses: summary.losses, + winrate: safe_div(summary.wins, recorded_games), + minutes_played: summary.minutes_played, + best_legend_rank: summary.best_legend_rank, + last_played: iso8601(summary.last_played) + }) + end + + defp serialize_streamer_deck(%StreamerDeck{} = streamer_deck) do + %{ + streamer: serialize_streamer(streamer_deck.streamer), + deck: serialize_deck(streamer_deck.deck), + first_played: iso8601(streamer_deck.first_played), + last_played: iso8601(streamer_deck.last_played), + mode: %{ + game_type: streamer_deck.game_type, + name: BnetGameType.game_type_name(streamer_deck.game_type) + }, + ranks: %{ + best: zero_to_nil(streamer_deck.best_rank), + best_legend: zero_to_nil(streamer_deck.best_legend_rank), + latest_legend: zero_to_nil(streamer_deck.latest_legend_rank), + worst_legend: zero_to_nil(streamer_deck.worst_legend_rank) + }, + performance: %{ + minutes_played: streamer_deck.minutes_played, + wins: streamer_deck.wins, + losses: streamer_deck.losses, + winrate: StreamerDeck.winrate(streamer_deck) + } + } + end + + defp serialize_deck(deck) do + archetype = Deck.archetype(deck) + + %{ + id: deck.id, + deckcode: deck.deckcode, + format: %{id: deck.format, name: Format.name(deck.format)}, + class: Deck.class(deck), + archetype: archetype && to_string(archetype), + dust_cost: deck.cost, + cards: serialize_cards(deck.cards), + sideboards: serialize_sideboards(deck.sideboards), + url: Deck.link(deck) + } + end + + defp serialize_cards(cards) do + cards + |> Enum.frequencies() + |> Enum.sort_by(&elem(&1, 0)) + |> Enum.map(fn {dbf_id, count} -> %{dbf_id: dbf_id, count: count} end) + end + + defp serialize_sideboards(sideboards) do + Enum.map(List.wrap(sideboards), fn sideboard -> + %{dbf_id: sideboard.card, count: sideboard.count, owner_dbf_id: sideboard.sideboard} + end) + end + + defp matches_live_stream?(stream, mode, language, deckcode, legend_rank, has_deck) do + matches_mode?(stream, mode) and + matches_language?(stream, language) and + matches_deckcode?(stream, deckcode) and + matches_legend_rank?(stream, legend_rank) and + matches_has_deck?(stream, has_deck) + end + + defp matches_mode?(_stream, nil), do: true + + defp matches_mode?(stream, mode) do + stream.game_type + |> BnetGameType.game_type_name() + |> String.downcase() + |> Kernel.==(String.downcase(mode)) + end + + defp matches_language?(_stream, nil), do: true + + defp matches_language?(stream, language), + do: String.downcase(stream.language || "") == String.downcase(language) + + defp matches_deckcode?(_stream, nil), do: true + defp matches_deckcode?(stream, deckcode), do: stream.deckcode == deckcode + + defp matches_legend_rank?(_stream, nil), do: true + + defp matches_legend_rank?(%{legend_rank: rank}, maximum_rank), + do: is_integer(rank) and rank > 0 and rank <= maximum_rank + + defp matches_has_deck?(_stream, "any"), do: true + defp matches_has_deck?(%{deckcode: deckcode}, "yes"), do: is_binary(deckcode) and deckcode != "" + defp matches_has_deck?(%{deckcode: deckcode}, "no"), do: is_nil(deckcode) or deckcode == "" + + defp sort_live_streams(streams, "newest"), + do: Enum.sort_by(streams, &datetime_sort_value(&1.started_at), :desc) + + defp sort_live_streams(streams, "oldest"), + do: Enum.sort_by(streams, &datetime_sort_value(&1.started_at), :asc) + + defp sort_live_streams(streams, "fewest_viewers"), + do: Enum.sort_by(streams, &(&1.viewer_count || 0), :asc) + + defp sort_live_streams(streams, _), + do: Enum.sort_by(streams, &(&1.viewer_count || 0), :desc) + + defp serialize_live_stream(stream) do + %{ + twitch_id: to_string(stream.user_id), + display_name: stream.user_name, + twitch_url: twitch_url(stream.user_name), + stream_id: to_string(stream.stream_id), + title: stream.title, + language: stream.language, + viewer_count: stream.viewer_count, + started_at: iso8601(stream.started_at), + legend_rank: stream.legend_rank, + deckcode: stream.deckcode, + game_type: stream.game_type, + mode: BnetGameType.game_type_name(stream.game_type) + } + end + + defp datetime_sort_value(%DateTime{} = datetime), do: DateTime.to_unix(datetime, :microsecond) + + defp datetime_sort_value(%NaiveDateTime{} = datetime), + do: NaiveDateTime.diff(datetime, ~N[1970-01-01 00:00:00], :microsecond) + + defp datetime_sort_value(_), do: 0 + + defp iso8601(%DateTime{} = datetime), do: DateTime.to_iso8601(datetime) + defp iso8601(%NaiveDateTime{} = datetime), do: NaiveDateTime.to_iso8601(datetime) + defp iso8601(_), do: nil + + defp twitch_url(login) when is_binary(login) and login != "", + do: "https://www.twitch.tv/#{URI.encode(login)}" + + defp twitch_url(_), do: nil + + defp zero_to_nil(value) when value in [nil, 0], do: nil + defp zero_to_nil(value), do: value + + defp safe_div(_, 0), do: nil + defp safe_div(value, total), do: value / total + + defp invalid_parameter(parameter, message), + do: {:error, {:invalid_parameter, parameter, message}} +end diff --git a/lib/backend/application.ex b/lib/backend/application.ex index d942c0ada..c8d088392 100644 --- a/lib/backend/application.ex +++ b/lib/backend/application.ex @@ -12,6 +12,7 @@ defmodule Backend.Application do [ # Start the Ecto repository Backend.Repo, + Backend.Api.RateLimiter, # Start the endpoint when the application starts {Phoenix.PubSub, name: Backend.PubSub}, Backend.Telemetry, diff --git a/lib/backend/deck_tracker.ex b/lib/backend/deck_tracker.ex index 69d05f179..2ed000cfc 100644 --- a/lib/backend/deck_tracker.ex +++ b/lib/backend/deck_tracker.ex @@ -1622,10 +1622,10 @@ defmodule Hearthstone.DeckTracker do do: query |> order_by([game: g], desc: max(g.inserted_at)) defp compose_games_query({"order_by", "newest_deck"}, %{group_bys: []} = query), - do: query |> order_by([player_deck: d], desc: d.inserted_at) + do: query |> order_by([player_deck: d], desc: d.inserted_at, desc: d.id) defp compose_games_query({"order_by", "newest_deck"}, query), - do: query |> order_by([player_deck: d], desc: max(d.inserted_at)) + do: query |> order_by([player_deck: d], desc: max(d.inserted_at), desc: max(d.id)) defp compose_games_query({"order_by", "oldest_deck"}, %{group_bys: []} = query), do: query |> order_by([player_deck: d], asc: d.inserted_at) @@ -1666,6 +1666,15 @@ defmodule Hearthstone.DeckTracker do defp compose_games_query({"deck_format", deck_format}, query), do: query |> where([player_deck: pd], pd.format == ^deck_format) + defp compose_games_query({:before_deck, {inserted_at, id}}, query) do + query + |> where( + [player_deck: deck], + deck.inserted_at < ^inserted_at or + (deck.inserted_at == ^inserted_at and deck.id < ^id) + ) + end + defp compose_games_query({"includes_latest_set", "yes"}, query) do query |> Hearthstone.includes_latest_set(:player_deck) diff --git a/lib/backend/streaming.ex b/lib/backend/streaming.ex index 7e7ec8230..17943fb4d 100644 --- a/lib/backend/streaming.ex +++ b/lib/backend/streaming.ex @@ -308,6 +308,26 @@ defmodule Backend.Streaming do |> Repo.all() end + def streamers_with_stats(criteria) do + base_streamers_query() + |> build_streamers_query(criteria) + |> join(:left, [streamer: s], sd in StreamerDeck, + on: sd.streamer_id == s.id, + as: :streamer_deck_stats + ) + |> group_by([streamer: s], s.id) + |> select([streamer: s, streamer_deck_stats: sd], %{ + streamer: s, + deck_count: count(sd.deck_id), + wins: fragment("COALESCE(SUM(?), 0)", sd.wins), + losses: fragment("COALESCE(SUM(?), 0)", sd.losses), + minutes_played: fragment("COALESCE(SUM(?), 0)", sd.minutes_played), + best_legend_rank: fragment("MIN(NULLIF(?, 0))", sd.best_legend_rank), + last_played: max(sd.last_played) + }) + |> Repo.all() + end + def base_streamers_query do from(s in Streamer, as: :streamer) end @@ -319,13 +339,39 @@ defmodule Backend.Streaming do search = "%#{search_term}%" query - |> where([streamer: s], ilike(s.twitch_login, ^search) or ilike(s.twitch_display, ^search)) + |> where( + [streamer: s], + ilike(fragment("COALESCE(?, ?)", s.twitch_login, s.hsreplay_twitch_login), ^search) or + ilike(fragment("COALESCE(?, ?)", s.twitch_display, s.hsreplay_twitch_display), ^search) + ) end defp compose_streamers_query({"limit", limit}, query) do limit(query, ^limit) end + defp compose_streamers_query({"offset", offset}, query) do + offset(query, ^offset) + end + + defp compose_streamers_query({"order_by", {direction, :display_name}}, query) do + order_by(query, [streamer: s], [ + {^direction, fragment("COALESCE(?, ?)", s.twitch_display, s.hsreplay_twitch_display)}, + asc: s.id + ]) + end + + defp compose_streamers_query({"order_by", {direction, :login}}, query) do + order_by(query, [streamer: s], [ + {^direction, fragment("COALESCE(?, ?)", s.twitch_login, s.hsreplay_twitch_login)}, + asc: s.id + ]) + end + + defp compose_streamers_query({"order_by", {direction, :inserted_at}}, query) do + order_by(query, [streamer: s], [{^direction, s.inserted_at}, asc: s.id]) + end + defp compose_streamers_query({"order_by", "search_similarity_" <> search_target}, query) do query |> order_by([streamer: s], @@ -347,12 +393,14 @@ defmodule Backend.Streaming do defp compose_streamers_query({"twitch_login", logins}, query) when is_list(logins) do query - |> where([streamer: s], s.twitch_login in ^logins) + |> where( + [streamer: s], + fragment("LOWER(COALESCE(?, ?))", s.twitch_login, s.hsreplay_twitch_login) in ^logins + ) end defp compose_streamers_query({"twitch_login", login}, query) when is_binary(login) do - query - |> where([streamer: s], s.twitch_login == ^login) + compose_streamers_query({"twitch_login", [String.downcase(login)]}, query) end defp compose_streamers_query(_unrecognized, query), do: query @@ -397,10 +445,9 @@ defmodule Backend.Streaming do defp compose_streamer_deck_query({"twitch_login", twitch_login}, query) do query - |> join(:inner, [sd], s in assoc(sd, :streamer)) |> where( - [_sd, s, _d], - s.hsreplay_twitch_login in ^twitch_login or s.twitch_login in ^twitch_login + [streamer: s], + fragment("LOWER(COALESCE(?, ?))", s.twitch_login, s.hsreplay_twitch_login) in ^twitch_login ) end @@ -414,13 +461,15 @@ defmodule Backend.Streaming do int_ids = Enum.map(twitch_ids, &Util.to_int_or_orig/1) query - |> join(:inner, [sd], s in assoc(sd, :streamer)) - |> where([_sd, s, _d], s.twitch_id in ^int_ids) + |> where([streamer: s], s.twitch_id in ^int_ids) end defp compose_streamer_deck_query({"order_by", {direction, field}}, query) do query - |> order_by([{^direction, ^field}]) + |> order_by( + [streamer_deck: sd], + [{^direction, field(sd, ^field)}, asc: sd.streamer_id, asc: sd.deck_id] + ) end defp compose_streamer_deck_query({"limit", limit}, query), do: query |> limit(^limit) diff --git a/lib/backend_web/controllers/developer_api_fallback_controller.ex b/lib/backend_web/controllers/developer_api_fallback_controller.ex new file mode 100644 index 000000000..989c45886 --- /dev/null +++ b/lib/backend_web/controllers/developer_api_fallback_controller.ex @@ -0,0 +1,34 @@ +defmodule BackendWeb.DeveloperApiFallbackController do + use Phoenix.Controller, formats: [:json] + + import Plug.Conn + + def call(conn, {:error, {:invalid_parameter, parameter, message}}) do + conn + |> put_status(:bad_request) + |> json(%{ + error: %{ + code: "invalid_parameter", + message: "#{parameter} #{message}", + parameter: parameter + } + }) + end + + def call(conn, {:error, :filters_not_available}) do + conn + |> put_status(:unprocessable_entity) + |> json(%{ + error: %{ + code: "filters_not_available", + message: "The requested filter combination is not available in public aggregates" + } + }) + end + + def call(conn, {:error, :archetype_not_found}) do + conn + |> put_status(:not_found) + |> json(%{error: %{code: "archetype_not_found", message: "Archetype was not found"}}) + end +end diff --git a/lib/backend_web/controllers/developer_deck_controller.ex b/lib/backend_web/controllers/developer_deck_controller.ex new file mode 100644 index 000000000..953a48b39 --- /dev/null +++ b/lib/backend_web/controllers/developer_deck_controller.ex @@ -0,0 +1,13 @@ +defmodule BackendWeb.DeveloperDeckController do + use Phoenix.Controller, formats: [:json] + + alias Backend.Api.Decks + + action_fallback BackendWeb.DeveloperApiFallbackController + + def index(conn, params) do + with {:ok, payload} <- Decks.latest(params) do + json(conn, %{data: payload}) + end + end +end diff --git a/lib/backend_web/controllers/developer_stats_controller.ex b/lib/backend_web/controllers/developer_stats_controller.ex new file mode 100644 index 000000000..ca2c53bea --- /dev/null +++ b/lib/backend_web/controllers/developer_stats_controller.ex @@ -0,0 +1,25 @@ +defmodule BackendWeb.DeveloperStatsController do + use Phoenix.Controller, formats: [:json] + + alias Backend.Api.Stats + + action_fallback BackendWeb.DeveloperApiFallbackController + + def archetypes(conn, params) do + with {:ok, payload} <- Stats.archetypes(params) do + json(conn, %{data: payload}) + end + end + + def meta(conn, params) do + with {:ok, payload} <- Stats.meta(params) do + json(conn, %{data: payload}) + end + end + + def archetype(conn, %{"archetype" => archetype} = params) do + with {:ok, payload} <- Stats.archetype(archetype, params) do + json(conn, %{data: payload}) + end + end +end diff --git a/lib/backend_web/controllers/developer_streaming_controller.ex b/lib/backend_web/controllers/developer_streaming_controller.ex new file mode 100644 index 000000000..038a14583 --- /dev/null +++ b/lib/backend_web/controllers/developer_streaming_controller.ex @@ -0,0 +1,25 @@ +defmodule BackendWeb.DeveloperStreamingController do + use Phoenix.Controller, formats: [:json] + + alias Backend.Api.Streaming + + action_fallback BackendWeb.DeveloperApiFallbackController + + def streamers(conn, params) do + with {:ok, payload} <- Streaming.streamers(params) do + json(conn, %{data: payload}) + end + end + + def streamer_decks(conn, params) do + with {:ok, payload} <- Streaming.streamer_decks(params) do + json(conn, %{data: payload}) + end + end + + def live_streams(conn, params) do + with {:ok, payload} <- Streaming.live_streams(params) do + json(conn, %{data: payload}) + end + end +end diff --git a/lib/backend_web/controllers/page_controller.ex b/lib/backend_web/controllers/page_controller.ex index d17f90503..e9195d622 100644 --- a/lib/backend_web/controllers/page_controller.ex +++ b/lib/backend_web/controllers/page_controller.ex @@ -19,6 +19,10 @@ defmodule BackendWeb.PageController do render(conn, "about.html") end + def api_docs(conn, _params) do + render(conn, "api_docs.html", page_title: "HSGuru Developer API") + end + def privacy(conn, _params) do render(conn, "privacy.html") end diff --git a/lib/backend_web/live/profile_settings.ex b/lib/backend_web/live/profile_settings.ex index d50fd6ce6..b2d4616d8 100644 --- a/lib/backend_web/live/profile_settings.ex +++ b/lib/backend_web/live/profile_settings.ex @@ -1,17 +1,24 @@ defmodule BackendWeb.ProfileSettingsLive do @moduledoc false use BackendWeb, :surface_live_view + alias Backend.Api + alias Backend.CollectionManager.Collection alias Backend.Streaming alias Backend.UserManager alias Backend.UserManager.User - alias Backend.CollectionManager.Collection alias Backend.UserManager.User.DecklistOptions data(user, :map) data(custom_hues, :boolean, default: false) + data(api_key, :any, default: nil) + data(revealed_api_key, :string, default: nil) def mount(_params, session, socket) do - {:ok, assign_defaults(socket, session) |> put_user_in_context() |> assign_custom_hues()} + {:ok, + assign_defaults(socket, session) + |> put_user_in_context() + |> assign_custom_hues() + |> assign_developer_api_key()} end def render(assigns) do @@ -178,6 +185,45 @@ defmodule BackendWeb.ProfileSettingsLive do + +
+
+
+

Developer API

+

Use an API key to access public HSGuru statistics and deck data.

+
+ Open API Docs +
+ +
+
+

Copy this key now

+

For security, the complete key will not be shown again.

+
+
+ {@revealed_api_key} + +
+
+ +
+
+ Active key + {masked_api_key(@api_key)} + Created {api_key_created_at(@api_key)} +
+
+ + +
+
+ +
+

No API key has been created for this Battle.net account.

+ +
+
+

Misc Settings

@@ -261,6 +307,32 @@ defmodule BackendWeb.ProfileSettingsLive do {:noreply, socket |> assign(:user, updated)} end + def handle_event("generate_api_key", _, %{assigns: %{user: %User{} = user}} = socket) do + create_developer_api_key(socket, user) + end + + def handle_event("rotate_api_key", _, %{assigns: %{user: %User{} = user}} = socket) do + create_developer_api_key(socket, user) + end + + def handle_event("revoke_api_key", _, %{assigns: %{user: %User{} = user}} = socket) do + case Api.revoke_developer_api_key(user) do + :ok -> + {:noreply, + socket + |> assign(api_key: nil, revealed_api_key: nil) + |> put_flash(:info, "API key revoked")} + + {:error, _reason} -> + {:noreply, put_flash(socket, :error, "Could not revoke API key")} + end + end + + def handle_event(event, _, socket) + when event in ["generate_api_key", "rotate_api_key", "revoke_api_key"] do + {:noreply, put_flash(socket, :error, "Sign in with Battle.net to manage an API key")} + end + def handle_event("submit", attrs_raw, %{assigns: %{user: user}} = socket) do attrs = attrs_raw @@ -287,6 +359,33 @@ defmodule BackendWeb.ProfileSettingsLive do assign(socket, custom_hues: custom_hues) end + def assign_developer_api_key(%{assigns: %{user: %User{} = user}} = socket) do + assign(socket, :api_key, Api.get_active_developer_api_key(user)) + end + + def assign_developer_api_key(socket), do: assign(socket, :api_key, nil) + + def masked_api_key(%{token_prefix: token_prefix}), do: token_prefix <> ".••••••••" + + def api_key_created_at(%{inserted_at: %NaiveDateTime{} = inserted_at}) do + Calendar.strftime(inserted_at, "%B %d, %Y") + end + + def api_key_created_at(_), do: "recently" + + defp create_developer_api_key(socket, user) do + case Api.create_developer_api_key(user) do + {:ok, %{api_key: api_key, token: token}} -> + {:noreply, + socket + |> assign(api_key: api_key, revealed_api_key: token) + |> put_flash(:info, "API key created")} + + {:error, _reason} -> + {:noreply, put_flash(socket, :error, "Could not create an API key")} + end + end + defp parse_int(attrs, keys) do Enum.reduce(keys, attrs, fn key, acc -> Map.update(acc, key, nil, &Util.to_int_or_orig/1) diff --git a/lib/backend_web/plug/api_key_auth.ex b/lib/backend_web/plug/api_key_auth.ex new file mode 100644 index 000000000..3c7a88e37 --- /dev/null +++ b/lib/backend_web/plug/api_key_auth.ex @@ -0,0 +1,47 @@ +defmodule BackendWeb.Plug.ApiKeyAuth do + @moduledoc "Authenticates developer API requests with a user-owned API key." + + import Plug.Conn + import Phoenix.Controller, only: [json: 2] + + def init(opts), do: opts + + def call(conn, _opts) do + with {:ok, api_key} <- fetch_api_key(conn), + {:ok, developer_api_key} <- Backend.Api.verify_developer_api_key(api_key) do + conn + |> assign(:developer_api_key, developer_api_key) + |> assign(:developer_api_user, developer_api_key.user) + else + _ -> unauthorized(conn) + end + end + + defp fetch_api_key(conn) do + case get_req_header(conn, "authorization") do + [authorization] -> parse_bearer_token(authorization, conn) + _ -> fetch_api_key_header(conn) + end + end + + defp parse_bearer_token(authorization, conn) do + case Regex.run(~r/^Bearer\s+(.+)$/i, authorization, capture: :all_but_first) do + [api_key] -> {:ok, String.trim(api_key)} + _ -> fetch_api_key_header(conn) + end + end + + defp fetch_api_key_header(conn) do + case get_req_header(conn, "x-api-key") do + [api_key] when api_key != "" -> {:ok, api_key} + _ -> {:error, :missing_api_key} + end + end + + defp unauthorized(conn) do + conn + |> put_status(:unauthorized) + |> json(%{error: %{code: "invalid_api_key", message: "A valid API key is required"}}) + |> halt() + end +end diff --git a/lib/backend_web/plug/api_rate_limit.ex b/lib/backend_web/plug/api_rate_limit.ex new file mode 100644 index 000000000..907bc62e2 --- /dev/null +++ b/lib/backend_web/plug/api_rate_limit.ex @@ -0,0 +1,53 @@ +defmodule BackendWeb.Plug.ApiRateLimit do + @moduledoc "Applies the configured per-key limit to developer API requests." + + import Plug.Conn + import Phoenix.Controller, only: [json: 2] + + alias Backend.Api.RateLimiter + + @default_limit 60 + @default_window_ms :timer.minutes(1) + + def init(opts), do: opts + + def call(%{assigns: %{developer_api_key: api_key}} = conn, opts) do + config = Application.get_env(:backend, :developer_api, []) + limit = Keyword.get(opts, :limit, Keyword.get(config, :rate_limit, @default_limit)) + window_ms = Keyword.get(opts, :window_ms, Keyword.get(config, :window_ms, @default_window_ms)) + + case RateLimiter.hit(api_key.id, limit, window_ms) do + {:allow, remaining, reset_after_ms} -> + put_rate_limit_headers(conn, limit, remaining, reset_after_ms) + + {:deny, retry_after_ms} -> + retry_after = milliseconds_to_seconds(retry_after_ms) + + conn + |> put_rate_limit_headers(limit, 0, retry_after_ms) + |> put_resp_header("retry-after", to_string(retry_after)) + |> put_status(:too_many_requests) + |> json(%{ + error: %{ + code: "rate_limit_exceeded", + message: "Rate limit exceeded", + retry_after: retry_after + } + }) + |> halt() + end + end + + def call(conn, _opts), do: conn + + defp put_rate_limit_headers(conn, limit, remaining, reset_after_ms) do + conn + |> put_resp_header("x-ratelimit-limit", to_string(limit)) + |> put_resp_header("x-ratelimit-remaining", to_string(remaining)) + |> put_resp_header("x-ratelimit-reset", to_string(milliseconds_to_seconds(reset_after_ms))) + end + + defp milliseconds_to_seconds(milliseconds) do + ceil(milliseconds / 1000) + end +end diff --git a/lib/backend_web/router.ex b/lib/backend_web/router.ex index 34519853e..600edb666 100644 --- a/lib/backend_web/router.ex +++ b/lib/backend_web/router.ex @@ -59,6 +59,12 @@ defmodule BackendWeb.Router do plug(:accepts, ["json"]) end + pipeline :developer_api do + plug(:accepts, ["json"]) + plug(BackendWeb.Plug.ApiKeyAuth) + plug(BackendWeb.Plug.ApiRateLimit) + end + # defp api_auth(conn, _opts) do # with {user, pass} <- Plug.BasicAuth.parse_basic_auth(conn), # {:ok, api_user} <- Backend.Api.verify_user(user, pass) do @@ -131,6 +137,18 @@ defmodule BackendWeb.Router do get("/cards/metadata", CardsController, :metadata) end + scope "/api/v1", BackendWeb do + pipe_through([:developer_api]) + get("/meta", DeveloperStatsController, :meta) + get("/archetypes", DeveloperStatsController, :archetypes) + get("/archetypes/:archetype", DeveloperStatsController, :archetype) + get("/decks", DeveloperDeckController, :index) + get("/streamers", DeveloperStreamingController, :streamers) + get("/streamers/:twitch_login/decks", DeveloperStreamingController, :streamer_decks) + get("/streamer-decks", DeveloperStreamingController, :streamer_decks) + get("/streams/live", DeveloperStreamingController, :live_streams) + end + scope "/admin", BackendWeb do pipe_through([:browser, :auth, :super_admin]) oban_dashboard("/oban") @@ -178,6 +196,7 @@ defmodule BackendWeb.Router do live("/", FeedLive) get("/incubator", PageController, :incubator) get("/about", PageController, :about) + get("/api-docs", PageController, :api_docs) get("/donate-follow", PageController, :donate_follow) get("/privacy", PageController, :privacy) diff --git a/lib/backend_web/templates/layout/navbar.html.heex b/lib/backend_web/templates/layout/navbar.html.heex index 623a99593..1bc71352b 100644 --- a/lib/backend_web/templates/layout/navbar.html.heex +++ b/lib/backend_web/templates/layout/navbar.html.heex @@ -99,6 +99,7 @@ <.navbar_item_link link={~p"/chat-bot-command-hook/help"} display={"Chat Bot Hooks"} /> <.navbar_item_link link={~p"/discord-bot"} display={"Discord Bot"} /> <.navbar_item_link link={~p"/hdt-plugin"} display={"HDT Plugin"} /> + <.navbar_item_link link={~p"/api-docs"} display={"API Docs"} /> <.navbar_item_link link={~p"/about"} display={"About"} /> <.current_giveaway /> diff --git a/lib/backend_web/templates/page/api_docs.html.heex b/lib/backend_web/templates/page/api_docs.html.heex new file mode 100644 index 000000000..c28bb4654 --- /dev/null +++ b/lib/backend_web/templates/page/api_docs.html.heex @@ -0,0 +1,237 @@ +
+
+
+
+
+ API v1 + Public aggregated Hearthstone data +
+

HSGuru Developer API

+

+ Build tools with the same public data used by the HSGuru Meta, Archetype, Decks, and Streaming pages. +

+ +
+
+ +
+ + +
+
+

Quick Start

+

Sign in with Battle.net, generate a key in Profile Settings, and send it as a Bearer token.

+
+
curl "https://www.hsguru.com/api/v1/meta?format=2&rank=legend" \
+  -H "Authorization: Bearer hsg_live_your_key"
+
+

All successful responses use a top-level data property.

+
+ +
+

Authentication

+

API keys belong to your Battle.net account. The complete key is displayed once and can be rotated or revoked from Profile Settings.

+
+
+

Recommended

+ Authorization: Bearer <api-key> +
+
+

Alternative

+ X-API-Key: <api-key> +
+
+
+ +
+

Rate Limits

+

Each key is limited to 60 requests per minute by default. Limits may be adjusted as the API evolves.

+
+ <.api_doc_metric name="X-RateLimit-Limit" description="Requests allowed per window" /> + <.api_doc_metric name="X-RateLimit-Remaining" description="Requests left in this window" /> + <.api_doc_metric name="X-RateLimit-Reset" description="Seconds until reset" /> + <.api_doc_metric name="Retry-After" description="Wait time after a 429 response" /> +
+
+ +
+

Response Format

+

+ Successful responses use a top-level data property. Win rates are ratios from 0 to 1, timestamps use ISO 8601, and unavailable values are returned as null. +

+
+
{Jason.encode!(%{data: %{streamer_decks: [%{streamer: %{login: "thijs"}, deck: %{id: 123, deckcode: "AAECA..."}, performance: %{wins: 18, losses: 12, winrate: 0.6}}], pagination: %{limit: 50, offset: 0, next_offset: 50}}}, pretty: true)}
+
+
+ +
+ <.api_doc_endpoint method="GET" path="/api/v1/meta" summary="Archetype win rates, popularity, game counts, duration, turns, and climbing speed." /> + <.api_doc_parameters rows={[ + {"format", "integer", "Hearthstone format. Standard is 2 and Wild is 1."}, + {"period", "string", "An available aggregate period slug."}, + {"rank", "string", "Rank bracket such as legend, top_legend, or another available public rank slug."}, + {"opponent_class[]", "string[]", "One or more opponent classes."}, + {"min_games", "integer", "Minimum archetype sample size. Default: 1000."}, + {"player_has_coin", "enum", "any, yes, or no."}, + {"sort_by", "enum", "winrate, total, turns, duration, or climbing_speed."} + ]} /> +

Array filters accept up to 25 values. Do not combine any with specific classes.

+
+ +
+ <.api_doc_endpoint method="GET" path="/api/v1/archetypes" summary="All currently aggregated Standard and Wild archetypes used by HSGuru filters." /> + <.api_doc_parameters rows={[ + {"format", "integer|string", "Optional. Use 2 or standard, 1 or wild. Omit it to return both formats."} + ]} /> +
+ +
+ <.api_doc_endpoint method="GET" path="/api/v1/archetypes/:archetype" summary="Overall stats, class matchups, and card performance for one archetype." /> + <.api_doc_parameters rows={[ + {"format", "integer", "Standard is 2 and Wild is 1."}, + {"period", "string", "An available aggregate period slug."}, + {"rank", "string", "An available rank slug."}, + {"opponent_class", "string", "Limit results to one opponent class."}, + {"player_has_coin", "enum", "any, yes, or no."}, + {"min_mull_count", "integer", "Minimum mulligan sample size."}, + {"min_drawn_count", "integer", "Minimum drawn sample size."}, + {"sort_by", "enum", "card, mull_impact, mull_count, drawn_impact, drawn_count, kept_impact, kept_count, not_drawn_impact, or not_drawn_count."}, + {"sort_direction", "enum", "asc or desc."} + ]} /> +
+ +
+ <.api_doc_endpoint method="GET" path="/api/v1/decks" summary="A cursor-paginated feed of the newest decks visible through public HSGuru aggregates." /> +
+ Results are always ordered by deck creation time and ID. Use pagination.next_cursor unchanged to request the next page. +
+ <.api_doc_parameters rows={[ + {"format", "integer|string", "Standard (2) or Wild (1)."}, + {"period", "string", "An available aggregate period slug."}, + {"rank", "string", "An available rank slug."}, + {"player_class[]", "string[]", "One or more player classes."}, + {"opponent_class[]", "string[]", "One or more opponent classes, or any."}, + {"player_deck_archetype[]", "string[]", "One or more deck archetypes."}, + {"player_has_coin", "enum", "any, yes, or no."}, + {"min_games", "integer", "Minimum sample size. Default: 200; minimum: 50."}, + {"min_winrate", "number", "Minimum win rate as a ratio or percentage."}, + {"includes_latest_set", "boolean", "Use yes or true."}, + {"player_deck_includes[]", "integer[]", "DBF IDs that must be present."}, + {"player_deck_excludes[]", "integer[]", "DBF IDs that must not be present."}, + {"limit", "integer", "Page size from 1 to 100. Default: 20."}, + {"cursor", "string", "Opaque cursor returned by the previous response."} + ]} /> +

Class, archetype, include-card, and exclude-card arrays accept up to 25 values.

+
+
curl "https://www.hsguru.com/api/v1/decks?format=1&rank=legend&player_class[]=MAGE&min_games=100" \
+  -H "Authorization: Bearer hsg_live_your_key"
+
+
+ +
+ <.api_doc_endpoint method="GET" path="/api/v1/streamers" summary="A paginated catalog of every known streamer with aggregate deck and performance statistics." /> +

+ Each streamer includes deck count, recorded wins and losses, win rate, minutes played, best Legend rank, and most recent play time. +

+ <.api_doc_parameters rows={[ + {"search", "string", "Search Twitch login or display name."}, + {"twitch_login[]", "string[]", "Return specific Twitch channels."}, + {"sort_by", "enum", "display_name, login, or created_at."}, + {"sort_direction", "enum", "asc or desc."}, + {"limit", "integer", "Page size from 1 to 100. Default: 50."}, + {"offset", "integer", "Page offset from 0 to 100000. Use pagination.next_offset for the next page."} + ]} /> +
+
curl "https://www.hsguru.com/api/v1/streamers?limit=100" \
+  -H "Authorization: Bearer hsg_live_your_key"
+
+
+ +
+ <.api_doc_endpoint method="GET" path="/api/v1/streamers/:twitch_login/decks" summary="All recorded decks for one streamer, including decklists and performance statistics." /> +
+ <.api_doc_endpoint method="GET" path="/api/v1/streamer-decks" summary="The same dataset across every streamer. Filter by one or more Twitch logins or IDs." /> +
+

+ Every result includes the full deckcode and card list, first and last play time, wins, losses, win rate, minutes played, and best, latest, and worst Legend ranks. +

+ <.api_doc_parameters rows={[ + {"twitch_login[]", "string[]", "One or more Twitch logins. The path endpoint supplies this automatically."}, + {"twitch_id[]", "integer[]", "One or more Twitch user IDs."}, + {"format", "integer|string", "Wild (1), Standard (2), Classic (3), or Twist (4)."}, + {"class", "string", "A Hearthstone player class."}, + {"best_legend_rank", "integer", "Maximum best Legend rank, for example 500."}, + {"latest_legend_rank", "integer", "Maximum latest Legend rank."}, + {"worst_legend_rank", "integer", "Maximum worst Legend rank."}, + {"last_played_within_minutes", "integer", "Only decks played during this recent time window."}, + {"first_played_within_minutes", "integer", "Only decks first seen during this recent time window."}, + {"min_minutes_played", "integer", "Minimum estimated minutes played."}, + {"deck_id", "integer", "Return one HSGuru deck ID."}, + {"include_cards[]", "integer[]", "DBF IDs that must be present."}, + {"exclude_cards[]", "integer[]", "DBF IDs that must not be present."}, + {"sort_by", "enum", "last_played, first_played, best_legend_rank, latest_legend_rank, worst_legend_rank, minutes_played, wins, or losses."}, + {"sort_direction", "enum", "asc or desc."}, + {"limit", "integer", "Page size from 1 to 100. Default: 50."}, + {"offset", "integer", "Page offset from 0 to 100000. Use pagination.next_offset for the next page."} + ]} /> +

Twitch login, Twitch ID, include-card, and exclude-card arrays accept up to 25 values.

+
+
curl "https://www.hsguru.com/api/v1/streamers/thijs/decks?format=2&last_played_within_minutes=43200" \
+  -H "Authorization: Bearer hsg_live_your_key"
+
+
+ +
+ <.api_doc_endpoint method="GET" path="/api/v1/streams/live" summary="Current Hearthstone Twitch streams known to HSGuru, optionally including the active deckcode." /> + <.api_doc_parameters rows={[ + {"mode", "string", "Hearthstone mode, such as Standard, Wild, or Battlegrounds."}, + {"language", "string", "Twitch stream language code."}, + {"legend_rank", "integer", "Maximum current Legend rank."}, + {"deckcode", "string", "Return streams playing one exact deckcode."}, + {"has_deck", "enum", "any, yes, or no."}, + {"sort_by", "enum", "most_viewers, fewest_viewers, newest, or oldest."}, + {"limit", "integer", "Maximum results from 1 to 100. Default: 50."} + ]} /> +
+ +
+

Errors

+
+ + + + + + + + + + + +
StatusMeaning
400Invalid or unsupported parameter.
401Missing, invalid, or revoked API key.
404Archetype not found.
422The filter combination is not available in public aggregates.
429Rate limit exceeded.
+
+
+
+
+
diff --git a/lib/backend_web/views/page_view.ex b/lib/backend_web/views/page_view.ex index 62de7fa66..9cc7f9384 100644 --- a/lib/backend_web/views/page_view.ex +++ b/lib/backend_web/views/page_view.ex @@ -1,4 +1,48 @@ defmodule BackendWeb.PageView do use BackendWeb, :view import FunctionComponents.MiscComponents + + def api_doc_metric(assigns) do + ~H""" +
+ {@name} +

{@description}

+
+ """ + end + + def api_doc_endpoint(assigns) do + ~H""" +
+
+ {@method} + {@path} +
+

{@summary}

+
+ """ + end + + def api_doc_parameters(assigns) do + ~H""" +
+ + + + + + + + + + + + + + + +
ParameterTypeDescription
{name}{type}{description}
+
+ """ + end end diff --git a/priv/repo/migrations/20260722000100_create_developer_api_keys.exs b/priv/repo/migrations/20260722000100_create_developer_api_keys.exs new file mode 100644 index 000000000..11c6ae854 --- /dev/null +++ b/priv/repo/migrations/20260722000100_create_developer_api_keys.exs @@ -0,0 +1,21 @@ +defmodule Backend.Repo.Migrations.CreateDeveloperApiKeys do + use Ecto.Migration + + def change do + create table(:developer_api_keys) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :token_prefix, :string, null: false + add :token_digest, :binary, null: false + add :revoked_at, :naive_datetime + + timestamps() + end + + create unique_index(:developer_api_keys, [:token_prefix]) + + create unique_index(:developer_api_keys, [:user_id], + where: "revoked_at IS NULL", + name: :developer_api_keys_one_active_per_user + ) + end +end diff --git a/test/backend/api/rate_limiter_test.exs b/test/backend/api/rate_limiter_test.exs new file mode 100644 index 000000000..3c67738f8 --- /dev/null +++ b/test/backend/api/rate_limiter_test.exs @@ -0,0 +1,17 @@ +defmodule Backend.Api.RateLimiterTest do + use ExUnit.Case, async: false + + alias Backend.Api.RateLimiter + + test "limits each key independently" do + first_key = {:first, System.unique_integer([:positive])} + second_key = {:second, System.unique_integer([:positive])} + + assert {:allow, 1, _reset_after_ms} = RateLimiter.hit(first_key, 2, 60_000) + assert {:allow, 0, _reset_after_ms} = RateLimiter.hit(first_key, 2, 60_000) + assert {:deny, retry_after_ms} = RateLimiter.hit(first_key, 2, 60_000) + assert retry_after_ms > 0 + + assert {:allow, 1, _reset_after_ms} = RateLimiter.hit(second_key, 2, 60_000) + end +end diff --git a/test/backend/api/streaming_test.exs b/test/backend/api/streaming_test.exs new file mode 100644 index 000000000..4926baa2b --- /dev/null +++ b/test/backend/api/streaming_test.exs @@ -0,0 +1,51 @@ +defmodule Backend.Api.StreamingTest do + use ExUnit.Case, async: true + + alias Backend.Api.Streaming + alias Hearthstone.Enums.BnetGameType + + test "filters and sorts current streams" do + now = DateTime.utc_now() + + streams = [ + stream("first", 50, now, BnetGameType.ranked_standard(), "deckcode"), + stream("second", 150, DateTime.add(now, -60), BnetGameType.ranked_wild(), nil) + ] + + assert {:ok, %{streams: [result], total: 1}} = + Streaming.live_streams( + %{"mode" => "Standard", "has_deck" => "yes", "sort_by" => "most_viewers"}, + streams + ) + + assert result.display_name == "first" + assert result.deckcode == "deckcode" + assert result.mode == "Standard" + end + + test "rejects unsupported streaming parameters" do + assert {:error, {:invalid_parameter, "private", "is not supported"}} = + Streaming.live_streams(%{"private" => "yes"}, []) + end + + test "validates streamer deck list filters" do + assert {:error, {:invalid_parameter, "include_cards", _message}} = + Streaming.streamer_decks(%{"include_cards" => ["not-a-dbf-id"]}) + end + + defp stream(name, viewers, started_at, game_type, deckcode) do + %{ + user_id: name, + user_name: name, + thumbnail_url: "https://example.com/#{name}.jpg", + viewer_count: viewers, + title: "Hearthstone", + language: "en", + started_at: started_at, + legend_rank: 100, + stream_id: name, + deckcode: deckcode, + game_type: game_type + } + end +end diff --git a/test/backend/api_test.exs b/test/backend/api_test.exs index 9b168ec8d..8f7894aab 100644 --- a/test/backend/api_test.exs +++ b/test/backend/api_test.exs @@ -81,4 +81,44 @@ defmodule Backend.ApiTest do assert %Ecto.Changeset{} = Api.change_api_user(api_user) end end + + describe "developer API keys" do + test "creates a one-time key without persisting its plaintext secret" do + user = create_temp_user() + + assert {:ok, %{api_key: api_key, token: token}} = Api.create_developer_api_key(user) + assert String.starts_with?(token, api_key.token_prefix <> ".") + refute api_key.token_digest == token + + assert {:ok, verified} = Api.verify_developer_api_key(token) + assert verified.id == api_key.id + assert verified.user_id == user.id + end + + test "rotating a key revokes the previous token" do + user = create_temp_user() + {:ok, %{api_key: first_key, token: first_token}} = Api.create_developer_api_key(user) + + assert {:ok, %{api_key: second_key, token: second_token}} = + Api.create_developer_api_key(user) + + refute first_key.id == second_key.id + assert {:error, :invalid_api_key} = Api.verify_developer_api_key(first_token) + assert {:ok, verified} = Api.verify_developer_api_key(second_token) + assert verified.id == second_key.id + assert Api.get_active_developer_api_key(user).id == second_key.id + end + + test "revokes only the key owned by the supplied user" do + first_user = create_temp_user() + second_user = create_temp_user() + {:ok, %{token: first_token}} = Api.create_developer_api_key(first_user) + {:ok, %{api_key: second_key, token: second_token}} = Api.create_developer_api_key(second_user) + + assert :ok = Api.revoke_developer_api_key(first_user) + assert {:error, :invalid_api_key} = Api.verify_developer_api_key(first_token) + assert {:ok, verified} = Api.verify_developer_api_key(second_token) + assert verified.id == second_key.id + end + end end diff --git a/test/backend_web/controllers/developer_stats_controller_test.exs b/test/backend_web/controllers/developer_stats_controller_test.exs new file mode 100644 index 000000000..6d835c364 --- /dev/null +++ b/test/backend_web/controllers/developer_stats_controller_test.exs @@ -0,0 +1,328 @@ +defmodule BackendWeb.DeveloperStatsControllerTest do + use BackendWeb.ConnCase + + alias Backend.Hearthstone.Deck + alias Backend.Repo + + @aggregate_table "dt_past_30_days_2_aggregated_stats" + + setup %{conn: conn} do + user = create_temp_user() + {:ok, %{token: token}} = Backend.Api.create_developer_api_key(user) + {:ok, conn: put_req_header(conn, "x-api-key", token)} + end + + test "requires an API key" do + conn = get(build_conn(), "/api/v1/meta") + + assert conn.status == 401 + assert %{"error" => %{"code" => "invalid_api_key"}} = json_response(conn, 401) + end + + test "rejects unsupported meta parameters", %{conn: conn} do + conn = get(conn, "/api/v1/meta?force_fresh=yes") + + assert %{ + "error" => %{ + "code" => "invalid_parameter", + "parameter" => "force_fresh" + } + } = json_response(conn, 400) + end + + test "rejects non-public archetype parameters", %{conn: conn} do + conn = get(conn, "/api/v1/archetypes/Control%20Priest?region[]=EU") + + assert %{ + "error" => %{ + "code" => "invalid_parameter", + "parameter" => "region" + } + } = json_response(conn, 400) + end + + test "rejects unsupported deck parameters", %{conn: conn} do + conn = get(conn, "/api/v1/decks?force_fresh=yes") + + assert %{ + "error" => %{ + "code" => "invalid_parameter", + "parameter" => "force_fresh" + } + } = json_response(conn, 400) + end + + test "rejects deck integers outside the database range", %{conn: conn} do + conn = get(conn, "/api/v1/decks?min_games=2147483648") + + assert %{ + "error" => %{ + "code" => "invalid_parameter", + "parameter" => "min_games" + } + } = json_response(conn, 400) + end + + test "validates Standard and Wild archetype catalog formats", %{conn: conn} do + conn = get(conn, "/api/v1/archetypes?format=twist") + + assert %{ + "error" => %{ + "code" => "invalid_parameter", + "parameter" => "format" + } + } = json_response(conn, 400) + end + + test "validates meta format before querying aggregates", %{conn: conn} do + conn = get(conn, "/api/v1/meta?format=4") + + assert %{ + "error" => %{ + "code" => "invalid_parameter", + "parameter" => "format" + } + } = json_response(conn, 400) + end + + test "validates public rank slugs", %{conn: conn} do + conn = get(conn, "/api/v1/meta?rank=not-a-rank") + + assert %{ + "error" => %{ + "code" => "invalid_parameter", + "parameter" => "rank" + } + } = json_response(conn, 400) + end + + test "validates public period slugs", %{conn: conn} do + conn = get(conn, "/api/v1/meta?period=not-a-period") + + assert %{ + "error" => %{ + "code" => "invalid_parameter", + "parameter" => "period" + } + } = json_response(conn, 400) + end + + test "rejects malformed deck class collections without crashing", %{conn: conn} do + conn = get(conn, "/api/v1/decks?player_class[MAGE]=true") + + assert %{ + "error" => %{ + "code" => "invalid_parameter", + "parameter" => "player_class" + } + } = json_response(conn, 400) + end + + test "rejects any combined with specific opponent classes", %{conn: conn} do + conn = get(conn, "/api/v1/meta?opponent_class[]=any&opponent_class[]=MAGE") + + assert %{ + "error" => %{ + "code" => "invalid_parameter", + "parameter" => "opponent_class" + } + } = json_response(conn, 400) + end + + test "returns public meta aggregates", %{conn: conn} do + create_aggregate_table() + insert_aggregate_row(nil, "Test Mage", "any") + + conn = + get( + conn, + "/api/v1/meta?format=2&period=past_30_days&rank=legend&min_games=1000" + ) + + assert %{ + "data" => %{ + "total_games" => 2000, + "archetypes" => [ + %{ + "archetype" => "Test Mage", + "games" => 2000, + "wins" => 1200, + "losses" => 800, + "winrate" => 0.6, + "popularity" => 1.0 + } + ] + } + } = json_response(conn, 200) + end + + test "returns archetype stats when card stats are not yet populated", %{conn: conn} do + create_aggregate_table() + insert_aggregate_row(nil, "Test Mage", "any") + insert_aggregate_row(nil, "Test Mage", "MAGE", 300, 200) + + conn = + get( + conn, + "/api/v1/archetypes/Test%20Mage?format=2&period=past_30_days&rank=legend" + ) + + assert %{ + "data" => %{ + "archetype" => "Test Mage", + "stats" => %{"games" => 2000, "winrate" => 0.6}, + "cards" => [], + "matchups" => [ + %{"opponent_class" => "MAGE", "games" => 500, "winrate" => 0.6} + ] + } + } = json_response(conn, 200) + end + + test "returns newest public decks with a stable response shape", %{conn: conn} do + create_aggregate_table() + + deck = + Repo.insert!(%Deck{ + cards: [1, 1, 2], + deckcode: "developer-api-deck", + format: 2, + hero: 637, + class: "MAGE", + archetype: "Test Mage", + cost: 1600 + }) + + insert_aggregate_row(deck.id, "any", "any") + + conn = + get( + conn, + "/api/v1/decks?format=2&period=past_30_days&rank=legend&min_games=50" + ) + + assert %{ + "data" => %{ + "decks" => [ + %{ + "id" => deck_id, + "deckcode" => "developer-api-deck", + "class" => "MAGE", + "archetype" => "Test Mage", + "url" => url, + "stats" => %{"games" => 2000, "winrate" => 0.6} + } + ], + "pagination" => %{"limit" => 20, "next_cursor" => nil} + } + } = json_response(conn, 200) + + assert deck_id == deck.id + assert url == "https://www.hsguru.com/deck/#{deck.id}" + end + + test "paginates newest decks without returning duplicates", %{conn: conn} do + create_aggregate_table() + + older = + Repo.insert!(%Deck{ + cards: [1, 2], + deckcode: "older-developer-api-deck", + format: 2, + hero: 637, + class: "MAGE", + archetype: "Test Mage", + cost: 1200, + inserted_at: ~N[2026-07-21 12:00:00], + updated_at: ~N[2026-07-21 12:00:00] + }) + + newer = + Repo.insert!(%Deck{ + cards: [3, 4], + deckcode: "newer-developer-api-deck", + format: 2, + hero: 637, + class: "MAGE", + archetype: "Test Mage", + cost: 1400, + inserted_at: ~N[2026-07-22 12:00:00], + updated_at: ~N[2026-07-22 12:00:00] + }) + + insert_aggregate_row(older.id, "any", "any") + insert_aggregate_row(newer.id, "any", "any") + + first_page = + conn + |> get("/api/v1/decks?format=2&period=past_30_days&rank=legend&min_games=50&limit=1") + |> json_response(200) + + assert %{ + "data" => %{ + "decks" => [%{"id" => first_id}], + "pagination" => %{"next_cursor" => cursor} + } + } = first_page + + assert first_id == newer.id + assert is_binary(cursor) + + second_page = + conn + |> get( + "/api/v1/decks?format=2&period=past_30_days&rank=legend&min_games=50&limit=1&cursor=#{URI.encode_www_form(cursor)}" + ) + |> json_response(200) + + assert %{ + "data" => %{ + "decks" => [%{"id" => second_id}], + "pagination" => %{"next_cursor" => nil} + } + } = second_page + + assert second_id == older.id + end + + defp create_aggregate_table do + Repo.query!(""" + CREATE TABLE IF NOT EXISTS #{@aggregate_table} ( + id bigserial PRIMARY KEY, + deck_id integer, + rank varchar, + opponent_class varchar, + archetype varchar, + format integer, + winrate double precision, + wins integer, + losses integer, + total integer, + turns double precision, + duration double precision, + climbing_speed double precision, + player_has_coin boolean, + card_stats jsonb + ) + """) + + timestamp = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) + Repo.query!("COMMENT ON TABLE #{@aggregate_table} IS '#{timestamp}'") + end + + defp insert_aggregate_row(deck_id, archetype, opponent_class, wins \\ 1200, losses \\ 800) do + total = wins + losses + + Repo.query!( + """ + INSERT INTO #{@aggregate_table} ( + deck_id, rank, opponent_class, archetype, format, winrate, wins, losses, + total, turns, duration, climbing_speed, player_has_coin, card_stats + ) VALUES ( + $1, 'legend', $2, $3, 2, $4, $5, $6, $7, 8.5, 600.0, 2.1, NULL, NULL + ) + """, + [deck_id, opponent_class, archetype, wins / total, wins, losses, total] + ) + end +end diff --git a/test/backend_web/controllers/developer_streaming_controller_test.exs b/test/backend_web/controllers/developer_streaming_controller_test.exs new file mode 100644 index 000000000..51a11a064 --- /dev/null +++ b/test/backend_web/controllers/developer_streaming_controller_test.exs @@ -0,0 +1,137 @@ +defmodule BackendWeb.DeveloperStreamingControllerTest do + use BackendWeb.ConnCase + + alias Backend.Hearthstone.Deck + alias Backend.Repo + alias Backend.Streaming.Streamer + alias Backend.Streaming.StreamerDeck + alias Hearthstone.Enums.BnetGameType + + setup %{conn: conn} do + user = create_temp_user() + {:ok, %{token: token}} = Backend.Api.create_developer_api_key(user) + + {:ok, conn: put_req_header(conn, "x-api-key", token)} + end + + test "lists streamers with aggregate stats", %{conn: conn} do + insert_streamer_deck_fixture() + + conn = get(conn, "/api/v1/streamers?search=api_streamer") + + assert %{ + "data" => %{ + "streamers" => [ + %{ + "login" => "api_streamer", + "stats" => %{ + "deck_count" => 1, + "recorded_games" => 4, + "wins" => 3, + "losses" => 1, + "winrate" => 0.75 + } + } + ] + } + } = json_response(conn, 200) + end + + test "lists every recorded deck for one streamer", %{conn: conn} do + insert_streamer_deck_fixture() + + conn = get(conn, "/api/v1/streamers/API_STREAMER/decks?format=2") + + assert %{ + "data" => %{ + "streamer_decks" => [ + %{ + "streamer" => %{"login" => "api_streamer"}, + "deck" => %{"deckcode" => "api-streamer-deck", "format" => %{"id" => 2}}, + "performance" => %{ + "minutes_played" => 90, + "wins" => 3, + "losses" => 1, + "winrate" => 0.75 + }, + "ranks" => %{ + "best_legend" => 120, + "latest_legend" => 180, + "worst_legend" => 400 + } + } + ] + } + } = json_response(conn, 200) + end + + test "uses HSReplay Twitch fields when direct Twitch fields are unavailable", %{conn: conn} do + insert_streamer_deck_fixture(%{ + twitch_login: nil, + twitch_display: nil, + hsreplay_twitch_login: "fallback_streamer", + hsreplay_twitch_display: "Fallback Streamer" + }) + + conn = get(conn, "/api/v1/streamers?search=FALLBACK_STREAMER") + + assert %{ + "data" => %{ + "streamers" => [ + %{"login" => "fallback_streamer", "display_name" => "Fallback Streamer"} + ] + } + } = json_response(conn, 200) + end + + test "rejects invalid streamer deck filters", %{conn: conn} do + conn = get(conn, "/api/v1/streamer-decks?class=INVALID") + + assert %{ + "error" => %{ + "code" => "invalid_parameter", + "parameter" => "class" + } + } = json_response(conn, 400) + end + + defp insert_streamer_deck_fixture(streamer_attrs \\ %{}) do + streamer = + %{ + twitch_id: System.unique_integer([:positive]), + twitch_login: "api_streamer", + twitch_display: "API Streamer" + } + |> Map.merge(streamer_attrs) + |> then(&struct!(Streamer, &1)) + |> Repo.insert!() + + deck = + Repo.insert!(%Deck{ + cards: [1, 1, 2], + deckcode: "api-streamer-deck", + format: 2, + hero: 637, + class: "MAGE", + archetype: "Test Mage", + cost: 1600 + }) + + now = DateTime.utc_now() |> DateTime.truncate(:second) + + Repo.insert!(%StreamerDeck{ + streamer_id: streamer.id, + deck_id: deck.id, + best_rank: 1, + best_legend_rank: 120, + latest_legend_rank: 180, + worst_legend_rank: 400, + first_played: now, + last_played: now, + minutes_played: 90, + wins: 3, + losses: 1, + game_type: BnetGameType.ranked_standard() + }) + end +end diff --git a/test/backend_web/controllers/nav_test.exs b/test/backend_web/controllers/nav_test.exs index 98e149b37..3762edad9 100644 --- a/test/backend_web/controllers/nav_test.exs +++ b/test/backend_web/controllers/nav_test.exs @@ -16,6 +16,9 @@ defmodule BackendWeb.NavTest do for link <- @logged_in_links do refute response =~ link end + + assert response =~ ~s(href="/api-docs") + assert response =~ "API Docs" end @tag :authenticated diff --git a/test/backend_web/controllers/page_controller_test.exs b/test/backend_web/controllers/page_controller_test.exs index 5fe99fb7e..652702220 100644 --- a/test/backend_web/controllers/page_controller_test.exs +++ b/test/backend_web/controllers/page_controller_test.exs @@ -5,4 +5,19 @@ defmodule BackendWeb.PageControllerTest do conn = get(conn, "/") assert html_response(conn, 200) =~ "leaderboard" end + + test "GET /api-docs renders the Developer API reference", %{conn: conn} do + conn = get(conn, "/api-docs") + response = html_response(conn, 200) + + assert response =~ "HSGuru Developer API" + assert response =~ "/api/v1/meta" + assert response =~ "/api/v1/archetypes" + assert response =~ "/api/v1/decks" + assert response =~ "/api/v1/streamers" + assert response =~ "/api/v1/streamer-decks" + assert response =~ "/api/v1/streams/live" + assert response =~ "API Docs" + assert response =~ "\nHSGuru Developer API" + end end diff --git a/test/backend_web/live/profile_settings_live_test.exs b/test/backend_web/live/profile_settings_live_test.exs index 667f3948e..175058fd6 100644 --- a/test/backend_web/live/profile_settings_live_test.exs +++ b/test/backend_web/live/profile_settings_live_test.exs @@ -14,7 +14,28 @@ defmodule BackendWeb.ProfileSettingsLiveTest do assert html =~ "Profile" assert html =~ "Settings" assert html =~ "Country Flag" - assert html =~ "Save" + assert html =~ ~s(id="profile_settings_form") + assert html =~ "Developer API" + assert html =~ "Generate API Key" + assert html =~ "/api-docs" + end + + @tag :authenticated + test "creates, rotates, and revokes a developer API key", %{conn: conn, user: user} do + {:ok, view, _html} = live(conn, "/profile/settings") + + assert view |> element("#generate-api-key") |> render_click() =~ "Copy this key now" + assert has_element?(view, "#new-api-key-value") + + first_key = Backend.Api.get_active_developer_api_key(user) + assert first_key + + assert view |> element("#rotate-api-key") |> render_click() =~ "Copy this key now" + second_key = Backend.Api.get_active_developer_api_key(user) + refute first_key.id == second_key.id + + assert view |> element("#revoke-api-key") |> render_click() =~ "Generate API Key" + assert Backend.Api.get_active_developer_api_key(user) == nil end @tag :authenticated diff --git a/test/backend_web/plug/api_key_auth_test.exs b/test/backend_web/plug/api_key_auth_test.exs new file mode 100644 index 000000000..c6116c7c3 --- /dev/null +++ b/test/backend_web/plug/api_key_auth_test.exs @@ -0,0 +1,63 @@ +defmodule BackendWeb.Plug.ApiKeyAuthTest do + use BackendWeb.ConnCase + + alias BackendWeb.Plug.ApiKeyAuth + + setup do + user = create_temp_user() + {:ok, %{api_key: api_key, token: token}} = Backend.Api.create_developer_api_key(user) + + %{api_key: api_key, token: token, user: user} + end + + test "authenticates a bearer API key", %{conn: conn, api_key: api_key, token: token, user: user} do + conn = + conn + |> put_req_header("authorization", "Bearer #{token}") + |> ApiKeyAuth.call([]) + + refute conn.halted + assert conn.assigns.developer_api_key.id == api_key.id + assert conn.assigns.developer_api_user.id == user.id + end + + test "accepts a case-insensitive bearer scheme", %{conn: conn, api_key: api_key, token: token} do + conn = + conn + |> put_req_header("authorization", "bearer #{token}") + |> ApiKeyAuth.call([]) + + refute conn.halted + assert conn.assigns.developer_api_key.id == api_key.id + end + + test "authenticates an x-api-key header", %{conn: conn, api_key: api_key, token: token} do + conn = + conn + |> put_req_header("x-api-key", token) + |> ApiKeyAuth.call([]) + + refute conn.halted + assert conn.assigns.developer_api_key.id == api_key.id + end + + test "returns JSON 401 for an invalid key", %{conn: conn} do + conn = + conn + |> put_req_header("x-api-key", "hsg_live_invalid.invalid") + |> ApiKeyAuth.call([]) + + assert conn.halted + assert conn.status == 401 + assert %{"error" => %{"code" => "invalid_api_key"}} = Jason.decode!(conn.resp_body) + end + + test "returns JSON 401 for a revoked key", %{conn: conn, token: token, user: user} do + :ok = Backend.Api.revoke_developer_api_key(user) + + conn = conn |> put_req_header("x-api-key", token) |> ApiKeyAuth.call([]) + + assert conn.halted + assert conn.status == 401 + end +end diff --git a/test/backend_web/plug/api_rate_limit_test.exs b/test/backend_web/plug/api_rate_limit_test.exs new file mode 100644 index 000000000..3f7e9e792 --- /dev/null +++ b/test/backend_web/plug/api_rate_limit_test.exs @@ -0,0 +1,23 @@ +defmodule BackendWeb.Plug.ApiRateLimitTest do + use BackendWeb.ConnCase + + alias BackendWeb.Plug.ApiRateLimit + + test "returns rate headers and rejects requests over the limit", %{conn: conn} do + api_key = %{id: System.unique_integer([:positive])} + opts = [limit: 1, window_ms: 60_000] + + allowed = conn |> assign(:developer_api_key, api_key) |> ApiRateLimit.call(opts) + + refute allowed.halted + assert get_resp_header(allowed, "x-ratelimit-limit") == ["1"] + assert get_resp_header(allowed, "x-ratelimit-remaining") == ["0"] + + denied = build_conn() |> assign(:developer_api_key, api_key) |> ApiRateLimit.call(opts) + + assert denied.halted + assert denied.status == 429 + assert get_resp_header(denied, "retry-after") != [] + assert %{"error" => %{"code" => "rate_limit_exceeded"}} = Jason.decode!(denied.resp_body) + end +end