diff --git a/lib/wanderer_app_web/controllers/common_api_controller.ex b/lib/wanderer_app_web/controllers/common_api_controller.ex new file mode 100644 index 00000000..b735fa88 --- /dev/null +++ b/lib/wanderer_app_web/controllers/common_api_controller.ex @@ -0,0 +1,63 @@ +defmodule WandererAppWeb.CommonAPIController do + use WandererAppWeb, :controller + + alias WandererApp.CachedInfo + alias WandererAppWeb.UtilAPIController, as: Util + + @doc """ + GET /api/common/system_static?id= + + Requires 'id' (the solar_system_id). + + Example: + GET /api/common/system_static?id=31002229 + """ + def show_system_static(conn, params) do + with {:ok, solar_system_str} <- Util.require_param(params, "id"), + {:ok, solar_system_id} <- Util.parse_int(solar_system_str) do + case CachedInfo.get_system_static_info(solar_system_id) do + {:ok, system} -> + data = static_system_to_json(system) + json(conn, %{data: data}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "System not found"}) + end + else + {:error, msg} -> + conn + |> put_status(:bad_request) + |> json(%{error: msg}) + end + end + + # ---------------------------------------------- + # Private helpers + # ---------------------------------------------- + + defp static_system_to_json(system) do + system + |> Map.take([ + :solar_system_id, + :region_id, + :constellation_id, + :solar_system_name, + :solar_system_name_lc, + :constellation_name, + :region_name, + :system_class, + :security, + :type_description, + :class_title, + :is_shattered, + :effect_name, + :effect_power, + :statics, + :wandering, + :triglavian_invasion_status, + :sun_type_id + ]) + end +end diff --git a/lib/wanderer_app_web/controllers/api_controller.ex b/lib/wanderer_app_web/controllers/map_api_controller.ex similarity index 56% rename from lib/wanderer_app_web/controllers/api_controller.ex rename to lib/wanderer_app_web/controllers/map_api_controller.ex index 498e594f..b7d3e456 100644 --- a/lib/wanderer_app_web/controllers/api_controller.ex +++ b/lib/wanderer_app_web/controllers/map_api_controller.ex @@ -1,87 +1,48 @@ -defmodule WandererAppWeb.APIController do +defmodule WandererAppWeb.MapAPIController do use WandererAppWeb, :controller import Ash.Query, only: [filter: 2] + require Logger alias WandererApp.Api + alias WandererApp.Api.Character alias WandererApp.MapSystemRepo alias WandererApp.MapCharacterSettingsRepo - alias WandererApp.Api.Character - alias WandererApp.CachedInfo + alias WandererApp.Zkb.KillsProvider.KillsCache -# ----------------------------------------------------------------- -# Common -# ----------------------------------------------------------------- - - @doc """ - GET /api/system-static-info - - Requires 'id' (the solar_system_id) - - Example: - GET /api/common/system_static?id=31002229 - GET /api/common/system_static?id=31002229 - """ - def show_system_static(conn, params) do - with {:ok, solar_system_str} <- require_param(params, "id"), - {:ok, solar_system_id} <- parse_int(solar_system_str) do - case CachedInfo.get_system_static_info(solar_system_id) do - {:ok, system} -> - data = static_system_to_json(system) - json(conn, %{data: data}) - - {:error, :not_found} -> - conn - |> put_status(:not_found) - |> json(%{error: "System not found"}) - end - else - {:error, msg} -> - conn - |> put_status(:bad_request) - |> json(%{error: msg}) - end - end + alias WandererAppWeb.UtilAPIController, as: Util + # ----------------------------------------------------------------- + # MAP endpoints + # ----------------------------------------------------------------- @doc """ GET /api/map/systems Requires either `?map_id=` **OR** `?slug=` in the query params. - If `?all=true` is provided, **all** systems are returned. - Otherwise, only "visible" systems are returned. + Only "visible" systems are returned. Examples: GET /api/map/systems?map_id=466e922b-e758-485e-9b86-afae06b88363 GET /api/map/systems?slug=my-unique-wormhole-map - GET /api/map/systems?map_id=&all=true """ def list_systems(conn, params) do - with {:ok, map_id} <- fetch_map_id(params) do - repo_fun = - if params["all"] == "true" do - &MapSystemRepo.get_all_by_map/1 - else - &MapSystemRepo.get_visible_by_map/1 - end - - case repo_fun.(map_id) do - {:ok, systems} -> - data = Enum.map(systems, &map_system_to_json/1) - json(conn, %{data: data}) - - {:error, reason} -> - conn - |> put_status(:not_found) - |> json(%{error: "Could not fetch systems for map_id=#{map_id}: #{inspect(reason)}"}) - end + with {:ok, map_id} <- Util.fetch_map_id(params), + {:ok, systems} <- MapSystemRepo.get_visible_by_map(map_id) do + data = Enum.map(systems, &map_system_to_json/1) + json(conn, %{data: data}) else - {:error, msg} -> + {:error, msg} when is_binary(msg) -> conn |> put_status(:bad_request) |> json(%{error: msg}) + + {:error, reason} -> + conn + |> put_status(:not_found) + |> json(%{error: "Could not fetch systems: #{inspect(reason)}"}) end end @@ -96,29 +57,30 @@ defmodule WandererAppWeb.APIController do GET /api/map/system?id=31002229&slug=my-unique-wormhole-map """ def show_system(conn, params) do - with {:ok, solar_system_str} <- require_param(params, "id"), - {:ok, solar_system_id} <- parse_int(solar_system_str), - {:ok, map_id} <- fetch_map_id(params) do - case MapSystemRepo.get_by_map_and_solar_system_id(map_id, solar_system_id) do - {:ok, system} -> - data = map_system_to_json(system) - json(conn, %{data: data}) - - {:error, :not_found} -> - conn - |> put_status(:not_found) - |> json(%{error: "System not found in map=#{map_id}"}) - end + with {:ok, solar_system_str} <- Util.require_param(params, "id"), + {:ok, solar_system_id} <- Util.parse_int(solar_system_str), + {:ok, map_id} <- Util.fetch_map_id(params), + {:ok, system} <- MapSystemRepo.get_by_map_and_solar_system_id(map_id, solar_system_id) do + data = map_system_to_json(system) + json(conn, %{data: data}) else - {:error, msg} -> + {:error, msg} when is_binary(msg) -> conn |> put_status(:bad_request) |> json(%{error: msg}) + + {:error, :not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "System not found"}) + + {:error, reason} -> + conn + |> put_status(:internal_server_error) + |> json(%{error: "Could not load system: #{inspect(reason)}"}) end end - - @doc """ GET /api/map/tracked_characters_with_info @@ -129,11 +91,9 @@ defmodule WandererAppWeb.APIController do Returns a list of tracked records, plus their fully-loaded `character` data. """ def tracked_characters_with_info(conn, params) do - with {:ok, map_id} <- fetch_map_id(params), + with {:ok, map_id} <- Util.fetch_map_id(params), {:ok, settings_list} <- get_tracked_by_map_ids(map_id), - {:ok, char_list} <- - read_characters_by_ids_wrapper(Enum.map(settings_list, & &1.character_id)) do - + {:ok, char_list} <- read_characters_by_ids_wrapper(Enum.map(settings_list, & &1.character_id)) do chars_by_id = Map.new(char_list, &{&1.id, &1}) data = @@ -175,8 +135,24 @@ defmodule WandererAppWeb.APIController do end end + @doc """ + GET /api/map/structure_timers + + Returns structure timers for visible systems on the map + or for a specific system if `system_id` is specified. + + **Example usage**: + - All visible systems: + ``` + GET /api/map/structure_timers?map_id= + ``` + - For a single system: + ``` + GET /api/map/structure_timers?map_id=&system_id=31002229 + ``` + """ def show_structure_timers(conn, params) do - with {:ok, map_id} <- fetch_map_id(params) do + with {:ok, map_id} <- Util.fetch_map_id(params) do system_id_str = params["system_id"] case system_id_str do @@ -184,7 +160,7 @@ defmodule WandererAppWeb.APIController do handle_all_structure_timers(conn, map_id) _ -> - case parse_int(system_id_str) do + case Util.parse_int(system_id_str) do {:ok, system_id} -> handle_single_structure_timers(conn, map_id, system_id) @@ -202,6 +178,102 @@ defmodule WandererAppWeb.APIController do end end + @doc """ + GET /api/map/systems_kills + + Returns kills data for all *visible* systems on the map. + + Requires either `?map_id=` or `?slug=`. + Optional hours_ago + + Example: + GET /api/map/systems_kills?map_id= + GET /api/map/systems_kills?slug= + GET /api/map/systems_kills?map_id=&hour_ago= + + """ + def list_systems_kills(conn, params) do + with {:ok, map_id} <- Util.fetch_map_id(params), + # fetch visible systems from the repo + {:ok, systems} <- MapSystemRepo.get_visible_by_map(map_id) do + + Logger.debug(fn -> "[list_systems_kills] Found #{length(systems)} visible systems for map_id=#{map_id}" end) + + # Parse the hours_ago param + hours_ago = parse_hours_ago(params["hours_ago"]) + + # Gather system IDs + solar_ids = Enum.map(systems, & &1.solar_system_id) + + # Fetch kills for each system from the cache + kills_map = KillsCache.fetch_cached_kills_for_systems(solar_ids) + + # Build final JSON data + data = + Enum.map(systems, fn sys -> + kills = Map.get(kills_map, sys.solar_system_id, []) + + # Filter out kills older than hours_ago + filtered_kills = maybe_filter_kills_by_time(kills, hours_ago) + + Logger.debug(fn -> " + [list_systems_kills] For system_id=#{sys.solar_system_id}, + found #{length(kills)} kills total, + returning #{length(filtered_kills)} kills after hours_ago filter + " end) + + %{ + solar_system_id: sys.solar_system_id, + kills: filtered_kills + } + end) + + json(conn, %{data: data}) + else + {:error, msg} when is_binary(msg) -> + Logger.warn("[list_systems_kills] Bad request: #{msg}") + conn + |> put_status(:bad_request) + |> json(%{error: msg}) + + {:error, reason} -> + Logger.error("[list_systems_kills] Could not fetch systems: #{inspect(reason)}") + conn + |> put_status(:not_found) + |> json(%{error: "Could not fetch systems: #{inspect(reason)}"}) + end + end + + # If hours_str is present and valid, parse it. Otherwise return nil (no filter). + defp parse_hours_ago(nil), do: nil + defp parse_hours_ago(hours_str) do + case Integer.parse(hours_str) do + {num, ""} when num > 0 -> num + _ -> nil + end + end + + defp maybe_filter_kills_by_time(kills, hours_ago) when is_integer(hours_ago) do + cutoff = DateTime.utc_now() |> DateTime.add(-hours_ago * 3600, :second) + + Enum.filter(kills, fn kill -> + kill_time = kill["kill_time"] + + case kill_time do + %DateTime{} = dt -> + # Keep kills that occurred after the cutoff + DateTime.compare(dt, cutoff) != :lt + + # If it's something else (nil, or a weird format), skip + _ -> + false + end + end) + end + + # If hours_ago is nil, maybe no time filtering: + defp maybe_filter_kills_by_time(kills, nil), do: kills + defp handle_all_structure_timers(conn, map_id) do case MapSystemRepo.get_visible_by_map(map_id) do {:ok, systems} -> @@ -268,11 +340,8 @@ defmodule WandererAppWeb.APIController do defp get_tracked_by_map_ids(map_id) do case MapCharacterSettingsRepo.get_tracked_by_map_all(map_id) do - {:ok, settings_list} -> - {:ok, settings_list} - - {:error, reason} -> - {:error, :get_tracked_error, reason} + {:ok, settings_list} -> {:ok, settings_list} + {:error, reason} -> {:error, :get_tracked_error, reason} end end @@ -286,38 +355,6 @@ defmodule WandererAppWeb.APIController do end end - defp fetch_map_id(%{"map_id" => mid}) when is_binary(mid) and mid != "" do - {:ok, mid} - end - - defp fetch_map_id(%{"slug" => slug}) when is_binary(slug) and slug != "" do - case WandererApp.Api.Map.get_map_by_slug(slug) do - {:ok, map} -> - {:ok, map.id} - - {:error, _reason} -> - {:error, "No map found for slug=#{slug}"} - end - end - - defp fetch_map_id(_), - do: {:error, "Must provide either ?map_id=UUID or ?slug=SLUG"} - - defp require_param(params, key) do - case params[key] do - nil -> {:error, "Missing required param: #{key}"} - "" -> {:error, "Param #{key} cannot be empty"} - val -> {:ok, val} - end - end - - defp parse_int(str) do - case Integer.parse(str) do - {num, ""} -> {:ok, num} - _ -> {:error, "Invalid integer for param id=#{str}"} - end - end - defp read_characters_by_ids(ids) when is_list(ids) do if ids == [] do {:ok, []} @@ -366,30 +403,4 @@ defmodule WandererAppWeb.APIController do :updated_at ]) end - - - defp static_system_to_json(system) do - system - |> Map.take([ - :solar_system_id, - :region_id, - :constellation_id, - :solar_system_name, - :solar_system_name_lc, - :constellation_name, - :region_name, - :system_class, - :security, - :type_description, - :class_title, - :is_shattered, - :effect_name, - :effect_power, - :statics, - :wandering, - :triglavian_invasion_status, - :sun_type_id - ]) - end - end diff --git a/lib/wanderer_app_web/controllers/plugs/check_kills_disabled.ex b/lib/wanderer_app_web/controllers/plugs/check_kills_disabled.ex new file mode 100644 index 00000000..100b8f23 --- /dev/null +++ b/lib/wanderer_app_web/controllers/plugs/check_kills_disabled.ex @@ -0,0 +1,15 @@ +defmodule WandererAppWeb.Plugs.CheckKillsDisabled do + import Plug.Conn + + def init(opts), do: opts + + def call(conn, _opts) do + if WandererApp.Env.zkill_preload_disabled?() do + conn + |> send_resp(403, "Map kill feed is disabled") + |> halt() + else + conn + end + end +end diff --git a/lib/wanderer_app_web/controllers/util_api_controller.ex b/lib/wanderer_app_web/controllers/util_api_controller.ex new file mode 100644 index 00000000..ae4fb024 --- /dev/null +++ b/lib/wanderer_app_web/controllers/util_api_controller.ex @@ -0,0 +1,41 @@ +defmodule WandererAppWeb.UtilAPIController do + @moduledoc """ + Utility functions for parameter handling, fetch helpers, etc. + """ + + alias WandererApp.Api + + def fetch_map_id(%{"map_id" => mid}) when is_binary(mid) and mid != "" do + {:ok, mid} + end + + def fetch_map_id(%{"slug" => slug}) when is_binary(slug) and slug != "" do + case Api.Map.get_map_by_slug(slug) do + {:ok, map} -> + {:ok, map.id} + + {:error, _reason} -> + {:error, "No map found for slug=#{slug}"} + end + end + + def fetch_map_id(_), + do: {:error, "Must provide either ?map_id=UUID or ?slug=SLUG"} + + # Require a given param to be present and non-empty + def require_param(params, key) do + case params[key] do + nil -> {:error, "Missing required param: #{key}"} + "" -> {:error, "Param #{key} cannot be empty"} + val -> {:ok, val} + end + end + + # Parse a string into an integer + def parse_int(str) do + case Integer.parse(str) do + {num, ""} -> {:ok, num} + _ -> {:error, "Invalid integer for param id=#{str}"} + end + end +end diff --git a/lib/wanderer_app_web/router.ex b/lib/wanderer_app_web/router.ex index d29a4318..44cff4eb 100644 --- a/lib/wanderer_app_web/router.ex +++ b/lib/wanderer_app_web/router.ex @@ -114,33 +114,39 @@ defmodule WandererAppWeb.Router do plug WandererAppWeb.Plugs.CheckMapApiKey end -scope "/api/map", WandererAppWeb do - pipe_through [:api_map] - pipe_through [:api] + pipeline :api_kills do + plug WandererAppWeb.Plugs.CheckApiDisabled + end - # GET /api/map/systems?map_id=... or ?slug=... - get "/systems", APIController, :list_systems + scope "/api/map/systems-kills", WandererAppWeb do + pipe_through [:api, :api_map, :api_kills] - # GET /api/map/system-static-info?id=... plus either map_id=... or slug=... - get "/system-static-info", APIController, :show_system_static + get "/", MapAPIController, :list_systems_kills + end - # GET /api/map/system?id=... plus either map_id=... or slug=... - get "/system", APIController, :show_system + scope "/api/map", WandererAppWeb do + pipe_through [:api, :api_map] - # GET /api/map/characters?map_id=... or slug=... - get "/characters", APIController, :tracked_characters_with_info + # GET /api/map/systems?map_id=... or ?slug=... + get "/systems", MapAPIController, :list_systems - # GET /api/map/structure-timers?map_id=... or slug=... and optionally ?system_id=... - get "/structure-timers", APIController, :show_structure_timers -end + # GET /api/map/system?id=... plus either map_id=... or slug=... + get "/system", MapAPIController, :show_system -scope "/api/common", WandererAppWeb do - pipe_through [:api] + # GET /api/map/characters?map_id=... or slug=... + get "/characters", MapAPIController, :tracked_characters_with_info - # GET /api/common/system-static-info?id=... - get "/system-static-info", APIController, :show_system_static + # GET /api/map/structure-timers?map_id=... or slug=... and optionally ?system_id=... + get "/structure-timers", MapAPIController, :show_structure_timers + end -end + scope "/api/common", WandererAppWeb do + pipe_through [:api] + + # GET /api/common/system-static-info?id=... + get "/system-static-info", CommonAPIController, :show_system_static + + end scope "/", WandererAppWeb do pipe_through [:browser, :blog, :redirect_if_user_is_authenticated] diff --git a/priv/posts/2025/01-05-map-public-api.md b/priv/posts/2025/01-05-map-public-api.md index 5b4934a9..1476d731 100644 --- a/priv/posts/2025/01-05-map-public-api.md +++ b/priv/posts/2025/01-05-map-public-api.md @@ -199,6 +199,101 @@ No api key is required for routes that being with /api/common ``` --- +### 4. Kills Activity + + GET /api/map/systems-kills?map_id= + GET /api/map/systems-kills?slug=" + +- **Description:** Retrieves the kill activity for the specified map (by `map_id` or `slug`), including details on the attacker and victim + +#### Example Request +``` + curl -H "Authorization: Bearer " "https://wanderer.example.com/api/map/systems-kills?slug==some-slug" +``` +#### Example Response +``` + { + "data": [ + { + "kills": [ + { + "attacker_count": 1, + "final_blow_alliance_id": 99013806, + "final_blow_alliance_ticker": "TCE", + "final_blow_char_id": 2116802670, + "final_blow_char_name": "Bambi Bunny", + "final_blow_corp_id": 98140648, + "final_blow_corp_ticker": "GNK3D", + "final_blow_ship_name": "Thrasher", + "final_blow_ship_type_id": 16242, + "kill_time": "2025-01-21T21:00:59Z", + "killmail_id": 124181782, + "npc": false, + "solar_system_id": 30002768, + "total_value": 10000, + "victim_alliance_id": null, + "victim_char_id": 2121725410, + "victim_char_name": "Bill Drummond", + "victim_corp_id": 98753095, + "victim_corp_ticker": "KSTJK", + "victim_ship_name": "Capsule", + "victim_ship_type_id": 670, + "zkb": { + "awox": false, + "destroyedValue": 10000, + "droppedValue": 0, + "fittedValue": 10000, + "hash": "777148f8bf344bade68a6a0821bfe0a37491a7a6", + "labels": ["cat:6","#:1","pvp","loc:highsec"], + "locationID": 50014064, + "npc": false, + "points": 1, + "solo": false, + "totalValue": 10000 + } + }, + { + "attacker_count": 3, + "final_blow_alliance_id": null, + "final_blow_char_id": null, + "final_blow_corp_id": null, + "final_blow_ship_type_id": 3740, + "kill_time": "2025-01-21T21:00:38Z", + "killmail_id": 124181769, + "npc": true, + "solar_system_id": 30002768, + "total_value": 2656048.48, + "victim_alliance_id": 99013806, + "victim_alliance_ticker": "TCE", + "victim_char_id": 2116802745, + "victim_char_name": "Brittni Bunny", + "victim_corp_id": 98140648, + "victim_corp_ticker": "GNK3D", + "victim_ship_name": "Coercer", + "victim_ship_type_id": 16236, + "zkb": { + "awox": false, + "destroyedValue": 2509214.44, + "droppedValue": 146834.04, + "fittedValue": 2607449.82, + "hash": "d3dd6b8833b2a9d36dd5a3eecf9838c4c8b01acd", + "labels": ["cat:6","#:2+","npc","loc:highsec"], + "locationID": 50014064, + "npc": true, + "points": 1, + "solo": false, + "totalValue": 2656048.48 + } + } + ], + "solar_system_id": 30002768 + }, + ... + ] + } +``` +--- + ## Conclusion Using these APIs, you can programmatically retrieve system and character information from your map. Whether you’re building a custom analytics dashboard, a corp management tool, or just want to explore data outside the standard UI, these endpoints provide a straightforward way to fetch up-to-date map details.