feat (api): add additional system/connections methods (#351)

This commit is contained in:
guarzo
2025-05-03 11:41:21 -04:00
committed by GitHub
parent c022b31c79
commit 6378754c57
22 changed files with 4137 additions and 881 deletions

View File

@@ -0,0 +1,490 @@
defmodule WandererApp.Map.Operations do
@moduledoc """
Orchestrates map systems and connections.
Centralizes cross-repo logic for controllers, templates, and batch operations.
"""
# Cache TTL in milliseconds (24 hours)
@owner_info_cache_ttl 86_400_000
alias WandererApp.{
MapRepo,
MapSystemRepo,
MapConnectionRepo,
MapCharacterSettingsRepo,
MapUserSettingsRepo
}
alias WandererApp.Map.Server
alias WandererApp.Character
alias WandererApp.Character.TrackingUtils
@doc """
Fetch main character ID for the map owner.
Returns {:ok, %{id: character_id, user_id: user_id}} on success
Returns {:error, reason} on failure
"""
@spec get_owner_character_id(String.t()) :: {:ok, %{id: term(), user_id: term()}} | {:error, String.t()}
def get_owner_character_id(map_id) do
case WandererApp.Cache.lookup!("map_#{map_id}:owner_info") do
nil ->
with {:ok, owner} <- fetch_map_owner(map_id),
{:ok, char_ids} <- fetch_character_ids(map_id),
{:ok, characters} <- load_characters(char_ids),
{:ok, user_settings} <- MapUserSettingsRepo.get(map_id, owner.id),
{:ok, main} <- TrackingUtils.get_main_character(user_settings, characters, characters) do
result = %{id: main.id, user_id: main.user_id}
WandererApp.Cache.insert("map_#{map_id}:owner_info", result, ttl: @owner_info_cache_ttl)
{:ok, result}
else
{:error, msg} ->
{:error, msg}
_ ->
{:error, "Failed to resolve main character"}
end
cached ->
{:ok, cached}
end
end
defp fetch_map_owner(map_id) do
case MapRepo.get(map_id, [:owner]) do
{:ok, %{owner: %_{} = owner}} -> {:ok, owner}
{:ok, %{owner: nil}} -> {:error, "Map has no owner"}
{:error, _} -> {:error, "Map not found"}
end
end
defp fetch_character_ids(map_id) do
case MapCharacterSettingsRepo.get_all_by_map(map_id) do
{:ok, settings} when is_list(settings) and settings != [] -> {:ok, Enum.map(settings, & &1.character_id)}
{:ok, []} -> {:error, "No character settings found"}
{:error, _} -> {:error, "Failed to fetch character settings"}
end
end
defp load_characters(ids) when is_list(ids) do
ids
|> Enum.map(&Character.get_character/1)
|> Enum.flat_map(fn
{:ok, ch} -> [ch]
_ -> []
end)
|> case do
[] -> {:error, "No valid characters found"}
chars -> {:ok, chars}
end
end
@doc "List visible systems"
@spec list_systems(String.t()) :: [any()]
def list_systems(map_id) do
case MapSystemRepo.get_visible_by_map(map_id) do
{:ok, systems} -> systems
_ -> []
end
end
@doc "Get a specific system"
@spec get_system(String.t(), integer()) :: {:ok, any()} | {:error, :not_found}
def get_system(map_id, sid) do
MapSystemRepo.get_by_map_and_solar_system_id(map_id, sid)
end
@doc "Create or update a system in a map"
@spec create_system(String.t(), map()) :: {:ok, any()} | {:error, String.t()}
def create_system(map_id, params) do
with {:ok, %{id: char_id, user_id: user_id}} <- get_owner_character_id(map_id),
{:ok, system_id} <- fetch_system_id(params),
coords <- normalize_coordinates(params),
:ok <- Server.add_system(map_id, %{solar_system_id: system_id, coordinates: coords}, user_id, char_id),
{:ok, system} <- MapSystemRepo.get_by_map_and_solar_system_id(map_id, system_id) do
{:ok, system}
else
{:error, reason} when is_binary(reason) ->
{:error, reason}
_ ->
{:error, "Unable to create system"}
end
end
@doc "Update attributes of an existing system"
@spec update_system(String.t(), integer(), map()) :: {:ok, any()} | {:error, String.t()}
def update_system(map_id, system_id, attrs) do
# Fetch current system to get its position if not provided
with {:ok, current_system} <- MapSystemRepo.get_by_map_and_solar_system_id(map_id, system_id),
x_raw <- Map.get(attrs, "position_x", Map.get(attrs, :position_x, current_system.position_x)),
y_raw <- Map.get(attrs, "position_y", Map.get(attrs, :position_y, current_system.position_y)),
{:ok, x} <- parse_int(x_raw, "position_x"),
{:ok, y} <- parse_int(y_raw, "position_y"),
coords = %{x: x, y: y},
:ok <- apply_system_updates(map_id, system_id, attrs, coords),
{:ok, system} <- MapSystemRepo.get_by_map_and_solar_system_id(map_id, system_id) do
{:ok, system}
else
{:error, reason} when is_binary(reason) ->
{:error, reason}
_ ->
{:error, "Error updating system"}
end
end
@spec delete_system(String.t(), integer()) :: {:ok, integer()} | {:error, term()}
def delete_system(map_id, system_id) do
with {:ok, %{id: char_id, user_id: user_id}} <- get_owner_character_id(map_id),
{:ok, _system} <- MapSystemRepo.get_by_map_and_solar_system_id(map_id, system_id),
:ok <- Server.delete_systems(map_id, [system_id], user_id, char_id) do
{:ok, 1}
else
{:error, :not_found} -> {:error, :not_found}
_ ->
{:error, "Failed to delete system"}
end
end
@doc """
Create a new connection if missing
Returns :ok on success
Returns {:skip, :exists} if connection already exists
Returns {:error, reason} on failure
"""
@spec create_connection(map(), String.t()) :: {:ok, any()} | {:skip, :exists} | {:error, String.t()}
def create_connection(attrs, map_id) when is_map(attrs) do
with {:ok, %{id: char_id}} <- get_owner_character_id(map_id) do
do_create_connection(attrs, map_id, char_id)
end
end
@doc """
Create a new connection if missing with explicit character ID
Returns :ok on success
Returns {:skip, :exists} if connection already exists
Returns {:error, reason} on failure
"""
@spec create_connection(map(), String.t(), integer()) :: {:ok, any()} | {:skip, :exists} | {:error, String.t()}
def create_connection(attrs, map_id, char_id) when is_map(attrs), do: do_create_connection(attrs, map_id, char_id)
defp do_create_connection(attrs, map_id, char_id) do
with {:ok, source} <- parse_int(attrs["solar_system_source"], "solar_system_source"),
{:ok, target} <- parse_int(attrs["solar_system_target"], "solar_system_target"),
info = build_connection_info(source, target, char_id, attrs["type"]),
:ok <- Server.add_connection(map_id, info),
{:ok, [conn | _]} <- MapConnectionRepo.get_by_locations(map_id, source, target) do
{:ok, conn}
else
{:ok, []} ->
{:ok, :created}
{:error, %Ash.Error.Invalid{errors: errors}} = err ->
if Enum.any?(errors, &is_unique_constraint_error?/1) do
{:skip, :exists}
else
err
end
{:error, _reason} = err ->
err
_ ->
{:error, "Failed to create connection"}
end
end
defp build_connection_info(source, target, char_id, type) do
%{
solar_system_source_id: source,
solar_system_target_id: target,
character_id: char_id,
type: parse_type(type)
}
end
@doc "Delete an existing connection"
@spec delete_connection(String.t(), integer(), integer()) :: :ok | {:error, term()}
def delete_connection(map_id, src, tgt) do
case Server.delete_connection(map_id, %{solar_system_source_id: src, solar_system_target_id: tgt}) do
:ok -> :ok
{:error, :not_found} -> {:error, :not_found}
{:error, _reason} = err -> err
_ -> {:error, :unknown}
end
end
# Helper to detect Ash 'not found' errors
defp is_not_found_error({:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}}), do: true
defp is_not_found_error({:error, %Ash.Error.Invalid{errors: errors}}) when is_list(errors), do: Enum.any?(errors, &match?(%Ash.Error.Query.NotFound{}, &1))
defp is_not_found_error(_), do: false
@spec upsert_systems_and_connections(String.t(), [map()], [map()]) :: {:ok, map()} | {:error, String.t()}
def upsert_systems_and_connections(map_id, systems, connections) do
with {:ok, %{id: char_id}} <- get_owner_character_id(map_id) do
system_results = upsert_each(systems, fn sys -> create_system(map_id, sys) end, 0, 0)
connection_results = Enum.reduce(connections, %{created: 0, updated: 0, skipped: 0}, fn conn, acc ->
upsert_connection_branch(map_id, conn, char_id, acc)
end)
{:ok, format_upsert_results(system_results, {connection_results.created, connection_results.updated})}
else
{:error, reason} -> {:error, reason}
end
end
# Private: Handles a single connection upsert branch for batch upsert
defp upsert_connection_branch(map_id, conn, char_id, acc) do
with {:ok, source} <- parse_int(conn["solar_system_source"], "solar_system_source"),
{:ok, target} <- parse_int(conn["solar_system_target"], "solar_system_target") do
case get_connection_by_systems(map_id, source, target) do
{:ok, existing_conn} when is_map(existing_conn) and not is_nil(existing_conn) ->
case update_connection(map_id, existing_conn.id, conn) do
{:ok, _} -> %{acc | updated: acc.updated + 1}
error ->
if is_not_found_error(error) do
case create_connection(conn, map_id, char_id) do
{:ok, _} -> %{acc | created: acc.created + 1}
{:skip, :exists} -> %{acc | updated: acc.updated + 1}
{:error, _} -> %{acc | skipped: acc.skipped + 1}
end
else
%{acc | skipped: acc.skipped + 1}
end
end
{:ok, _} ->
case create_connection(conn, map_id, char_id) do
{:ok, _} -> %{acc | created: acc.created + 1}
{:skip, :exists} -> %{acc | updated: acc.updated + 1}
{:error, _} -> %{acc | skipped: acc.skipped + 1}
end
{:error, :not_found} ->
case create_connection(conn, map_id, char_id) do
{:ok, _} -> %{acc | created: acc.created + 1}
{:skip, :exists} -> %{acc | updated: acc.updated + 1}
{:error, _} -> %{acc | skipped: acc.skipped + 1}
end
_ ->
%{acc | skipped: acc.skipped + 1}
end
else
{:error, _} ->
%{acc | skipped: acc.skipped + 1}
end
end
# Helper to get a connection by source/target system IDs
def get_connection_by_systems(map_id, source, target) do
case WandererApp.Map.find_connection(map_id, source, target) do
{:ok, nil} ->
WandererApp.Map.find_connection(map_id, target, source)
{:ok, conn} ->
{:ok, conn}
end
end
defp format_upsert_results({created_s, updated_s, _}, {created_c, updated_c}) do
%{
systems: %{created: created_s, updated: updated_s},
connections: %{created: created_c, updated: updated_c}
}
end
@doc "Get connection by ID"
@spec get_connection(String.t(), String.t()) :: {:ok, map()} | {:error, String.t()}
def get_connection(map_id, id) do
case MapConnectionRepo.get_by_id(map_id, id) do
{:ok, %{} = conn} -> {:ok, conn}
{:error, _} -> {:error, "Connection not found"}
end
end
# -- Helpers ---------------------------------------------------------------
defp fetch_system_id(%{"solar_system_id" => id}), do: parse_int(id, "solar_system_id")
defp fetch_system_id(%{solar_system_id: id}) when not is_nil(id), do: parse_int(id, "solar_system_id")
defp fetch_system_id(%{"name" => name}) when is_binary(name) and name != "", do: find_by_name(name)
defp fetch_system_id(%{name: name}) when is_binary(name) and name != "", do: find_by_name(name)
defp fetch_system_id(_), do: {:error, "Missing system identifier (id or name)"}
@doc """
Find system ID by name
Uses EveDataService for lookup
"""
defp find_by_name(name) do
case WandererApp.EveDataService.find_system_id_by_name(name) do
{:ok, id} when is_integer(id) -> {:ok, id}
{:ok, _} ->
{:error, "Invalid system name: #{name}"}
{:error, reason} ->
{:error, "Failed to find system by name '#{name}': #{reason}"}
_ ->
{:error, "Unknown system name: #{name}"}
end
end
defp parse_int(val, _field) when is_integer(val), do: {:ok, val}
defp parse_int(val, field) when is_binary(val) do
case Integer.parse(val) do
{i, _} -> {:ok, i}
_ -> {:error, "Invalid #{field}: #{val}"}
end
end
defp parse_int(nil, field), do: {:error, "Missing #{field}"}
defp parse_int(val, field), do: {:error, "Invalid #{field} type: #{inspect(val)}"}
defp parse_type(val) when is_binary(val) do
case Integer.parse(val) do
{i, _} -> i
_ -> 0
end
end
defp parse_type(val) when is_integer(val), do: val
defp parse_type(_), do: 0
defp normalize_coordinates(%{"coordinates" => %{"x" => x, "y" => y}}) when is_number(x) and is_number(y), do: %{x: x, y: y}
defp normalize_coordinates(%{coordinates: %{x: x, y: y}}) when is_number(x) and is_number(y), do: %{x: x, y: y}
defp normalize_coordinates(params) do
%{
x: params |> Map.get("position_x", Map.get(params, :position_x, 0)),
y: params |> Map.get("position_y", Map.get(params, :position_y, 0))
}
end
defp apply_system_updates(map_id, system_id, attrs, %{x: x, y: y}) do
with :ok <- Server.update_system_position(map_id, %{solar_system_id: system_id, position_x: round(x), position_y: round(y)}) do
attrs
|> Map.drop([:coordinates, :position_x, :position_y, :solar_system_id,
"coordinates", "position_x", "position_y", "solar_system_id"])
|> Enum.reduce_while(:ok, fn {key, val}, _acc ->
case update_system_field(map_id, system_id, to_string(key), val) do
:ok -> {:cont, :ok}
{:error, _} = err -> {:halt, err}
end
end)
end
end
defp update_system_field(map_id, system_id, field, val) do
case field do
"status" -> Server.update_system_status(map_id, %{solar_system_id: system_id, status: convert_status(val)})
"description" -> Server.update_system_description(map_id, %{solar_system_id: system_id, description: val})
"tag" -> Server.update_system_tag(map_id, %{solar_system_id: system_id, tag: val})
"locked" ->
bool = val in [true, "true", 1, "1"]
Server.update_system_locked(map_id, %{solar_system_id: system_id, locked: bool})
f when f in ["label", "labels"] ->
labels =
cond do
is_list(val) -> val
is_binary(val) -> String.split(val, ",", trim: true)
true -> []
end
Server.update_system_labels(map_id, %{solar_system_id: system_id, labels: Enum.join(labels, ",")})
"temporary_name" -> Server.update_system_temporary_name(map_id, %{solar_system_id: system_id, temporary_name: val})
_ -> :ok
end
end
defp convert_status("CLEAR"), do: 0
defp convert_status("DANGEROUS"), do: 1
defp convert_status("OCCUPIED"), do: 2
defp convert_status("MASS_CRITICAL"), do: 3
defp convert_status("TIME_CRITICAL"), do: 4
defp convert_status("REINFORCED"), do: 5
defp convert_status(i) when is_integer(i), do: i
defp convert_status(s) when is_binary(s) do
case Integer.parse(s) do
{i, _} -> i
_ -> 0
end
end
defp convert_status(_), do: 0
defp upsert_each(list, fun, c, u), do: upsert_each(list, fun, c, u, 0)
defp upsert_each([], _fun, c, u, d), do: {c, u, d}
defp upsert_each([item | rest], fun, c, u, d) do
case fun.(item) do
{:ok, _} -> upsert_each(rest, fun, c + 1, u, d)
:ok -> upsert_each(rest, fun, c + 1, u, d)
{:skip, _} -> upsert_each(rest, fun, c, u + 1, d)
_ -> upsert_each(rest, fun, c, u, d + 1)
end
end
@doc "Update an existing connection"
@spec update_connection(String.t(), String.t(), map()) :: {:ok, any()} | {:error, String.t()}
def update_connection(map_id, connection_id, attrs) do
with {:ok, conn} <- MapConnectionRepo.get_by_id(map_id, connection_id),
{:ok, %{id: char_id}} <- get_owner_character_id(map_id),
:ok <- validate_connection_update(conn, attrs),
:ok <- apply_connection_updates(map_id, conn, attrs, char_id),
{:ok, updated_conn} <- MapConnectionRepo.get_by_id(map_id, connection_id) do
{:ok, updated_conn}
else
{:error, reason} when is_binary(reason) ->
{:error, reason}
{:error, %Ash.Error.Invalid{} = ash_error} ->
{:error, ash_error}
_error ->
{:error, "Failed to update connection"}
end
end
defp validate_connection_update(_conn, _attrs), do: :ok
defp apply_connection_updates(map_id, conn, attrs, _char_id) do
with :ok <- maybe_update_mass_status(map_id, conn, Map.get(attrs, "mass_status", conn.mass_status)),
:ok <- maybe_update_ship_size_type(map_id, conn, Map.get(attrs, "ship_size_type", conn.ship_size_type)),
:ok <- maybe_update_type(map_id, conn, Map.get(attrs, "type", conn.type)) do
:ok
else
error ->
error
end
end
defp maybe_update_mass_status(map_id, conn, value) when not is_nil(value) do
Server.update_connection_mass_status(map_id, %{
solar_system_source_id: conn.solar_system_source,
solar_system_target_id: conn.solar_system_target,
mass_status: value
})
end
defp maybe_update_mass_status(_map_id, _conn, nil), do: :ok
defp maybe_update_ship_size_type(map_id, conn, value) when not is_nil(value) do
Server.update_connection_ship_size_type(map_id, %{
solar_system_source_id: conn.solar_system_source,
solar_system_target_id: conn.solar_system_target,
ship_size_type: value
})
end
defp maybe_update_ship_size_type(_map_id, _conn, nil), do: :ok
defp maybe_update_type(map_id, conn, value) when not is_nil(value) do
Server.update_connection_type(map_id, %{
solar_system_source_id: conn.solar_system_source,
solar_system_target_id: conn.solar_system_target,
type: value
})
end
defp maybe_update_type(_map_id, _conn, nil), do: :ok
@doc "List all connections for a map"
@spec list_connections(String.t()) :: [map()]
def list_connections(map_id) do
case MapConnectionRepo.get_by_map(map_id) do
{:ok, connections} -> connections
_ -> []
end
end
@doc "List connections for a map involving a specific system (source or target)"
@spec list_connections(String.t(), integer()) :: [map()]
def list_connections(map_id, system_id) do
list_connections(map_id)
|> Enum.filter(fn conn ->
conn.solar_system_source == system_id or conn.solar_system_target == system_id
end)
end
# Helper to detect unique constraint errors in Ash error lists
defp is_unique_constraint_error?(%{constraint: :unique}), do: true
defp is_unique_constraint_error?(%{constraint: :unique_constraint}), do: true
defp is_unique_constraint_error?(_), do: false
end

View File

@@ -95,4 +95,12 @@ defmodule WandererApp.MapConnectionRepo do
do: do:
connection connection
|> WandererApp.Api.MapConnection.update_custom_info(update) |> WandererApp.Api.MapConnection.update_custom_info(update)
def get_by_id(map_id, id) do
case WandererApp.Api.MapConnection.by_id(id) do
{:ok, conn} when conn.map_id == map_id -> {:ok, conn}
{:ok, _} -> {:error, :not_found}
{:error, _} -> {:error, :not_found}
end
end
end end

View File

@@ -13,7 +13,7 @@ defmodule WandererAppWeb.MapAccessListAPIController do
use OpenApiSpex.ControllerSpecs use OpenApiSpex.ControllerSpecs
alias WandererApp.Api.{AccessList, Character} alias WandererApp.Api.{AccessList, Character}
alias WandererAppWeb.UtilAPIController, as: Util alias WandererAppWeb.Helpers.APIUtils
import Ash.Query import Ash.Query
require Logger require Logger
@@ -245,7 +245,7 @@ defmodule WandererAppWeb.MapAccessListAPIController do
}} }}
] ]
def index(conn, params) do def index(conn, params) do
case Util.fetch_map_id(params) do case APIUtils.fetch_map_id(params) do
{:ok, map_identifier} -> {:ok, map_identifier} ->
with {:ok, map} <- get_map(map_identifier), with {:ok, map} <- get_map(map_identifier),
{:ok, loaded_map} <- Ash.load(map, acls: [:owner]) do {:ok, loaded_map} <- Ash.load(map, acls: [:owner]) do
@@ -320,7 +320,7 @@ defmodule WandererAppWeb.MapAccessListAPIController do
}} }}
] ]
def create(conn, params) do def create(conn, params) do
with {:ok, map_identifier} <- Util.fetch_map_id(params), with {:ok, map_identifier} <- APIUtils.fetch_map_id(params),
{:ok, map} <- get_map(map_identifier), {:ok, map} <- get_map(map_identifier),
%{"acl" => acl_params} <- params, %{"acl" => acl_params} <- params,
owner_eve_id when not is_nil(owner_eve_id) <- Map.get(acl_params, "owner_eve_id"), owner_eve_id when not is_nil(owner_eve_id) <- Map.get(acl_params, "owner_eve_id"),

View File

@@ -3,7 +3,7 @@ defmodule WandererAppWeb.CommonAPIController do
use OpenApiSpex.ControllerSpecs use OpenApiSpex.ControllerSpecs
alias WandererApp.CachedInfo alias WandererApp.CachedInfo
alias WandererAppWeb.UtilAPIController, as: Util alias WandererAppWeb.Helpers.APIUtils
alias WandererApp.EveDataService alias WandererApp.EveDataService
@system_static_response_schema %OpenApiSpex.Schema{ @system_static_response_schema %OpenApiSpex.Schema{
@@ -87,8 +87,8 @@ defmodule WandererAppWeb.CommonAPIController do
} }
] ]
def show_system_static(conn, params) do def show_system_static(conn, params) do
with {:ok, solar_system_str} <- Util.require_param(params, "id"), with {:ok, solar_system_str} <- APIUtils.require_param(params, "id"),
{:ok, solar_system_id} <- Util.parse_int(solar_system_str) do {:ok, solar_system_id} <- APIUtils.parse_int(solar_system_str) do
case CachedInfo.get_system_static_info(solar_system_id) do case CachedInfo.get_system_static_info(solar_system_id) do
{:ok, system} -> {:ok, system} ->
# Get basic system data # Get basic system data

View File

@@ -0,0 +1,50 @@
defmodule WandererAppWeb.FallbackController do
use WandererAppWeb, :controller
alias WandererAppWeb.Helpers.APIUtils
# Handles not_found errors from with/else
def call(conn, {:error, :not_found}) do
APIUtils.error_response(conn, :not_found, "Not found", "The requested resource could not be found")
end
# Handles invalid_id errors
def call(conn, {:error, :invalid_id}) do
APIUtils.error_response(conn, :bad_request, "Invalid system ID")
end
# Handles invalid_coordinates_format errors
def call(conn, {:error, :invalid_coordinates_format}) do
APIUtils.error_response(conn, :bad_request, "Invalid coordinates format. Use %{\"coordinates\" => %{\"x\" => number, \"y\" => number}}")
end
# Handles not_associated errors
def call(conn, {:error, :not_associated}) do
APIUtils.error_response(conn, :not_found, "Connection not associated with specified system")
end
# Handles not_involved errors
def call(conn, {:error, :not_involved}) do
APIUtils.error_response(conn, :bad_request, "Connection must involve specified system")
end
# Handles creation_failed errors
def call(conn, {:error, :creation_failed}) do
APIUtils.error_response(conn, :internal_server_error, "Failed to create resource")
end
# Handles deletion_failed errors
def call(conn, {:error, :deletion_failed}) do
APIUtils.error_response(conn, :internal_server_error, "Failed to delete resource")
end
# Handles any other {:error, message} returns
def call(conn, {:error, msg}) when is_binary(msg) do
APIUtils.error_response(conn, :bad_request, msg)
end
# Handles any other unmatched errors
def call(conn, _error) do
APIUtils.error_response(conn, :internal_server_error, "An unexpected error occurred")
end
end

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,7 @@ defmodule WandererAppWeb.MapAuditAPIController do
alias WandererApp.Zkb.KillsProvider.KillsCache alias WandererApp.Zkb.KillsProvider.KillsCache
alias WandererAppWeb.UtilAPIController, as: Util alias WandererAppWeb.Helpers.APIUtils
# ----------------------------------------------------------------- # -----------------------------------------------------------------
# Inline Schemas # Inline Schemas
@@ -117,8 +117,8 @@ defmodule WandererAppWeb.MapAuditAPIController do
) )
def index(conn, params) do def index(conn, params) do
with {:ok, map_id} <- Util.fetch_map_id(params), with {:ok, map_id} <- APIUtils.fetch_map_id(params),
{:ok, period} <- Util.require_param(params, "period"), {:ok, period} <- APIUtils.require_param(params, "period"),
query <- WandererApp.Map.Audit.get_activity_query(map_id, period, "all"), query <- WandererApp.Map.Audit.get_activity_query(map_id, period, "all"),
{:ok, data} <- {:ok, data} <-
Api.read(query) do Api.read(query) do

View File

@@ -0,0 +1,286 @@
# lib/wanderer_app_web/controllers/map_connection_api_controller.ex
defmodule WandererAppWeb.MapConnectionAPIController do
@moduledoc """
API controller for managing map connections.
Provides operations to list, show, create, delete, and batch-delete connections, with legacy routing support.
"""
use WandererAppWeb, :controller
use OpenApiSpex.ControllerSpecs
alias OpenApiSpex.Schema
alias WandererApp.Map, as: MapData
alias WandererApp.Map.Operations
alias WandererAppWeb.Helpers.APIUtils
alias WandererAppWeb.Schemas.{ApiSchemas, ResponseSchemas}
action_fallback WandererAppWeb.FallbackController
# -- JSON Schemas --
@map_connection_schema %Schema{
type: :object,
properties: %{
id: %Schema{type: :string, description: "Unique connection ID"},
map_id: %Schema{type: :string, description: "Map UUID"},
solar_system_source: %Schema{type: :integer, description: "Source system ID"},
solar_system_target: %Schema{type: :integer, description: "Target system ID"},
type: %Schema{type: :integer, description: "Connection type"},
mass_status: %Schema{type: :integer, description: "Mass status (0-3)"},
time_status: %Schema{type: :integer, description: "Time status (0-3)"},
ship_size_type: %Schema{type: :integer, description: "Ship size limit (0-3)"},
locked: %Schema{type: :boolean, description: "Locked flag"},
custom_info: %Schema{type: :string, nullable: true, description: "Optional metadata"},
wormhole_type: %Schema{type: :string, nullable: true, description: "Wormhole code"}
},
required: ~w(id map_id solar_system_source solar_system_target)a
}
@connection_request_schema %Schema{
type: :object,
properties: %{
solar_system_source: %Schema{type: :integer, description: "Source system ID"},
solar_system_target: %Schema{type: :integer, description: "Target system ID"},
type: %Schema{type: :integer, description: "Connection type (default 0)"}
},
required: ~w(solar_system_source solar_system_target)a,
example: %{solar_system_source: 30_000_142, solar_system_target: 30_000_144, type: 0}
}
@batch_delete_schema %Schema{
type: :object,
properties: %{
connection_ids: %Schema{
type: :array,
items: %Schema{type: :string, description: "Connection UUID"},
description: "IDs to delete"
}
},
required: ["connection_ids"]
}
@list_response_schema ApiSchemas.data_wrapper(%Schema{type: :array, items: @map_connection_schema})
@detail_response_schema ApiSchemas.data_wrapper(@map_connection_schema)
@batch_delete_response_schema ApiSchemas.data_wrapper(
%Schema{
type: :object,
properties: %{deleted_count: %Schema{type: :integer, description: "Deleted count"}},
required: ["deleted_count"]
}
)
# -- Actions --
operation :index,
summary: "List Map Connections",
parameters: [
map_slug: [in: :path, type: :string],
map_id: [in: :path, type: :string],
solar_system_source: [in: :query, type: :integer, required: false],
solar_system_target: [in: :query, type: :integer, required: false]
],
responses: ResponseSchemas.standard_responses(@list_response_schema)
def index(%{assigns: %{map_id: map_id}} = conn, params) do
with {:ok, src_filter} <- parse_optional(params, "solar_system_source"),
{:ok, tgt_filter} <- parse_optional(params, "solar_system_target") do
conns = MapData.list_connections!(map_id)
conns =
conns
|> filter_by_source(src_filter)
|> filter_by_target(tgt_filter)
data = Enum.map(conns, &APIUtils.connection_to_json/1)
APIUtils.respond_data(conn, data)
else
{:error, msg} when is_binary(msg) ->
conn
|> Plug.Conn.put_status(:bad_request)
|> APIUtils.error_response(:bad_request, msg)
{:error, _} ->
conn
|> Plug.Conn.put_status(:bad_request)
|> APIUtils.error_response(:bad_request, "Invalid filter parameter")
end
end
defp parse_optional(params, key) do
case Map.get(params, key) do
nil -> {:ok, nil}
val -> APIUtils.parse_int(val)
end
end
defp filter_by_source(conns, nil), do: conns
defp filter_by_source(conns, s), do: Enum.filter(conns, &(&1.solar_system_source == s))
defp filter_by_target(conns, nil), do: conns
defp filter_by_target(conns, t), do: Enum.filter(conns, &(&1.solar_system_target == t))
operation :show,
summary: "Show Connection (by id or by source/target)",
parameters: [map_slug: [in: :path], map_id: [in: :path], id: [in: :path, required: false], solar_system_source: [in: :query, type: :integer, required: false], solar_system_target: [in: :query, type: :integer, required: false]],
responses: ResponseSchemas.standard_responses(@detail_response_schema)
def show(%{assigns: %{map_id: map_id}} = conn, %{"id" => id}) do
case Operations.get_connection(map_id, id) do
{:ok, conn_struct} -> APIUtils.respond_data(conn, APIUtils.connection_to_json(conn_struct))
err -> err
end
end
def show(%{assigns: %{map_id: map_id}} = conn, %{"solar_system_source" => src, "solar_system_target" => tgt}) do
with {:ok, source} <- APIUtils.parse_int(src),
{:ok, target} <- APIUtils.parse_int(tgt),
{:ok, conn_struct} <- Operations.get_connection_by_systems(map_id, source, target) do
APIUtils.respond_data(conn, APIUtils.connection_to_json(conn_struct))
else
err -> err
end
end
operation :create,
summary: "Create Connection",
parameters: [map_slug: [in: :path], map_id: [in: :path], system_id: [in: :path]],
request_body: {"Connection create", "application/json", @connection_request_schema},
responses: ResponseSchemas.create_responses(@detail_response_schema)
def create(conn, params) do
map_id = conn.assigns[:map_id]
case Operations.create_connection(params, map_id) do
{:ok, conn_struct} when is_map(conn_struct) ->
conn
|> APIUtils.respond_data(APIUtils.connection_to_json(conn_struct), :created)
{:ok, :created} ->
conn
|> put_status(:created)
|> json(%{data: %{result: "created"}})
{:skip, :exists} ->
conn
|> put_status(:ok)
|> json(%{data: %{result: "exists"}})
{:error, reason} ->
conn
|> put_status(:bad_request)
|> json(%{error: reason})
other ->
conn
|> put_status(:internal_server_error)
|> json(%{error: "Unexpected error"})
end
end
def create(_, _), do: {:error, :bad_request}
operation :delete,
summary: "Delete Connection (by id or by source/target)",
parameters: [map_slug: [in: :path], map_id: [in: :path], id: [in: :path, required: false], solar_system_source: [in: :query, type: :integer, required: false], solar_system_target: [in: :query, type: :integer, required: false]],
responses: ResponseSchemas.delete_responses(nil)
def delete(%{assigns: %{map_id: map_id}} = conn, %{"id" => id}) do
case Operations.get_connection(map_id, id) do
{:ok, conn_struct} ->
case MapData.remove_connection(map_id, conn_struct) do
:ok -> send_resp(conn, :no_content, "")
error -> {:error, error}
end
err -> err
end
end
def delete(%{assigns: %{map_id: map_id}} = conn, %{"solar_system_source" => src, "solar_system_target" => tgt}) do
with {:ok, source} <- APIUtils.parse_int(src),
{:ok, target} <- APIUtils.parse_int(tgt),
{:ok, conn_struct} <- Operations.get_connection_by_systems(map_id, source, target) do
case MapData.remove_connection(map_id, conn_struct) do
:ok -> send_resp(conn, :no_content, "")
error -> {:error, error}
end
else
err -> err
end
end
operation :batch_delete,
summary: "Batch Delete Connections",
parameters: [map_slug: [in: :path], map_id: [in: :path]],
request_body: {"Batch delete", "application/json", @batch_delete_schema},
responses: ResponseSchemas.standard_responses(@batch_delete_response_schema),
deprecated: true,
description: "Deprecated. Use individual DELETE requests instead."
def batch_delete(%{assigns: %{map_id: map_id}} = conn, %{"connection_ids" => ids})
when is_list(ids) do
deleted_count =
ids
|> Enum.map(&fetch_and_delete(map_id, &1))
|> Enum.count(&(&1 == :ok))
APIUtils.respond_data(conn, %{deleted_count: deleted_count})
end
def batch_delete(_, _), do: {:error, :bad_request}
# -- Legacy route --
@deprecated "Use GET /api/maps/:map_identifier/systems/:system_id/connections instead"
operation :list_all_connections,
summary: "List All Connections (Legacy)",
deprecated: true,
parameters: [map_id: [in: :query]],
responses: ResponseSchemas.standard_responses(@list_response_schema)
defdelegate list_all_connections(conn, params), to: __MODULE__, as: :index
operation :update,
summary: "Update Connection (by id or by source/target)",
parameters: [map_slug: [in: :path], map_id: [in: :path], id: [in: :path, required: false], solar_system_source: [in: :query, type: :integer, required: false], solar_system_target: [in: :query, type: :integer, required: false]],
request_body: {"Connection update", "application/json", @connection_request_schema},
responses: ResponseSchemas.standard_responses(@detail_response_schema)
def update(%{assigns: %{map_id: map_id}} = conn, %{"id" => id}) do
allowed_fields = ["mass_status", "ship_size_type", "locked", "custom_info", "type"]
attrs =
conn.body_params
|> Map.take(allowed_fields)
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Enum.into(%{})
case Operations.update_connection(map_id, id, attrs) do
{:ok, updated_conn} -> APIUtils.respond_data(conn, APIUtils.connection_to_json(updated_conn))
err -> err
end
end
def update(%{assigns: %{map_id: map_id}} = conn, %{"solar_system_source" => src, "solar_system_target" => tgt}) do
allowed_fields = ["mass_status", "ship_size_type", "locked", "custom_info", "type"]
attrs =
conn.body_params
|> Map.take(allowed_fields)
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Enum.into(%{})
with {:ok, source} <- APIUtils.parse_int(src),
{:ok, target} <- APIUtils.parse_int(tgt),
{:ok, conn_struct} <- Operations.get_connection_by_systems(map_id, source, target),
{:ok, updated_conn} <- Operations.update_connection(map_id, conn_struct.id, attrs) do
APIUtils.respond_data(conn, APIUtils.connection_to_json(updated_conn))
else
{:error, :not_found} ->
{:error, :not_found}
{:error, reason} ->
{:error, reason}
error ->
{:error, :internal_server_error}
end
end
# -- Helpers --
defp involves_system?(%{"solar_system_source" => s, "solar_system_target" => t}, id),
do: s == id or t == id
defp involves_system?(%{solar_system_source: s, solar_system_target: t}, id),
do: s == id or t == id
defp fetch_and_delete(map_id, id) do
case Operations.get_connection(map_id, id) do
{:ok, conn_struct} -> MapData.remove_connection(map_id, conn_struct)
_ -> :error
end
end
defp fetch_connection!(map_id, id) do
MapData.list_connections!(map_id)
|> Enum.find(&(&1.id == id))
|> case do
nil -> raise "Connection #{id} not found"
conn -> conn
end
end
end

View File

@@ -0,0 +1,298 @@
# lib/wanderer_app_web/controllers/map_system_api_controller.ex
defmodule WandererAppWeb.MapSystemAPIController do
@moduledoc """
API controller for managing map systems and their associated connections.
Provides CRUD operations and batch upsert for systems and connections.
"""
use WandererAppWeb, :controller
use OpenApiSpex.ControllerSpecs
alias OpenApiSpex.Schema
alias WandererApp.Map.Operations
alias WandererAppWeb.Helpers.APIUtils
alias WandererAppWeb.Schemas.{ApiSchemas, ResponseSchemas}
action_fallback WandererAppWeb.FallbackController
# -- JSON Schemas --
@map_system_schema %Schema{
type: :object,
properties: %{
id: %Schema{type: :string, description: "Map system UUID"},
map_id: %Schema{type: :string, description: "Map UUID"},
solar_system_id: %Schema{type: :integer, description: "EVE solar system ID"},
solar_system_name: %Schema{type: :string, description: "EVE solar system name"},
region_name: %Schema{type: :string, description: "EVE region name"},
position_x: %Schema{type: :number, format: :float, description: "X coordinate"},
position_y: %Schema{type: :number, format: :float, description: "Y coordinate"},
status: %Schema{type: :string, description: "System status"},
visible: %Schema{type: :boolean, description: "Visibility flag"},
description: %Schema{type: :string, nullable: true, description: "Custom description"},
tag: %Schema{type: :string, nullable: true, description: "Custom tag"},
locked: %Schema{type: :boolean, description: "Lock flag"},
temporary_name: %Schema{type: :string, nullable: true, description: "Temporary name"},
labels: %Schema{type: :array, items: %Schema{type: :string}, nullable: true, description: "Labels"}
},
required: ~w(id map_id solar_system_id)a
}
@system_request_schema %Schema{
type: :object,
properties: %{
solar_system_id: %Schema{type: :integer, description: "EVE solar system ID"},
solar_system_name: %Schema{type: :string, description: "EVE solar system name"},
position_x: %Schema{type: :number, format: :float, description: "X coordinate"},
position_y: %Schema{type: :number, format: :float, description: "Y coordinate"},
status: %Schema{type: :string, description: "System status"},
visible: %Schema{type: :boolean, description: "Visibility flag"},
description: %Schema{type: :string, nullable: true, description: "Custom description"},
tag: %Schema{type: :string, nullable: true, description: "Custom tag"},
locked: %Schema{type: :boolean, description: "Lock flag"},
temporary_name: %Schema{type: :string, nullable: true, description: "Temporary name"},
labels: %Schema{type: :array, items: %Schema{type: :string}, nullable: true, description: "Labels"}
},
required: ~w(solar_system_id)a,
example: %{
solar_system_id: 30_000_142,
solar_system_name: "Jita",
position_x: 100.5,
position_y: 200.3,
visible: true
}
}
@system_update_schema %Schema{
type: :object,
properties: %{
solar_system_name: %Schema{type: :string, description: "EVE solar system name", nullable: true},
position_x: %Schema{type: :number, format: :float, description: "X coordinate", nullable: true},
position_y: %Schema{type: :number, format: :float, description: "Y coordinate", nullable: true},
status: %Schema{type: :string, description: "System status", nullable: true},
visible: %Schema{type: :boolean, description: "Visibility flag", nullable: true},
description: %Schema{type: :string, nullable: true, description: "Custom description"},
tag: %Schema{type: :string, nullable: true, description: "Custom tag"},
locked: %Schema{type: :boolean, description: "Lock flag", nullable: true},
temporary_name: %Schema{type: :string, nullable: true, description: "Temporary name"},
labels: %Schema{type: :array, items: %Schema{type: :string}, nullable: true, description: "Labels"}
},
example: %{
position_x: 100.5,
position_y: 200.3,
visible: true
}
}
@list_response_schema ApiSchemas.data_wrapper(%Schema{type: :array, items: @map_system_schema})
@detail_response_schema ApiSchemas.data_wrapper(@map_system_schema)
@delete_response_schema ApiSchemas.data_wrapper(%Schema{
type: :object,
properties: %{deleted: %Schema{type: :boolean, description: "Deleted flag"}},
required: ["deleted"]
})
@batch_response_schema ApiSchemas.data_wrapper(%Schema{
type: :object,
properties: %{
systems: %Schema{
type: :object,
properties: %{created: %Schema{type: :integer}, updated: %Schema{type: :integer}},
required: ~w(created updated)a
},
connections: %Schema{
type: :object,
properties: %{
created: %Schema{type: :integer},
updated: %Schema{type: :integer},
deleted: %Schema{type: :integer}
},
required: ~w(created updated deleted)a
}
},
required: ~w(systems connections)a
})
@batch_delete_schema %Schema{
type: :object,
properties: %{
system_ids: %Schema{
type: :array,
items: %Schema{type: :integer},
description: "IDs to delete"
},
connection_ids: %Schema{
type: :array,
items: %Schema{type: :string},
description: "Connection UUIDs to delete",
nullable: true
}
},
required: ["system_ids"]
}
@batch_delete_response_schema ApiSchemas.data_wrapper(%Schema{
type: :object,
properties: %{deleted_count: %Schema{type: :integer, description: "Deleted count"}},
required: ["deleted_count"]
})
@batch_request_schema ApiSchemas.data_wrapper(%Schema{
type: :object,
properties: %{
systems: %Schema{type: :array, items: @system_request_schema},
connections: %Schema{type: :array, items: %Schema{
type: :object,
properties: %{
solar_system_source: %Schema{type: :integer, description: "Source system ID"},
solar_system_target: %Schema{type: :integer, description: "Target system ID"},
type: %Schema{type: :integer, description: "Connection type (default 0)"},
mass_status: %Schema{type: :integer, description: "Mass status (0-3)", nullable: true},
time_status: %Schema{type: :integer, description: "Time decay status (0-3)", nullable: true},
ship_size_type: %Schema{type: :integer, description: "Ship size limit (0-3)", nullable: true},
locked: %Schema{type: :boolean, description: "Lock flag", nullable: true},
custom_info: %Schema{type: :string, description: "Optional metadata", nullable: true}
},
required: ~w(solar_system_source solar_system_target)a
}}
}
})
# -- Actions --
operation :index,
summary: "List Map Systems and Connections",
parameters: [map_slug: [in: :path], map_id: [in: :path]],
responses: ResponseSchemas.standard_responses(@list_response_schema)
def index(%{assigns: %{map_id: map_id}} = conn, _params) do
systems = Operations.list_systems(map_id) |> Enum.map(&APIUtils.map_system_to_json/1)
connections = Operations.list_connections(map_id) |> Enum.map(&APIUtils.connection_to_json/1)
APIUtils.respond_data(conn, %{systems: systems, connections: connections})
end
operation :show,
summary: "Show Map System",
parameters: [map_slug: [in: :path], map_id: [in: :path], id: [in: :path]],
responses: ResponseSchemas.standard_responses(@detail_response_schema)
def show(%{assigns: %{map_id: map_id}} = conn, %{"id" => id}) do
with {:ok, system_id} <- APIUtils.parse_int(id),
{:ok, system} <- Operations.get_system(map_id, system_id) do
APIUtils.respond_data(conn, APIUtils.map_system_to_json(system))
end
end
operation :create,
summary: "Upsert Systems and Connections (batch or single)",
request_body: {"Systems+Connections upsert", "application/json", @batch_request_schema},
responses: ResponseSchemas.standard_responses(@batch_response_schema)
def create(%{assigns: %{map_id: map_id}} = conn, params) do
systems = Map.get(params, "systems", [])
connections = Map.get(params, "connections", [])
with {:ok, result} <- Operations.upsert_systems_and_connections(map_id, systems, connections) do
APIUtils.respond_data(conn, result)
else
error ->
error
end
end
operation :update,
summary: "Update System",
parameters: [map_slug: [in: :path], map_id: [in: :path], id: [in: :path]],
request_body: {"System update request", "application/json", @system_update_schema},
responses: ResponseSchemas.update_responses(@detail_response_schema)
def update(%{assigns: %{map_id: map_id}} = conn, %{"id" => id} = params) do
with {:ok, sid} <- APIUtils.parse_int(id),
{:ok, attrs} <- APIUtils.extract_update_params(params),
update_attrs = Map.put(attrs, "solar_system_id", sid),
{:ok, system} <- Operations.update_system(map_id, sid, update_attrs) do
APIUtils.respond_data(conn, APIUtils.map_system_to_json(system))
end
end
operation :delete,
summary: "Batch Delete Systems and Connections",
request_body: {"Batch delete", "application/json", @batch_delete_schema},
responses: ResponseSchemas.standard_responses(@batch_delete_response_schema)
def delete(%{assigns: %{map_id: map_id}} = conn, params) do
system_ids = Map.get(params, "system_ids", [])
connection_ids = Map.get(params, "connection_ids", [])
deleted_systems = Enum.map(system_ids, fn id ->
case APIUtils.parse_int(id) do
{:ok, sid} -> Operations.delete_system(map_id, sid)
_ -> {:error, :invalid_id}
end
end)
deleted_connections = Enum.map(connection_ids, fn id ->
case Operations.get_connection(map_id, id) do
{:ok, conn_struct} ->
case WandererApp.Map.Server.delete_connection(map_id, conn_struct) do
:ok -> {:ok, conn_struct}
error -> error
end
_ -> {:error, :invalid_id}
end
end)
systems_deleted = Enum.count(deleted_systems, &match?({:ok, _}, &1))
connections_deleted = Enum.count(deleted_connections, &match?({:ok, _}, &1))
deleted_count = systems_deleted + connections_deleted
APIUtils.respond_data(conn, %{deleted_count: deleted_count})
end
operation :delete_single,
summary: "Delete a single Map System",
parameters: [map_slug: [in: :path], map_id: [in: :path], id: [in: :path]],
responses: ResponseSchemas.standard_responses(@delete_response_schema)
def delete_single(%{assigns: %{map_id: map_id}} = conn, %{"id" => id}) do
with {:ok, sid} <- APIUtils.parse_int(id),
{:ok, _} <- Operations.delete_system(map_id, sid) do
APIUtils.respond_data(conn, %{deleted: true})
else
{:error, :not_found} ->
conn
|> put_status(:not_found)
|> APIUtils.respond_data(%{deleted: false, error: "System not found"})
{:error, reason} ->
conn
|> put_status(:unprocessable_entity)
|> APIUtils.respond_data(%{deleted: false, error: "Failed to delete system", reason: reason})
_ ->
conn
|> put_status(:bad_request)
|> APIUtils.respond_data(%{deleted: false, error: "Invalid system ID format"})
end
end
# -- Legacy endpoints --
operation :list_systems,
summary: "List Map Systems (Legacy)",
deprecated: true,
description: "Deprecated, use GET /api/maps/:map_identifier/systems instead",
parameters: [map_id: [in: :query]],
responses: ResponseSchemas.standard_responses(@list_response_schema)
defdelegate list_systems(conn, params), to: __MODULE__, as: :index
operation :show_system,
summary: "Show Map System (Legacy)",
deprecated: true,
description: "Deprecated, use GET /api/maps/:map_identifier/systems/:id instead",
parameters: [map_id: [in: :query], id: [in: :query]],
responses: ResponseSchemas.standard_responses(@detail_response_schema)
defdelegate show_system(conn, params), to: __MODULE__, as: :show
@deprecated "Use GET /api/maps/:map_identifier/systems instead"
operation :list_all_connections,
summary: "List All Connections (Legacy)",
deprecated: true,
parameters: [map_id: [in: :query]],
responses: ResponseSchemas.standard_responses(@list_response_schema)
def list_all_connections(%{assigns: %{map_id: map_id}} = conn, _params) do
connections = Operations.list_connections(map_id)
data = Enum.map(connections, &APIUtils.connection_to_json/1)
APIUtils.respond_data(conn, data)
end
end

View File

@@ -1,52 +1,116 @@
defmodule WandererAppWeb.Plugs.CheckMapApiKey do defmodule WandererAppWeb.Plugs.CheckMapApiKey do
@moduledoc """ @behaviour Plug
A plug that checks the "Authorization: Bearer <token>" header
against the map's stored public_api_key. Halts with 401 if invalid.
"""
import Plug.Conn import Plug.Conn
alias WandererAppWeb.UtilAPIController, as: Util alias Plug.Crypto
alias WandererApp.Api.Map, as: ApiMap
alias WandererAppWeb.Schemas.ResponseSchemas, as: R
require Logger
@impl true
def init(opts), do: opts def init(opts), do: opts
@impl true
def call(conn, _opts) do def call(conn, _opts) do
header = get_req_header(conn, "authorization") |> List.first() with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
{:ok, map_id} <- fetch_map_id(conn),
{:ok, map} <- ApiMap.by_id(map_id),
true <- is_binary(map.public_api_key) &&
Crypto.secure_compare(map.public_api_key, token)
do
conn
|> assign(:map, map)
|> assign(:map_id, map.id)
else
[] ->
Logger.warning("Missing or invalid 'Bearer' token")
conn |> respond(401, "Missing or invalid 'Bearer' token") |> halt()
case header do {:error, :bad_request, msg} ->
"Bearer " <> incoming_token -> Logger.warning("Bad request: #{msg}")
case fetch_map(conn.query_params) do conn |> respond(400, msg) |> halt()
{:ok, map} ->
if map.public_api_key == incoming_token do
conn
else
conn
|> put_resp_content_type("application/json")
|> send_resp(401, Jason.encode!(%{error: "Unauthorized (invalid token for map)"}))
|> halt()
end
{:error, _reason} -> {:error, :not_found, msg} ->
conn Logger.warning("Not found: #{msg}")
|> put_resp_content_type("application/json") conn |> respond(404, msg) |> halt()
|> send_resp(404, Jason.encode!(%{error: "Map not found"}))
|> halt()
end
_ -> {:error, _} ->
Logger.warning("Map identifier required")
conn conn
|> put_resp_content_type("application/json") |> respond(400, "Map identifier required. Provide `map_identifier` in the path or `map_id`/`slug` in query.")
|> send_resp(401, Jason.encode!(%{error: "Missing or invalid 'Bearer' token"}))
|> halt() |> halt()
end
end
defp fetch_map(query_params) do false ->
case Util.fetch_map_id(query_params) do Logger.warning("Unauthorized: invalid token for map #{inspect(conn.params["map_identifier"])}")
{:ok, map_id} -> conn |> respond(401, "Unauthorized (invalid token for map)") |> halt()
WandererApp.Api.Map.by_id(map_id)
error -> error ->
error Logger.error("Unexpected error: #{inspect(error)}")
conn |> respond(500, "Unexpected error") |> halt()
end end
end end
# Try unified path param first, then fall back to legacy query params
defp fetch_map_id(%Plug.Conn{params: %{"map_identifier" => id}}) when is_binary(id) and id != "" do
resolve_identifier(id)
end
defp fetch_map_id(conn), do: legacy_fetch(conn)
# Try ID lookup first, then slug lookup
defp resolve_identifier(id) do
case ApiMap.by_id(id) do
{:ok, %{id: map_id}} ->
{:ok, map_id}
_ ->
case ApiMap.get_map_by_slug(id) do
{:ok, %{id: map_id}} ->
{:ok, map_id}
_ ->
{:error, :not_found, "Map not found for identifier: #{id}"}
end
end
end
# Legacy: check assigns, then params["map_id"], then params["slug"]
defp legacy_fetch(conn) do
map_id_from_assign = conn.assigns[:map_id]
map_id_param = conn.params["map_id"]
slug_param = conn.params["slug"]
cond do
is_binary(map_id_from_assign) and map_id_from_assign != "" ->
{:ok, map_id_from_assign}
is_binary(map_id_param) and map_id_param != "" ->
{:ok, map_id_param}
is_binary(slug_param) and slug_param != "" ->
case ApiMap.get_map_by_slug(slug_param) do
{:ok, %{id: map_id}} -> {:ok, map_id}
_ -> {:error, :not_found, "Map not found for slug: #{slug_param}"}
end
true ->
{:error, :bad_request,
"Map identifier required. Provide `map_identifier` in the path or `map_id`/`slug` in query."}
end
end
# Pick the right shared schema and send JSON
defp respond(conn, status, msg) do
{_desc, content_type, _schema} =
case status do
400 -> R.bad_request(msg)
401 -> R.unauthorized(msg)
404 -> R.not_found(msg)
500 -> R.internal_server_error(msg)
_ -> R.internal_server_error("Unexpected error")
end
conn
|> put_resp_content_type(content_type)
|> send_resp(status, Jason.encode!(%{error: msg}))
end
end end

View File

@@ -5,11 +5,13 @@ defmodule WandererAppWeb.Plugs.CheckMapSubscription do
""" """
import Plug.Conn import Plug.Conn
require Logger
def init(opts), do: opts def init(opts), do: opts
def call(conn, _opts) do def call(conn, _opts) do
case fetch_map_id(conn.query_params) do # First check if map_id is already in conn.assigns (from CheckMapApiKey)
case get_map_id_from_assigns_or_params(conn) do
{:ok, map_id} -> {:ok, map_id} ->
{:ok, is_subscription_active} = map_id |> WandererApp.Map.is_subscription_active?() {:ok, is_subscription_active} = map_id |> WandererApp.Map.is_subscription_active?()
@@ -28,6 +30,17 @@ defmodule WandererAppWeb.Plugs.CheckMapSubscription do
end end
end end
# First try to get map_id from conn.assigns
defp get_map_id_from_assigns_or_params(conn) do
if Map.has_key?(conn.assigns, :map_id) do
Logger.debug("Found map_id in conn.assigns: #{conn.assigns.map_id}")
{:ok, conn.assigns.map_id}
else
# Fall back to query params if not in assigns
fetch_map_id(conn.query_params)
end
end
defp fetch_map_id(%{"map_id" => mid}) when is_binary(mid) and mid != "" do defp fetch_map_id(%{"map_id" => mid}) when is_binary(mid) and mid != "" do
{:ok, mid} {:ok, mid}
end end

View File

@@ -1,41 +0,0 @@
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

View File

@@ -0,0 +1,294 @@
defmodule WandererAppWeb.Helpers.APIUtils do
@moduledoc """
Unified helper module for API operations:
- Parameter parsing and validation
- Map ID resolution
- Standardized responses
- JSON serialization
"""
# Explicit imports to avoid unnecessary dependencies
import Plug.Conn, only: [put_status: 2]
import Phoenix.Controller, only: [json: 2]
alias WandererApp.Api.Map, as: MapApi
alias WandererApp.Api.MapSolarSystem
require Logger
# -----------------------------------------------------------------------------
# Map ID Resolution
# -----------------------------------------------------------------------------
@spec fetch_map_id(map()) :: {:ok, String.t()} | {:error, String.t()}
def fetch_map_id(%{"map_id" => id}) when is_binary(id) do
case Ecto.UUID.cast(id) do
{:ok, _} -> {:ok, id}
:error -> {:error, "Invalid UUID format for map_id: #{id}"}
end
end
def fetch_map_id(%{"slug" => slug}) when is_binary(slug) do
case MapApi.get_map_by_slug(slug) do
{:ok, %{id: id}} -> {:ok, id}
_ -> {:error, "No map found for slug=#{slug}"}
end
end
def fetch_map_id(_), do: {:error, "Must provide either ?map_id=UUID or ?slug=SLUG"}
# -----------------------------------------------------------------------------
# Parameter Validators and Parsers
# -----------------------------------------------------------------------------
@spec require_param(map(), String.t()) :: {:ok, any()} | {:error, String.t()}
def require_param(params, key) do
case Map.fetch(params, key) do
{:ok, val} when is_binary(val) ->
trimmed = String.trim(val)
if trimmed == "" do
{:error, "Param #{key} cannot be empty"}
else
{:ok, trimmed}
end
{:ok, val} ->
{:ok, val}
:error ->
{:error, "Missing required param: #{key}"}
end
end
@spec parse_int(binary() | integer()) :: {:ok, integer()} | {:error, String.t()}
def parse_int(str) when is_binary(str) do
Logger.debug("Parsing integer from: #{inspect(str)}")
case Integer.parse(str) do
{num, ""} -> {:ok, num}
_ -> {:error, "Invalid integer format: #{str}"}
end
end
def parse_int(num) when is_integer(num), do: {:ok, num}
def parse_int(other), do: {:error, "Expected integer or string, got: #{inspect(other)}"}
@spec parse_int!(binary() | integer()) :: integer()
def parse_int!(str) do
case parse_int(str) do
{:ok, num} -> num
{:error, msg} -> raise ArgumentError, msg
end
end
@spec validate_uuid(any()) :: {:ok, String.t()} | {:error, String.t()}
def validate_uuid(id) when is_binary(id) do
case Ecto.UUID.cast(id) do
{:ok, uuid} -> {:ok, uuid}
:error -> {:error, "Invalid UUID format: #{id}"}
end
end
def validate_uuid(_), do: {:error, "ID must be a UUID string"}
# -----------------------------------------------------------------------------
# Parameter Extraction
# -----------------------------------------------------------------------------
@doc """
Extract and validate parameters for upserting a system.
Returns {:ok, attrs} or {:error, error_message}.
"""
@spec extract_upsert_params(map()) :: {:ok, map()} | {:error, String.t()}
def extract_upsert_params(params) when is_map(params) do
required = ["solar_system_id"]
optional = [
"solar_system_name", "position_x", "position_y", "coordinates",
"status", "visible", "description", "tag",
"locked", "temporary_name", "labels"
]
case Map.fetch(params, "solar_system_id") do
:error -> {:error, "Missing solar_system_id in request body"}
{:ok, _} ->
params
|> Map.take(required ++ optional)
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Enum.into(%{})
|> then(&{:ok, &1})
end
end
@doc """
Extract and validate parameters for updating a system.
Returns {:ok, attrs} or {:error, error_message}.
"""
@spec extract_update_params(map()) :: {:ok, map()} | {:error, String.t()}
def extract_update_params(params) when is_map(params) do
allowed = [
"solar_system_name", "position_x", "position_y", "coordinates",
"status", "visible", "description", "tag",
"locked", "temporary_name", "labels"
]
attrs =
params
|> Map.take(allowed)
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Enum.into(%{})
{:ok, attrs}
end
@spec normalize_connection_params(map()) :: {:ok, map()} | {:error, String.t()}
def normalize_connection_params(params) do
# Convert all keys to strings for consistent access
string_params = for {k, v} <- params, into: %{} do
{to_string(k), v}
end
# Define parameter mappings for normalization
aliases = %{
"source" => "solar_system_source",
"source_id" => "solar_system_source",
"target" => "solar_system_target",
"target_id" => "solar_system_target"
}
# Normalize parameters using aliases
normalized_params = Enum.reduce(aliases, string_params, fn {alias_key, std_key}, acc ->
if Map.has_key?(acc, alias_key) && !Map.has_key?(acc, std_key) do
Map.put(acc, std_key, acc[alias_key])
else
acc
end
end)
# Handle required parameters
with {:ok, src} <- parse_to_int(normalized_params["solar_system_source"], "solar_system_source"),
{:ok, tgt} <- parse_to_int(normalized_params["solar_system_target"], "solar_system_target") do
# Handle optional parameters with sane defaults
type = normalized_params["type"] || 0
mass_status = normalized_params["mass_status"] || 0
time_status = normalized_params["time_status"] || 0
ship_size_type = normalized_params["ship_size_type"] || 0
locked = normalized_params["locked"] || false
custom_info = normalized_params["custom_info"]
wormhole_type = normalized_params["wormhole_type"]
# Build standardized attrs map
attrs = %{
"solar_system_source" => src,
"solar_system_target" => tgt,
"type" => parse_optional_int(type, 0),
"mass_status" => parse_optional_int(mass_status, 0),
"time_status" => parse_optional_int(time_status, 0),
"ship_size_type" => parse_optional_int(ship_size_type, 0)
}
# Add non-nil optional attributes
attrs = if is_nil(locked), do: attrs, else: Map.put(attrs, "locked", locked)
attrs = if is_nil(custom_info), do: attrs, else: Map.put(attrs, "custom_info", custom_info)
attrs = if is_nil(wormhole_type), do: attrs, else: Map.put(attrs, "wormhole_type", wormhole_type)
{:ok, attrs}
else
{:error, msg} -> {:error, msg}
end
end
# Helper to handle various input formats
defp parse_to_int(nil, field), do: {:error, "Missing #{field}"}
defp parse_to_int(val, _field) when is_integer(val), do: {:ok, val}
defp parse_to_int(val, field) when is_binary(val) do
case Integer.parse(val) do
{i, ""} -> {:ok, i}
:error -> {:error, "Invalid #{field}: #{val}"}
_ -> {:error, "Invalid #{field}: #{val}"}
end
end
defp parse_to_int(val, field), do: {:error, "Invalid #{field} type: #{inspect(val)}"}
defp parse_optional_int(nil, default), do: default
defp parse_optional_int(i, _default) when is_integer(i), do: i
defp parse_optional_int(s, default) when is_binary(s) do
case Integer.parse(s) do
{i, _} -> i
:error -> default
end
end
# -----------------------------------------------------------------------------
# Standardized JSON Responses
# -----------------------------------------------------------------------------
@spec respond_data(Plug.Conn.t(), any(), atom() | integer()) :: Plug.Conn.t()
def respond_data(conn, data, status \\ :ok) do
conn
|> put_status(status)
|> json(%{data: data})
end
@spec error_response(Plug.Conn.t(), atom() | integer(), String.t(), map() | nil) :: Plug.Conn.t()
def error_response(conn, status, message, details \\ nil) do
body = if details, do: %{error: message, details: details}, else: %{error: message}
conn
|> put_status(status)
|> json(body)
end
@spec error_not_found(Plug.Conn.t(), String.t()) :: Plug.Conn.t()
def error_not_found(conn, message), do: error_response(conn, :not_found, message)
@doc """
Formats error messages for consistent display.
"""
@spec format_error(any()) :: String.t()
def format_error(error) when is_binary(error), do: error
def format_error(error) when is_atom(error), do: Atom.to_string(error)
def format_error(error), do: inspect(error)
# -----------------------------------------------------------------------------
# JSON Serialization
# -----------------------------------------------------------------------------
@spec map_system_to_json(struct()) :: map()
def map_system_to_json(system) do
base =
Map.take(system, ~w(
id map_id solar_system_id custom_name temporary_name description tag labels
locked visible status position_x position_y inserted_at updated_at
)a)
original = get_original_name(system.solar_system_id)
name = pick_name(system)
base
|> Map.put(:original_name, original)
|> Map.put(:name, name)
end
defp get_original_name(id) do
case MapSolarSystem.by_solar_system_id(id) do
{:ok, sys} -> sys.solar_system_name
_ -> "System #{id}"
end
end
defp pick_name(%{temporary_name: t, custom_name: c, solar_system_id: id}) do
cond do
t not in [nil, ""] -> t
c not in [nil, ""] -> c
true -> get_original_name(id)
end
end
@spec connection_to_json(struct()) :: map()
def connection_to_json(conn) do
Map.take(conn, ~w(
id map_id solar_system_source solar_system_target mass_status
time_status ship_size_type type wormhole_type inserted_at updated_at
)a)
end
end

View File

@@ -206,17 +206,37 @@ defmodule WandererAppWeb.Router do
scope "/api/map", WandererAppWeb do scope "/api/map", WandererAppWeb do
pipe_through [:api, :api_map] pipe_through [:api, :api_map]
get "/audit", MapAuditAPIController, :index get "/audit", MapAuditAPIController, :index
get "/systems", MapAPIController, :list_systems # Deprecated routes - use /api/maps/:map_identifier/systems instead
get "/system", MapAPIController, :show_system get "/systems", MapSystemAPIController, :list_systems
get "/connections", MapAPIController, :list_connections get "/system", MapSystemAPIController, :show_system
get "/characters", MapAPIController, :tracked_characters_with_info get "/connections", MapSystemAPIController, :list_all_connections
get "/characters", MapAPIController, :list_tracked_characters
get "/structure-timers", MapAPIController, :show_structure_timers get "/structure-timers", MapAPIController, :show_structure_timers
get "/character-activity", MapAPIController, :character_activity get "/character-activity", MapAPIController, :character_activity
get "/user_characters", MapAPIController, :user_characters get "/user_characters", MapAPIController, :user_characters
get "/acls", MapAccessListAPIController, :index get "/acls", MapAccessListAPIController, :index
post "/acls", MapAccessListAPIController, :create post "/acls", MapAccessListAPIController, :create
end end
#
# Unified RESTful routes for systems & connections by slug or ID
#
scope "/api/maps/:map_identifier", WandererAppWeb do
pipe_through [:api, :api_map]
patch "/connections", MapConnectionAPIController, :update
delete "/connections", MapConnectionAPIController, :delete
delete "/systems", MapSystemAPIController, :delete
resources "/systems", MapSystemAPIController, only: [:index, :show, :create, :update, :delete]
resources "/connections", MapConnectionAPIController, only: [:index, :show, :create, :update, :delete], param: "id"
end
#
# Other API routes
#
scope "/api/characters", WandererAppWeb do scope "/api/characters", WandererAppWeb do
pipe_through [:api, :api_character] pipe_through [:api, :api_character]
get "/", CharactersAPIController, :index get "/", CharactersAPIController, :index

View File

@@ -0,0 +1,156 @@
defmodule WandererAppWeb.Schemas.ApiSchemas do
@moduledoc """
Shared OpenAPI schema definitions for the Wanderer API.
This module defines common schema components that can be reused
across different controller specifications.
"""
alias OpenApiSpex.Schema
# Standard response wrappers
def data_wrapper(schema) do
%Schema{
type: :object,
properties: %{
data: schema
},
required: ["data"]
}
end
# Standard error responses
def error_response(description \\ "Error") do
%Schema{
type: :object,
properties: %{
error: %Schema{type: :string, description: "Brief error message"},
details: %Schema{type: :string, description: "Detailed explanation", nullable: true},
code: %Schema{type: :string, description: "Optional error code", nullable: true}
},
required: ["error"],
example: %{"error" => description, "details" => "Additional information about the error"}
}
end
# Common entity schemas
def character_schema do
%Schema{
type: :object,
properties: %{
eve_id: %Schema{type: :string},
name: %Schema{type: :string},
corporation_id: %Schema{type: :string},
corporation_ticker: %Schema{type: :string},
alliance_id: %Schema{type: :string},
alliance_ticker: %Schema{type: :string}
},
required: ["eve_id", "name"]
}
end
# Common system schema based on what we've seen in controllers
def solar_system_basic_schema do
%Schema{
type: :object,
properties: %{
solar_system_id: %Schema{type: :integer},
solar_system_name: %Schema{type: :string},
region_id: %Schema{type: :integer},
region_name: %Schema{type: :string},
constellation_id: %Schema{type: :integer},
constellation_name: %Schema{type: :string},
security: %Schema{type: :string}
},
required: ["solar_system_id", "solar_system_name"]
}
end
# Map schema with common fields
def map_basic_schema do
%Schema{
type: :object,
properties: %{
id: %Schema{type: :string},
name: %Schema{type: :string},
slug: %Schema{type: :string},
description: %Schema{type: :string},
owner_id: %Schema{type: :string},
inserted_at: %Schema{type: :string, format: :date_time},
updated_at: %Schema{type: :string, format: :date_time}
},
required: ["id", "name", "slug"]
}
end
# License schema
def license_schema do
%Schema{
type: :object,
properties: %{
id: %Schema{type: :string},
license_key: %Schema{type: :string},
is_valid: %Schema{type: :boolean},
expire_at: %Schema{type: :string, format: :date_time},
map_id: %Schema{type: :string}
},
required: ["id", "license_key", "is_valid", "map_id"]
}
end
# Access list schema
def access_list_schema do
%Schema{
type: :object,
properties: %{
id: %Schema{type: :string},
name: %Schema{type: :string},
description: %Schema{type: :string},
owner_id: %Schema{type: :string},
api_key: %Schema{type: :string},
inserted_at: %Schema{type: :string, format: :date_time},
updated_at: %Schema{type: :string, format: :date_time}
},
required: ["id", "name"]
}
end
# Access list member schema
def access_list_member_schema do
%Schema{
type: :object,
properties: %{
id: %Schema{type: :string},
name: %Schema{type: :string},
role: %Schema{type: :string},
eve_character_id: %Schema{type: :string},
eve_corporation_id: %Schema{type: :string},
eve_alliance_id: %Schema{type: :string},
inserted_at: %Schema{type: :string, format: :date_time},
updated_at: %Schema{type: :string, format: :date_time}
},
required: ["id", "name", "role"]
}
end
# Common paginated response wrapper
def paginated_response(items_schema) do
%Schema{
type: :object,
properties: %{
data: items_schema,
pagination: %Schema{
type: :object,
properties: %{
page: %Schema{type: :integer},
page_size: %Schema{type: :integer},
total_pages: %Schema{type: :integer},
total_count: %Schema{type: :integer}
},
required: ["page", "page_size", "total_count"]
}
},
required: ["data", "pagination"]
}
end
end

View File

@@ -0,0 +1,115 @@
defmodule WandererAppWeb.Schemas.ResponseSchemas do
@moduledoc """
Standard response schema definitions for API responses.
This module provides helper functions to create standardized
HTTP response schemas for OpenAPI documentation.
"""
alias OpenApiSpex.Schema
alias WandererAppWeb.Schemas.ApiSchemas
# Standard response status codes
def ok(schema, description \\ "Successful operation") do
{
description,
"application/json",
schema
}
end
def created(schema, description \\ "Resource created") do
{
description,
"application/json",
schema
}
end
def bad_request(description \\ "Bad request") do
{
description,
"application/json",
ApiSchemas.error_response(description)
}
end
def not_found(description \\ "Resource not found") do
{
description,
"application/json",
ApiSchemas.error_response(description)
}
end
def internal_server_error(description \\ "Internal server error") do
{
description,
"application/json",
ApiSchemas.error_response(description)
}
end
def unauthorized(description \\ "Unauthorized") do
{
description,
"application/json",
ApiSchemas.error_response(description)
}
end
def forbidden(description \\ "Forbidden") do
{
description,
"application/json",
ApiSchemas.error_response(description)
}
end
# Helper for common response patterns
def standard_responses(success_schema, success_description \\ "Successful operation") do
[
ok: ok(success_schema, success_description),
bad_request: bad_request(),
not_found: not_found(),
internal_server_error: internal_server_error()
]
end
# Helper for create operation responses
def create_responses(created_schema, created_description \\ "Resource created") do
[
created: created(created_schema, created_description),
bad_request: bad_request(),
internal_server_error: internal_server_error()
]
end
# Helper for update operation responses
def update_responses(updated_schema, updated_description \\ "Resource updated") do
[
ok: ok(updated_schema, updated_description),
bad_request: bad_request(),
not_found: not_found(),
internal_server_error: internal_server_error()
]
end
# Helper for delete operation responses
def delete_responses(deleted_schema \\ nil, deleted_description \\ "Resource deleted") do
if deleted_schema do
[
ok: ok(deleted_schema, deleted_description),
not_found: not_found(),
internal_server_error: internal_server_error()
]
else
[
no_content:
{deleted_description <> " (no content)", nil, nil},
not_found: not_found(),
internal_server_error: internal_server_error()
]
end
end
end

View File

@@ -0,0 +1,501 @@
%{
title: "Guide: Systems and Connections API",
author: "Wanderer Team",
cover_image_uri: "/images/news/03-06-systems/api-endpoints.png",
tags: ~w(api map systems connections documentation),
description: "Detailed guide for Wanderer's systems and connections API endpoints, including batch operations, updates, and deletions."
}
---
# Guide to Wanderer's Systems and Connections API
## Introduction
This guide covers Wanderer's dedicated API endpoints for managing systems and connections on your maps. These endpoints provide fine-grained control over individual systems and connections, as well as batch operations for efficient updates.
With these APIs, you can:
- Create, update, and delete individual systems
- Create, update, and delete individual connections
- Perform batch operations on systems and connections
- Query system and connection details
---
## Authentication
All endpoints require a Map API Token, which you can generate in your map settings. Pass the token in the Authorization header:
```bash
Authorization: Bearer <YOUR_MAP_TOKEN>
```
---
## Systems Endpoints
### 1. List Systems
```bash
GET /api/maps/:map_identifier/systems
```
- **Description:** Retrieves all systems and their connections for the specified map.
- **Authentication:** Requires Map API Token.
- **Parameters:**
- `map_identifier` (required) — the map's slug or UUID.
#### Example Request
```bash
curl -H "Authorization: Bearer <YOUR_TOKEN>" \
"https://wanderer.example.com/api/maps/your-map-slug/systems"
```
#### Example Response
```json
{
"data": {
"systems": [
{
"id": "<SYSTEM_UUID>",
"solar_system_id": 30000142,
"solar_system_name": "Jita",
"position_x": 100.5,
"position_y": 200.3,
"status": "clear",
"visible": true,
"description": "Trade hub",
"tag": "TRADE",
"locked": false,
"labels": ["market", "highsec"],
"map_id": "<MAP_UUID>"
}
],
"connections": [
{
"id": "<CONNECTION_UUID>",
"solar_system_source": 30000142,
"solar_system_target": 30000144,
"type": 0,
"mass_status": 0,
"time_status": 0,
"ship_size_type": 1,
"wormhole_type": "K162",
"count_of_passage": 0,
"locked": false,
"custom_info": "Fresh hole"
}
]
}
}
```
### 2. Show Single System
```bash
GET /api/maps/:map_identifier/systems/:id
```
- **Description:** Retrieves details for a specific system.
- **Authentication:** Requires Map API Token.
- **Parameters:**
- `map_identifier` (required) — the map's slug or UUID.
- `id` (required) — the system's solar_system_id.
#### Example Request
```bash
curl -H "Authorization: Bearer <YOUR_TOKEN>" \
"https://wanderer.example.com/api/maps/your-map-slug/systems/30000142"
```
#### Example Response
```json
{
"data": {
"id": "<SYSTEM_UUID>",
"solar_system_id": 30000142,
"solar_system_name": "Jita",
"position_x": 100.5,
"position_y": 200.3,
"status": "clear",
"visible": true,
"description": "Trade hub",
"tag": "TRADE",
"locked": false,
"labels": ["market", "highsec"],
"map_id": "<MAP_UUID>"
}
}
```
### 3. Create/Update System
```bash
POST /api/maps/:map_identifier/systems
PUT /api/maps/:map_identifier/systems/:id
```
- **Description:** Creates a new system or updates an existing one.
- **Authentication:** Requires Map API Token.
- **Parameters:**
- `map_identifier` (required) — the map's slug or UUID.
- `id` (required for PUT) — the system's solar_system_id.
#### Example Create Request
```bash
curl -X POST \
-H "Authorization: Bearer <YOUR_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"solar_system_id": 30000142,
"solar_system_name": "Jita",
"position_x": 100.5,
"position_y": 200.3,
"status": "clear",
"visible": true,
"description": "Trade hub",
"tag": "TRADE",
"locked": false,
"labels": ["market", "highsec"]
}' \
"https://wanderer.example.com/api/maps/your-map-slug/systems"
```
#### Example Update Request
```bash
curl -X PUT \
-H "Authorization: Bearer <YOUR_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"status": "hostile",
"description": "Hostiles reported",
"tag": "DANGER"
}' \
"https://wanderer.example.com/api/maps/your-map-slug/systems/30000142"
```
### 4. Delete System
```bash
DELETE /api/maps/:map_identifier/systems/:id
```
- **Description:** Deletes a specific system and its associated connections.
- **Authentication:** Requires Map API Token.
- **Parameters:**
- `map_identifier` (required) — the map's slug or UUID.
- `id` (required) — the system's solar_system_id.
#### Example Request
```bash
curl -X DELETE \
-H "Authorization: Bearer <YOUR_TOKEN>" \
"https://wanderer.example.com/api/maps/your-map-slug/systems/30000142"
```
### 5. Batch Delete Systems
```bash
DELETE /api/maps/:map_identifier/systems
```
- **Description:** Deletes multiple systems and their connections in a single operation.
- **Authentication:** Requires Map API Token.
- **Parameters:**
- `map_identifier` (required) — the map's slug or UUID.
#### Example Request
```bash
curl -X DELETE \
-H "Authorization: Bearer <YOUR_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"system_ids": [30000142, 30000144, 30000145]
}' \
"https://wanderer.example.com/api/maps/your-map-slug/systems"
```
---
## Connections Endpoints
### 1. List Connections
```bash
GET /api/maps/:map_identifier/connections
```
- **Description:** Retrieves all connections for the specified map.
- **Authentication:** Requires Map API Token.
- **Parameters:**
- `map_identifier` (required) — the map's slug or UUID.
#### Example Request
```bash
curl -H "Authorization: Bearer <YOUR_TOKEN>" \
"https://wanderer.example.com/api/maps/your-map-slug/connections"
```
#### Example Response
```json
{
"data": [
{
"id": "<CONNECTION_UUID>",
"solar_system_source": 30000142,
"solar_system_target": 30000144,
"type": 0,
"mass_status": 0,
"time_status": 0,
"ship_size_type": 1,
"wormhole_type": "K162",
"count_of_passage": 0,
"locked": false,
}
]
}
```
### 2. Create Connection
```bash
POST /api/maps/:map_identifier/connections
```
- **Description:** Creates a new connection between two systems.
- **Authentication:** Requires Map API Token.
- **Parameters:**
- `map_identifier` (required) — the map's slug or UUID.
#### Example Request
```bash
curl -X POST \
-H "Authorization: Bearer <YOUR_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"solar_system_source": 30000142,
"solar_system_target": 30000144,
"type": 0,
"mass_status": 0,
"time_status": 0,
"ship_size_type": 1,
"locked": false,
}' \
"https://wanderer.example.com/api/maps/your-map-slug/connections"
```
### 3. Update Connection
```bash
PATCH /api/maps/:map_identifier/connections
```
- **Description:** Updates an existing connection's properties.
- **Authentication:** Requires Map API Token.
- **Parameters:**
- `map_identifier` (required) — the map's slug or UUID.
- Query parameters:
- `solar_system_source` (required) — source system ID
- `solar_system_target` (required) — target system ID
#### Example Request
```bash
curl -X PATCH \
-H "Authorization: Bearer <YOUR_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"mass_status": 1,
"time_status": 1,
}' \
"https://wanderer.example.com/api/maps/your-map-slug/connections?solar_system_source=30000142&solar_system_target=30000144"
```
### 4. Delete Connection
```bash
DELETE /api/maps/:map_identifier/connections
```
- **Description:** Deletes a connection between two systems.
- **Authentication:** Requires Map API Token.
- **Parameters:**
- `map_identifier` (required) — the map's slug or UUID.
- Query parameters:
- `solar_system_source` (required) — source system ID
- `solar_system_target` (required) — target system ID
#### Example Request
```bash
curl -X DELETE \
-H "Authorization: Bearer <YOUR_TOKEN>" \
"https://wanderer.example.com/api/maps/your-map-slug/connections?solar_system_source=30000142&solar_system_target=30000144"
```
---
## Batch Operations
### 1. Batch Upsert Systems and Connections
```bash
POST /api/maps/:map_identifier/systems
```
- **Description:** Creates or updates multiple systems and connections in a single operation.
- **Authentication:** Requires Map API Token.
- **Parameters:**
- `map_identifier` (required) — the map's slug or UUID.
#### Example Request
```bash
curl -X POST \
-H "Authorization: Bearer <YOUR_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"systems": [
{
"solar_system_id": 30000142,
"solar_system_name": "Jita",
"position_x": 100.5,
"position_y": 200.3,
"status": "clear"
},
{
"solar_system_id": 30000144,
"solar_system_name": "Perimeter",
"position_x": 150.5,
"position_y": 250.3,
"status": "clear"
}
],
"connections": [
{
"solar_system_source": 30000142,
"solar_system_target": 30000144,
"type": 0,
"mass_status": 0,
"ship_size_type": 1
}
]
}' \
"https://wanderer.example.com/api/maps/your-map-slug/systems"
```
#### Example Response
```json
{
"data": {
"systems": {
"created": 2,
"updated": 0
},
"connections": {
"created": 1,
"updated": 0,
"deleted": 0
}
}
}
```
The response includes counts for:
- Systems created and updated
- Connections created, updated, and deleted (if any)
Note: The `deleted` count in connections will be 0 for batch operations as deletion is handled through separate endpoints.
---
## Practical Examples
### Backup and Restore Map State
We provide a utility script that demonstrates how to use these endpoints to backup and restore your map state:
```bash
#!/bin/bash
# backup_restore_test.sh
# 1. Backup current state
curl -H "Authorization: Bearer <YOUR_TOKEN>" \
"https://wanderer.example.com/api/maps/your-map-slug/systems" \
> map_backup.json
# 2. Delete everything (after confirmation)
read -p "Delete all systems? (y/N) " confirm
if [[ "$confirm" =~ ^[Yy]$ ]]; then
# Get system IDs
systems=$(cat map_backup.json | jq -r '.data.systems[].solar_system_id')
# Create deletion payload
payload=$(jq -n --argjson ids "$(echo "$systems" | jq -R . | jq -s .)" \
'{system_ids: $ids}')
# Delete all systems
curl -X DELETE \
-H "Authorization: Bearer <YOUR_TOKEN>" \
-H "Content-Type: application/json" \
-d "$payload" \
"https://wanderer.example.com/api/maps/your-map-slug/systems"
fi
# 3. Restore from backup (after confirmation)
read -p "Restore from backup? (y/N) " confirm
if [[ "$confirm" =~ ^[Yy]$ ]]; then
# Extract systems and connections
backup_data=$(cat map_backup.json)
systems=$(echo "$backup_data" | jq '.data.systems')
connections=$(echo "$backup_data" | jq '.data.connections')
# Create restore payload
payload="{\"systems\": $systems, \"connections\": $connections}"
# Restore everything
curl -X POST \
-H "Authorization: Bearer <YOUR_TOKEN>" \
-H "Content-Type: application/json" \
-d "$payload" \
"https://wanderer.example.com/api/maps/your-map-slug/systems"
fi
```
This script demonstrates a practical application of the batch operations endpoints for backing up and restoring map data.
---
## Conclusion
These endpoints provide powerful tools for managing your map's systems and connections programmatically. Key features include:
1. Individual system and connection management
2. Efficient batch operations
3. Flexible update options
4. Robust error handling
5. Consistent response formats
For the most up-to-date and interactive documentation, remember to check the Swagger UI at `/swaggerui`.
If you have questions about these endpoints or need assistance, please reach out to the Wanderer Team.
----
Fly safe,
**The Wanderer Team**
----

View File

@@ -0,0 +1,163 @@
#!/bin/bash
# test/manual/api/backup_restore_test.sh
# ─── Backup and Restore Test for Map Systems and Connections ────────────────────────
#
# Usage:
# ./backup_restore_test.sh # Run with default settings
# ./backup_restore_test.sh -v # Run in verbose mode
# ./backup_restore_test.sh -h # Show help
#
source "$(dirname "$0")/utils.sh"
# Set to "true" to see detailed output, "false" for minimal output
VERBOSE=${VERBOSE:-false}
# Parse command line options
while getopts "vh" opt; do
case $opt in
v)
VERBOSE=true
;;
h)
echo "Usage: $0 [-v] [-h]"
echo " -v Verbose mode (show detailed output)"
echo " -h Show this help message"
exit 0
;;
\?)
echo "Invalid option: -$OPTARG" >&2
echo "Use -h for help"
exit 1
;;
esac
done
shift $((OPTIND-1))
# File to store backup data
BACKUP_FILE="/tmp/wanderer_map_backup.json"
# ─── UTILITY FUNCTIONS ─────────────────────────────────────────────────────
# Function to backup current map state
backup_map_state() {
echo "==== Backing Up Map State ===="
echo "Fetching current map state..."
local raw=$(make_request GET "$API_BASE_URL/api/maps/$MAP_SLUG/systems")
local status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
local response=$(parse_response "$raw")
echo "$response" > "$BACKUP_FILE"
local system_count=$(echo "$response" | jq '.data.systems | length')
local conn_count=$(echo "$response" | jq '.data.connections | length')
echo "✅ Backed up $system_count systems and $conn_count connections to $BACKUP_FILE"
[[ "$VERBOSE" == "true" ]] && echo "Backup data:" && cat "$BACKUP_FILE" | jq '.'
return 0
else
echo "❌ Failed to backup map state. Status: $status"
return 1
fi
}
# Function to delete all systems (which will cascade to connections)
delete_all() {
echo "==== Deleting All Systems ===="
# Get current systems
local raw=$(make_request GET "$API_BASE_URL/api/maps/$MAP_SLUG/systems")
local status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
local response=$(parse_response "$raw")
local system_ids=$(echo "$response" | jq -r '.data.systems[].solar_system_id')
if [ -z "$system_ids" ]; then
echo "No systems to delete."
return 0
fi
# Convert system IDs to JSON array and create payload
local system_ids_json=$(echo "$system_ids" | jq -R . | jq -s .)
local payload=$(jq -n --argjson system_ids "$system_ids_json" '{system_ids: $system_ids}')
# Send batch delete request
local raw=$(make_request DELETE "$API_BASE_URL/api/maps/$MAP_SLUG/systems" "$payload")
local status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
echo "✅ Successfully deleted all systems and their connections"
return 0
else
echo "❌ Failed to delete systems. Status: $status"
[[ "$VERBOSE" == "true" ]] && echo "Response: $(parse_response "$raw")"
return 1
fi
else
echo "❌ Failed to fetch systems for deletion. Status: $status"
return 1
fi
}
# Function to restore map state from backup
restore_map_state() {
echo "==== Restoring Map State ===="
if [ ! -f "$BACKUP_FILE" ]; then
echo "❌ No backup file found at $BACKUP_FILE"
return 1
fi
local backup_data=$(cat "$BACKUP_FILE")
local systems=$(echo "$backup_data" | jq '.data.systems')
local connections=$(echo "$backup_data" | jq '.data.connections')
# Create payload for batch upsert
local payload="{\"systems\": $systems, \"connections\": $connections}"
# Send batch upsert request
local raw=$(make_request POST "$API_BASE_URL/api/maps/$MAP_SLUG/systems" "$payload")
local status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
local response=$(parse_response "$raw")
local systems_created=$(echo "$response" | jq '.data.systems.created')
local systems_updated=$(echo "$response" | jq '.data.systems.updated')
local conns_created=$(echo "$response" | jq '.data.connections.created')
local conns_updated=$(echo "$response" | jq '.data.connections.updated')
echo "✅ Restore successful:"
echo " Systems: $systems_created created, $systems_updated updated"
echo " Connections: $conns_created created, $conns_updated updated"
return 0
else
echo "❌ Failed to restore map state. Status: $status"
[[ "$VERBOSE" == "true" ]] && echo "Response: $(parse_response "$raw")"
return 1
fi
}
# ─── MAIN EXECUTION FLOW ─────────────────────────────────────────────────
echo "Starting backup/restore test sequence..."
# Step 1: Backup current state
backup_map_state || { echo "Backup failed, aborting."; exit 1; }
echo -e "\nBackup complete. Press Enter to proceed with deletion..."
read -r
# Step 2: Delete everything
delete_all || { echo "Deletion failed, aborting."; exit 1; }
echo -e "\nDeletion complete. Press Enter to proceed with restore..."
read -r
# Step 3: Restore from backup
restore_map_state || { echo "Restore failed."; exit 1; }
echo -e "\nTest sequence completed."
exit 0

18
test/manual/api/run_tests.sh Executable file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# test/manual/api/run_tests.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Main entry point for all manual API tests
# Usage: ./run_tests.sh [all|create|update|delete|-v]
if [ $# -eq 0 ]; then
echo "Running all improved API tests..."
"$SCRIPT_DIR/improved_api_tests.sh"
exit $?
fi
# Pass through any arguments to improved_api_tests.sh
"$SCRIPT_DIR/improved_api_tests.sh" "$@"
exit $?

View File

@@ -0,0 +1,531 @@
#!/usr/bin/env bash
# ─── Legacy Map endpoint tests ───────────────────────────────────────────────────────
source "$(dirname "${BASH_SOURCE[0]}")/utils.sh"
# Track created IDs for cleanup - use space-delimited strings to match utils.sh
CREATED_SYSTEM_IDS=""
CREATED_CONNECTION_IDS=""
# Optional environment variables to control verbosity:
# VERBOSE_LOGGING=1 - Show full API responses
QUIET_MODE=1 # Show minimal output (just test names and results)
# DUMP RESPONSE - Call this to see the complete raw API response
dump_complete_response() {
local url="$1"
# Only show full response dumps if VERBOSE_LOGGING is set
if [ "${VERBOSE_LOGGING:-0}" -eq 1 ]; then
echo ""
echo "🔍 DUMPING COMPLETE RESPONSE FOR: $url"
echo "────────────────────────────────────────────────────────────────────────────────"
curl -s -H "Authorization: Bearer $API_TOKEN" "$url"
echo ""
echo "────────────────────────────────────────────────────────────────────────────────"
echo ""
else
# In non-verbose mode, just do the curl but don't show output
curl -s -H "Authorization: Bearer $API_TOKEN" "$url" > /dev/null
fi
}
# Initial test to show raw API response structure for system endpoint
test_dump_system_response() {
# If verbose logging is not enabled, skip this test
if [ "${VERBOSE_LOGGING:-0}" -ne 1 ]; then
#echo "Skipping raw response dump (enable with VERBOSE_LOGGING=1)"
return 0
fi
local id="30000142" # Jita
echo "Getting complete raw API response for system ID $id..."
dump_complete_response "$API_BASE_URL/api/maps/$MAP_SLUG/systems/$id"
return 0
}
# Helper function to add element to space-delimited string list
add_to_list() {
local list="$1"
local item="$2"
if [ -z "$list" ]; then
echo "$item"
else
echo "$list $item"
fi
}
# Helper function to count items in a space-delimited list
count_items() {
local list="$1"
if [ -z "$list" ]; then
echo "0"
else
echo "$list" | wc -w
fi
}
# Parse JSON response with error handling
parse_response() {
local raw="$1"
# Skip HTTP headers and get the JSON body
local json_body=$(echo "$raw" | sed '1,/^\s*$/d')
# If JSON is valid, return it. Otherwise, return empty object
if echo "$json_body" | jq . >/dev/null 2>&1; then
echo "$json_body"
else
echo "{}"
fi
}
# Function to get and display detailed system information including visibility
fetch_system_details() {
local system_id=$1
local verbose=${2:-0} # Default to non-verbose mode
# Skip detailed output in quiet mode
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "Fetching system details for ID $system_id..."
fi
# Get the complete raw response
local raw
raw=$(curl -s -H "Authorization: Bearer $API_TOKEN" "$API_BASE_URL/api/maps/$MAP_SLUG/systems/$system_id")
# Only show raw response in verbose mode
if [ "$verbose" -eq 1 ] && [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "Raw response from curl:"
echo "$raw" | jq '.' 2>/dev/null || echo "$raw"
fi
# Extract key information
local name=""
local visible=""
# First attempt to extract from data wrapper
if echo "$raw" | jq -e '.data' >/dev/null 2>&1; then
name=$(echo "$raw" | jq -r '.data.name // .data.solar_system_name // ""')
visible=$(echo "$raw" | jq -r '.data.visible // ""')
else
# Use grep as a last resort
if echo "$raw" | grep -q '"visible":true'; then
visible="true"
elif echo "$raw" | grep -q '"visible":false'; then
visible="false"
fi
if echo "$raw" | grep -q '"name":"[^"]*"'; then
name=$(echo "$raw" | grep -o '"name":"[^"]*"' | head -1 | cut -d':' -f2 | tr -d '"')
fi
fi
# Show results only if not in quiet mode
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "SYSTEM NAME: $name"
echo "VISIBILITY: $visible"
fi
# Return success if we found both name and visibility
if [ ! -z "$name" ] && [ ! -z "$visible" ]; then
return 0
else
return 1
fi
}
test_direct_api_access() {
local raw status
raw=$(make_request GET "$API_BASE_URL/api/map/systems?slug=$MAP_SLUG")
status=$(parse_status "$raw")
[[ "$status" =~ ^2[0-9]{2}$ ]]
}
test_missing_params() {
local raw status
raw=$(make_request GET "$API_BASE_URL/api/map/systems")
status=$(parse_status "$raw")
[[ "$status" =~ ^4[0-9]{2}$ ]]
}
test_invalid_auth() {
local old="$API_TOKEN" raw status
API_TOKEN="invalid-token"
raw=$(make_request GET "$API_BASE_URL/api/map/systems?slug=$MAP_SLUG")
status=$(parse_status "$raw")
API_TOKEN="$old"
[[ "$status" == "401" || "$status" == "403" ]]
}
test_invalid_slug() {
local raw status
raw=$(make_request GET "$API_BASE_URL/api/map/systems?slug=nonexistent")
status=$(parse_status "$raw")
[[ "$status" =~ ^4[0-9]{2}$ ]]
}
# Create and then show systems for legacy API
test_show_systems() {
# Use two well-known systems (use actual EVE IDs for clarity)
local jita_id=30000142 # Jita
local amarr_id=30002187 # Amarr
local success_count=0
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "Creating and verifying systems: Jita and Amarr"
fi
# Create first system - Jita with coordinates
local payload raw status response
payload=$(jq -n \
--argjson sid "$jita_id" \
--argjson visible true \
'{solar_system_id:$sid,solar_system_name:"Jita",coordinates:{"x":100,"y":200},visible:$visible}')
# Create the system using the RESTful API
raw=$(make_request POST "$API_BASE_URL/api/maps/$MAP_SLUG/systems" "$payload")
status=$(parse_status "$raw")
if [[ "$status" == "201" || "$status" == "200" ]]; then
success_count=$((success_count + 1))
CREATED_SYSTEM_IDS=$(add_to_list "$CREATED_SYSTEM_IDS" "$jita_id")
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "✓ Created Jita system (ID: $jita_id)"
echo "Verifying system $jita_id is visible after creation..."
fi
# Allow a moment for system to be registered
sleep 1
# Verify the system is visible
fetch_system_details "$jita_id"
else
echo "Warning: Couldn't create Jita system, status: $status"
fi
# Create second system - Amarr with coordinates
payload=$(jq -n \
--argjson sid "$amarr_id" \
--argjson visible true \
'{solar_system_id:$sid,solar_system_name:"Amarr",coordinates:{"x":300,"y":400},visible:$visible}')
# Create the system using the RESTful API
raw=$(make_request POST "$API_BASE_URL/api/maps/$MAP_SLUG/systems" "$payload")
status=$(parse_status "$raw")
if [[ "$status" == "201" || "$status" == "200" ]]; then
success_count=$((success_count + 1))
CREATED_SYSTEM_IDS=$(add_to_list "$CREATED_SYSTEM_IDS" "$amarr_id")
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "✓ Created Amarr system (ID: $amarr_id)"
echo "Verifying system $amarr_id is visible after creation..."
fi
# Allow a moment for system to be registered
sleep 1
# Verify the system is visible
fetch_system_details "$amarr_id"
else
echo "Warning: Couldn't create Amarr system, status: $status"
fi
# If we couldn't create any systems, test fails
if [ $success_count -eq 0 ]; then
echo "Couldn't create any test systems for legacy API"
return 1
fi
# Verify systems are in the list API
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "Checking if systems appear in the list API after creation..."
fi
raw=$(make_request GET "$API_BASE_URL/api/maps/$MAP_SLUG/systems")
status=$(parse_status "$raw")
response_body=$(echo "$raw" | sed '1,/^\s*$/d')
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
# Parse the response appropriately depending on structure
local data_array=""
# Check if the response has data array structure
if echo "$response_body" | jq -e '.data' >/dev/null 2>&1; then
data_array=$(echo "$response_body" | jq '.data')
else
data_array="$response_body"
fi
# Check each created system
local all_systems_in_list=true
for sid in $CREATED_SYSTEM_IDS; do
if echo "$data_array" | jq -e ".[] | select(.solar_system_id == $sid)" >/dev/null 2>&1; then
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "✓ System $sid appears in list API after creation"
fi
else
all_systems_in_list=false
echo "⚠ WARNING: System $sid does not appear in list API after creation"
fi
done
else
echo "ERROR: Failed to get systems list: status $status"
fi
# Now test the legacy API endpoint for each created system
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "Verifying systems are accessible via legacy API..."
fi
local legacy_success=true
for sid in $CREATED_SYSTEM_IDS; do
local raw status
raw=$(make_request GET "$API_BASE_URL/api/map/system?id=$sid&slug=$MAP_SLUG")
status=$(parse_status "$raw")
if [[ ! "$status" =~ ^2[0-9]{2}$ ]]; then
echo "Failed to retrieve system $sid via legacy API: status $status"
legacy_success=false
fi
done
if [ "$legacy_success" = "true" ] && [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "✓ All systems accessible via legacy API"
fi
return 0
}
test_verify_connections() {
# Even if we don't have systems, we can still test the legacy connections API endpoint
# by checking that it returns a valid response
local raw status response
# Try to check all connections via legacy API
raw=$(make_request GET "$API_BASE_URL/api/map/connections?slug=$MAP_SLUG")
status=$(parse_status "$raw")
# If the endpoint exists and returns a success status, the test passes
if [[ "$status" =~ ^2[0-9]{2}$ ]]; then
return 0
fi
return 1
}
test_delete_systems() {
# If we don't have system IDs, skip the test
if [ $(count_items "$CREATED_SYSTEM_IDS") -eq 0 ]; then
echo "No systems to delete, skipping"
return 0
fi
local success_count=0
local total_systems=$(count_items "$CREATED_SYSTEM_IDS")
local deleted_ids=""
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "TEST: Delete Systems API"
echo "------------------------"
echo "Testing system deletion for existing systems in map $MAP_SLUG"
echo "Systems to delete: $CREATED_SYSTEM_IDS"
fi
# Try batch delete first
if [ $(count_items "$CREATED_SYSTEM_IDS") -gt 1 ]; then
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "Attempting batch delete of systems: $CREATED_SYSTEM_IDS"
fi
local payload=$(echo "$CREATED_SYSTEM_IDS" | tr ' ' '\n' | jq -R . | jq -s '{system_ids: .}')
local raw status
raw=$(make_request POST "$API_BASE_URL/api/maps/$MAP_SLUG/systems/batch_delete" "$payload")
status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "✓ Batch delete successful"
fi
# Verify systems are gone from the list
sleep 1
local list_response
list_response=$(curl -s -H "Authorization: Bearer $API_TOKEN" "$API_BASE_URL/api/maps/$MAP_SLUG/systems")
# Check if all systems are gone
local all_deleted=1
for system_id in $CREATED_SYSTEM_IDS; do
if echo "$list_response" | jq -e --arg id "$system_id" '.data[] | select(.solar_system_id == ($id|tonumber) and .visible == true)' >/dev/null 2>&1; then
all_deleted=0
else
success_count=$((success_count + 1))
deleted_ids=$(add_to_list "$deleted_ids" "$system_id")
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "✓ System $system_id no longer visible in list API after batch deletion"
fi
fi
done
if [ $all_deleted -eq 1 ]; then
# Update the list of created systems to remove successfully deleted ones
for id in $deleted_ids; do
CREATED_SYSTEM_IDS=$(echo "$CREATED_SYSTEM_IDS" | sed "s/\b$id\b//g" | tr -s ' ' | sed 's/^ //g' | sed 's/ $//g')
done
# If batch delete worked for all systems, we're done
if [ $success_count -eq $total_systems ]; then
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "✅ All systems successfully deleted via batch delete"
fi
return 0
fi
fi
else
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "Batch delete failed with status $status, trying individual deletes"
fi
fi
fi
# If batch delete didn't work, try individual deletes
for system_id in $CREATED_SYSTEM_IDS; do
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "Attempting to delete system with ID: $system_id"
fi
local raw status
# Use the RESTful DELETE endpoint
raw=$(make_request DELETE "$API_BASE_URL/api/maps/$MAP_SLUG/systems/$system_id")
status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "✓ Delete API call successful for system $system_id"
fi
# Allow time for change to propagate
sleep 1
# Get the complete system list after deletion
local list_response
list_response=$(curl -s -H "Authorization: Bearer $API_TOKEN" "$API_BASE_URL/api/maps/$MAP_SLUG/systems")
# Check if the system appears in the list (deleted systems shouldn't appear or should be invisible)
local system_still_visible=0
if echo "$list_response" | jq -e --arg id "$system_id" '.data[] | select(.solar_system_id == ($id|tonumber) and .visible == true)' >/dev/null 2>&1; then
system_still_visible=1
fi
if [ $system_still_visible -eq 0 ]; then
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "✓ System $system_id no longer visible in list API after deletion"
fi
success_count=$((success_count + 1))
deleted_ids=$(add_to_list "$deleted_ids" "$system_id")
fi
else
echo "❌ Failed to delete system $system_id: status $status"
fi
done
# Update the list of created systems to remove successfully deleted ones
for id in $deleted_ids; do
CREATED_SYSTEM_IDS=$(echo "$CREATED_SYSTEM_IDS" | sed "s/\b$id\b//g" | tr -s ' ' | sed 's/^ //g' | sed 's/ $//g')
done
# Report results
if [ $success_count -eq $total_systems ]; then
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "✅ All systems successfully deleted (no longer visible in list API): $success_count / $total_systems"
fi
return 0
else
echo "⚠ Some systems still appear visible in list API after deletion: $success_count / $total_systems deleted"
return 1
fi
}
# Test the system list API endpoint
test_system_list() {
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "Testing system list API endpoint..."
fi
local raw status
raw=$(make_request GET "$API_BASE_URL/api/maps/$MAP_SLUG/systems")
status=$(parse_status "$raw")
if [[ "$status" != "200" ]]; then
echo "ERROR: Failed to get system list: status $status"
return 1
fi
# Test legacy system list endpoint too
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "Testing legacy system list API endpoint..."
fi
raw=$(make_request GET "$API_BASE_URL/api/map/systems?slug=$MAP_SLUG")
status=$(parse_status "$raw")
if [[ "$status" != "200" ]]; then
echo "ERROR: Failed to get legacy system list: status $status"
return 1
fi
# Check that both APIs return the same number of systems
local restful_count=$(echo "$raw" | sed '1,/^\s*$/d' | jq '.data | length // length')
raw=$(make_request GET "$API_BASE_URL/api/map/systems?slug=$MAP_SLUG")
local legacy_count=$(echo "$raw" | sed '1,/^\s*$/d' | jq '.data | length // length')
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "RESTful API returned $restful_count systems, Legacy API returned $legacy_count systems"
fi
if [[ "$restful_count" == "$legacy_count" ]]; then
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo "✓ Both APIs return the same number of systems"
fi
else
echo "WARNING: APIs return different numbers of systems"
fi
return 0
}
# ─── Execute Tests ────────────────────────────────────────────────────────────
# Function to run a test and report success/failure
run_test() {
local name="$1"
local func="$2"
# Only print test name if not in quiet mode
if [ "${QUIET_MODE:-0}" -ne 1 ]; then
echo -n "Testing: $name... "
fi
# Run the test function
if $func; then
echo "$name"
return 0
else
echo "$name"
return 1
fi
}
run_test "Dump Raw API Response" test_dump_system_response
run_test "Direct API access" test_direct_api_access
run_test "Missing params (4xx)" test_missing_params
run_test "Invalid auth (401/403)" test_invalid_auth
run_test "Invalid slug on GET" test_invalid_slug
run_test "Show systems" test_show_systems
run_test "System list" test_system_list
run_test "Verify connections" test_verify_connections
run_test "Delete systems" test_delete_systems

View File

@@ -0,0 +1,676 @@
#!/bin/bash
# test/manual/api/improved_api_tests.sh
# ─── Improved API Tests for Map System and Connection APIs ────────────────────────
#
# Usage:
# ./improved_api_tests.sh # Run all tests with menu selection
# ./improved_api_tests.sh create # Run only creation tests
# ./improved_api_tests.sh update # Run only update tests
# ./improved_api_tests.sh delete # Run only deletion tests
# ./improved_api_tests.sh -v # Run in verbose mode
#
source "$(dirname "$0")/utils.sh"
# Set to "true" to see detailed output, "false" for minimal output
VERBOSE=${VERBOSE:-false}
# Parse command line options
while getopts "vh" opt; do
case $opt in
v)
VERBOSE=true
;;
h)
echo "Usage: $0 [-v] [-h] [all|create|update|delete]"
echo " -v Verbose mode (show detailed test output)"
echo " -h Show this help message"
echo " all Run all tests (default with menu)"
echo " create Run only creation tests"
echo " update Run only update tests"
echo " delete Run only deletion tests"
exit 0
;;
\?)
echo "Invalid option: -$OPTARG" >&2
echo "Use -h for help"
exit 1
;;
esac
done
shift $((OPTIND-1))
COMMAND=${1:-"all"}
# File to store system and connection IDs for persistence between command runs
SYSTEMS_FILE="/tmp/wanderer_test_systems.txt"
CONNECTIONS_FILE="/tmp/wanderer_test_connections.txt"
# Track created IDs for cleanup
CREATED_SYSTEM_IDS=""
CREATED_CONNECTION_IDS=""
# Array of valid EVE system IDs and names (first 5 for individual creation)
declare -a EVE_SYSTEMS=(
"30005304:Alentene"
"30003380:Alf"
"30003811:Algasienan"
"30004972:Algogille"
"30002698:Aliette"
)
# Next 5 for batch upsert
declare -a BATCH_EVE_SYSTEMS=(
"30002754:Alikara"
"30002712:Alillere"
"30003521:Alkabsi"
"30000034:Alkez"
"30004995:Allamotte"
)
# ─── UTILITY FUNCTIONS ─────────────────────────────────────────────────────
# Function to save created system IDs to file
save_systems() {
echo "$CREATED_SYSTEM_IDS" > "$SYSTEMS_FILE"
[[ "$VERBOSE" == "true" ]] && echo "Saved $(wc -w < "$SYSTEMS_FILE") systems to $SYSTEMS_FILE"
}
# Function to load system IDs from file
load_systems() {
if [ -f "$SYSTEMS_FILE" ]; then
CREATED_SYSTEM_IDS=$(cat "$SYSTEMS_FILE")
[[ "$VERBOSE" == "true" ]] && echo "Loaded $(wc -w < "$SYSTEMS_FILE") systems from $SYSTEMS_FILE"
else
echo "No systems file found at $SYSTEMS_FILE. Run creation tests first."
CREATED_SYSTEM_IDS=""
fi
}
# Function to save created connection IDs to file
save_connections() {
echo "$CREATED_CONNECTION_IDS" > "$CONNECTIONS_FILE"
[[ "$VERBOSE" == "true" ]] && echo "Saved $(wc -w < "$CONNECTIONS_FILE") connections to $CONNECTIONS_FILE"
}
# Function to load connection IDs from file
load_connections() {
if [ -f "$CONNECTIONS_FILE" ]; then
CREATED_CONNECTION_IDS=$(cat "$CONNECTIONS_FILE")
[[ "$VERBOSE" == "true" ]] && echo "Loaded $(wc -w < "$CONNECTIONS_FILE") connections from $CONNECTIONS_FILE"
else
echo "No connections file found at $CONNECTIONS_FILE. Run creation tests first."
CREATED_CONNECTION_IDS=""
fi
}
# Function to add item to space-delimited list
add_to_list() {
local list="$1"
local item="$2"
if [ -z "$list" ]; then
echo "$item"
else
echo "$list $item"
fi
}
# ─── TEST FUNCTIONS ─────────────────────────────────────────────────────
# FUNCTION: Create systems
create_systems() {
echo "==== Creating Systems ===="
local system_count=0
local center_x=500
local center_y=500
local radius=250
# Only clear the systems file if we're starting fresh
> "$SYSTEMS_FILE"
CREATED_SYSTEM_IDS=""
# Build all system payloads as a JSON array
local systems_payload="["
local num_systems=${#EVE_SYSTEMS[@]}
for i in $(seq 0 $((num_systems-1))); do
IFS=':' read -r system_id system_name <<< "${EVE_SYSTEMS[$i]}"
local angle=$(echo "scale=6; $i * 6.28318 / $num_systems" | bc -l)
local x=$(echo "scale=2; $center_x + $radius * c($angle)" | bc -l)
local y=$(echo "scale=2; $center_y + $radius * s($angle)" | bc -l)
local system_json=$(jq -n \
--argjson sid "$system_id" \
--arg name "$system_name" \
--argjson x "$x" \
--argjson y "$y" \
'{
solar_system_id: $sid,
solar_system_name: $name,
position_x: $x,
position_y: $y,
status: "clear",
visible: true,
description: "Test system",
tag: "TEST",
locked: false
}')
systems_payload+="$system_json"
if [ $i -lt $((num_systems-1)) ]; then
systems_payload+=","
fi
done
systems_payload+="]"
# Wrap in the 'systems' key
local payload="{\"systems\": $systems_payload}"
# Send the batch create request
local raw=$(make_request POST "$API_BASE_URL/api/maps/$MAP_SLUG/systems" "$payload")
local status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
echo "✅ Created all systems in batch"
# Track the system IDs for later cleanup
for i in $(seq 0 $((num_systems-1))); do
IFS=':' read -r system_id _ <<< "${EVE_SYSTEMS[$i]}"
CREATED_SYSTEM_IDS=$(add_to_list "$CREATED_SYSTEM_IDS" "$system_id")
system_count=$((system_count+1))
done
else
echo "❌ Failed to create systems in batch. Status: $status"
[[ "$VERBOSE" == "true" ]] && echo "Response: $(parse_response "$raw")"
fi
echo "Total systems created: $system_count/$num_systems"
save_systems
# Validate actual state after creation
echo "Validating systems after dedicated creation:"
list_systems_and_connections
}
# FUNCTION: Create connections
create_connections() {
echo "==== Creating Connections ===="
load_systems
if [ -z "$CREATED_SYSTEM_IDS" ]; then
echo "No systems available. Run system creation first."
return 1
fi
> "$CONNECTIONS_FILE"
CREATED_CONNECTION_IDS=""
local connection_count=0
local total_connections=0
local system_array=($CREATED_SYSTEM_IDS)
echo "Testing dedicated connection endpoints..."
# Create connections one by one using the dedicated endpoint
for i in $(seq 0 $((${#system_array[@]}-1))); do
local source=${system_array[$i]}
local target=${system_array[$(( (i+1) % ${#system_array[@]} ))]}
total_connections=$((total_connections+1))
# Create single connection payload
local payload=$(jq -n \
--argjson source "$source" \
--argjson target "$target" \
'{
solar_system_source: $source,
solar_system_target: $target,
type: 0,
mass_status: 0,
time_status: 0,
ship_size_type: 1,
wormhole_type: "K162",
count_of_passage: 0,
locked: false,
custom_info: "Test connection"
}')
# Send create request to dedicated endpoint
local raw=$(make_request POST "$API_BASE_URL/api/maps/$MAP_SLUG/connections" "$payload")
local status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
echo "✅ Created connection from $source to $target"
local response=$(parse_response "$raw")
# Store source and target for later use
CREATED_CONNECTION_IDS=$(add_to_list "$CREATED_CONNECTION_IDS" "${source}:${target}")
connection_count=$((connection_count+1))
else
echo "❌ Failed to create connection from $source to $target. Status: $status"
[[ "$VERBOSE" == "true" ]] && echo "Response: $(parse_response "$raw")"
fi
done
echo "Total connections created via dedicated endpoint: $connection_count/$total_connections"
save_connections
# Validate actual state after dedicated connection creation
echo "Validating connections after dedicated creation:"
list_systems_and_connections
echo -e "\nTesting batch upsert functionality..."
# Build batch upsert payload using BATCH_EVE_SYSTEMS
local batch_systems_json="["
local batch_connections_json="["
local num_batch_systems=${#BATCH_EVE_SYSTEMS[@]}
for i in $(seq 0 $((num_batch_systems-1))); do
IFS=':' read -r system_id system_name <<< "${BATCH_EVE_SYSTEMS[$i]}"
local angle=$(echo "scale=6; $i * 6.28318 / $num_batch_systems" | bc -l)
local x=$(echo "scale=2; 500 + 250 * c($angle)" | bc -l)
local y=$(echo "scale=2; 500 + 250 * s($angle)" | bc -l)
local system_json=$(jq -n \
--argjson sid "$system_id" \
--arg name "$system_name" \
--argjson x "$x" \
--argjson y "$y" \
'{
solar_system_id: $sid,
solar_system_name: $name,
position_x: $x,
position_y: $y,
status: "clear",
visible: true,
description: "Test system (batch)",
tag: "BATCH",
locked: false
}')
batch_systems_json+="$system_json"
if [ $i -lt $((num_batch_systems-1)) ]; then
batch_systems_json+=","
fi
# Build connections in a ring
local source=$system_id
local next_index=$(( (i+1) % num_batch_systems ))
IFS=':' read -r target_id _ <<< "${BATCH_EVE_SYSTEMS[$next_index]}"
batch_connections_json+="{\"solar_system_source\":$source,\"solar_system_target\":$target_id,\"mass_status\":0,\"ship_size_type\":1,\"type\":0}"
if [ $i -lt $((num_batch_systems-1)) ]; then
batch_connections_json+=","
fi
done
batch_systems_json+="]"
batch_connections_json+="]"
echo "[SCRIPT] Batch upsert systems: $batch_systems_json"
echo "[SCRIPT] Batch upsert connections: $batch_connections_json"
# Check for API_TOKEN
if [ -z "$API_TOKEN" ]; then
echo "❌ API_TOKEN is not set. Please export API_TOKEN before running the script."
return 1
fi
# Send batch upsert request
local response=$(curl -s -X POST "$API_BASE_URL/api/maps/$MAP_SLUG/systems" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $API_TOKEN" \
-d "{\"systems\":$batch_systems_json,\"connections\":$batch_connections_json}")
echo "[SCRIPT] Batch upsert response: $response"
# Add batch system IDs to CREATED_SYSTEM_IDS
for i in $(seq 0 $((num_batch_systems-1))); do
IFS=':' read -r system_id _ <<< "${BATCH_EVE_SYSTEMS[$i]}"
CREATED_SYSTEM_IDS=$(add_to_list "$CREATED_SYSTEM_IDS" "$system_id")
done
# Add batch connection pairs to CREATED_CONNECTION_IDS
for i in $(seq 0 $((num_batch_systems-1))); do
IFS=':' read -r source _ <<< "${BATCH_EVE_SYSTEMS[$i]}"
next_index=$(( (i+1) % num_batch_systems ))
IFS=':' read -r target _ <<< "${BATCH_EVE_SYSTEMS[$next_index]}"
CREATED_CONNECTION_IDS=$(add_to_list "$CREATED_CONNECTION_IDS" "${source}:${target}")
done
save_systems
save_connections
list_systems_and_connections
echo "Total connections updated: $connection_count/${#system_array[@]}"
}
# FUNCTION: Update systems
update_systems() {
echo "==== Updating Systems ===="
load_systems
if [ -z "$CREATED_SYSTEM_IDS" ]; then
echo "No systems available. Run system creation first."
return 1
fi
local update_count=0
local system_array=($CREATED_SYSTEM_IDS)
local num_systems=${#system_array[@]}
for i in $(seq 0 $((num_systems-1))); do
local system_id=${system_array[$i]}
# Get system name from EVE_SYSTEMS array if available
local system_name="System $system_id"
for j in $(seq 0 $((${#EVE_SYSTEMS[@]}-1))); do
IFS=':' read -r curr_id curr_name <<< "${EVE_SYSTEMS[$j]}"
if [ "$curr_id" = "$system_id" ]; then
system_name=$curr_name
break
fi
done
echo "Updating system $((i+1))/$num_systems: $system_name (ID: $system_id)"
# Create update payload with new values
local status_values=("clear" "friendly" "hostile" "occupied")
local status=${status_values[$((RANDOM % 4))]}
local desc="Updated description for $system_name"
local tag="UPDATED"
local payload=$(jq -n \
--arg status "$status" \
--arg desc "$desc" \
--arg tag "$tag" \
'{
status: $status,
description: $desc,
tag: $tag,
locked: false
}')
# Send the update request
local raw=$(make_request PUT "$API_BASE_URL/api/maps/$MAP_SLUG/systems/$system_id" "$payload")
local status_code=$(parse_status "$raw")
if [[ "$status_code" =~ ^2[0-9][0-9]$ ]]; then
echo "✅ Updated system $system_name with status: $status"
update_count=$((update_count+1))
else
echo "❌ Failed to update system $system_name. Status: $status_code"
[[ "$VERBOSE" == "true" ]] && echo "Response: $(parse_response "$raw")"
fi
done
echo "Total systems updated: $update_count/$num_systems"
}
# FUNCTION: Update connections
update_connections() {
echo "==== Updating Connections ===="
load_systems
load_connections
if [ -z "$CREATED_SYSTEM_IDS" ] || [ -z "$CREATED_CONNECTION_IDS" ]; then
echo "No systems or connections available. Run creation tests first."
return 1
fi
echo "Testing connection updates..."
local update_count=0
local conn_array=($CREATED_CONNECTION_IDS)
for triple in "${conn_array[@]}"; do
local source=$(echo $triple | cut -d: -f1)
local target=$(echo $triple | cut -d: -f2)
# Create update payload
local mass_values=(0 1 2)
local ship_values=(0 1 2 3)
local mass=${mass_values[$((RANDOM % 3))]}
local ship=${ship_values[$((RANDOM % 4))]}
local payload=$(jq -n \
--argjson mass "$mass" \
--argjson ship "$ship" \
'{
mass_status: $mass,
ship_size_type: $ship,
locked: false,
custom_info: "Updated via PATCH"
}')
# Try source/target update
local raw=$(make_request PATCH "$API_BASE_URL/api/maps/$MAP_SLUG/connections?solar_system_source=$source&solar_system_target=$target" "$payload")
local status_code=$(parse_status "$raw")
if [[ "$status_code" =~ ^2[0-9][0-9]$ ]]; then
echo "✅ Updated connection $source->$target"
update_count=$((update_count+1))
else
echo "❌ Failed to update connection $source->$target. Status: $status_code"
[[ "$VERBOSE" == "true" ]] && echo "Response: $(parse_response "$raw")"
fi
done
echo "Total connections updated: $update_count/${#conn_array[@]}"
echo -e "\nTesting batch connection updates..."
# Create batch update payload for all connections
local batch_connections="["
local first=true
for triple in "${conn_array[@]}"; do
local source=$(echo $triple | cut -d: -f1)
local target=$(echo $triple | cut -d: -f2)
local mass=${mass_values[$((RANDOM % 3))]}
local ship=${ship_values[$((RANDOM % 4))]}
if [ "$first" = true ]; then
first=false
else
batch_connections+=","
fi
batch_connections+=$(jq -n \
--argjson source "$source" \
--argjson target "$target" \
--argjson mass "$mass" \
--argjson ship "$ship" \
'{
solar_system_source: $source,
solar_system_target: $target,
mass_status: $mass,
ship_size_type: $ship,
custom_info: "Batch updated"
}')
done
batch_connections+="]"
local batch_payload="{\"connections\": $batch_connections}"
local raw=$(make_request POST "$API_BASE_URL/api/maps/$MAP_SLUG/systems" "$batch_payload")
local status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
local response=$(parse_response "$raw")
local updated_count=$(echo "$response" | jq '.data.connections.updated')
if [ "$updated_count" != "null" ]; then
echo "✅ Batch update successful - Updated connections: $updated_count"
else
echo "❌ Batch update returned null for updated count"
fi
else
echo "❌ Batch update failed. Status: $status"
[[ "$VERBOSE" == "true" ]] && echo "Response: $(parse_response "$raw")"
fi
}
# FUNCTION: List systems and connections
list_systems_and_connections() {
echo "==== Listing Systems and Connections ===="
load_systems
if [ -z "$CREATED_SYSTEM_IDS" ]; then
echo "No systems available. Run system creation first."
return 1
fi
echo "Testing list all systems and connections endpoint"
local raw=$(make_request GET "$API_BASE_URL/api/maps/$MAP_SLUG/systems")
local status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
local response=$(parse_response "$raw")
local system_count=$(echo "$response" | jq '.data.systems | length')
local conn_count=$(echo "$response" | jq '.data.connections | length')
echo "✅ Listed $system_count systems and $conn_count connections"
[[ "$VERBOSE" == "true" ]] && echo "$response" | jq '.'
return 0
else
echo "❌ Failed to list systems and connections. Status: $status"
return 1
fi
}
# FUNCTION: Delete connections and systems
delete_everything() {
echo "==== Deleting Connections and Systems ===="
load_connections
load_systems
echo "Cleaning up connections..."
# Delete connections using source/target pairs
local conn_array=($CREATED_CONNECTION_IDS)
for triple in "${conn_array[@]}"; do
local source=$(echo $triple | cut -d: -f1)
local target=$(echo $triple | cut -d: -f2)
local raw=$(make_request DELETE "$API_BASE_URL/api/maps/$MAP_SLUG/connections?solar_system_source=$source&solar_system_target=$target")
local status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
echo "✅ Deleted connection $source->$target"
else
echo "❌ Failed to delete connection $source->$target. Status: $status"
[[ "$VERBOSE" == "true" ]] && echo "Response: $(parse_response "$raw")"
fi
done
echo "Cleaning up systems..."
# Use batch delete for systems
local system_array=($CREATED_SYSTEM_IDS)
echo "Attempting batch delete of systems..."
echo "System ${system_array[@]}"
local system_ids_json=$(printf '%s\n' "${system_array[@]}" | jq -R . | jq -s .)
local payload=$(jq -n --argjson system_ids "$system_ids_json" '{system_ids: $system_ids}')
local raw=$(make_request DELETE "$API_BASE_URL/api/maps/$MAP_SLUG/systems" "$payload")
local status=$(parse_status "$raw")
if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then
echo "✅ Batch delete successful for all systems"
> "$SYSTEMS_FILE"
> "$CONNECTIONS_FILE"
CREATED_SYSTEM_IDS=""
CREATED_CONNECTION_IDS=""
else
echo "❌ Batch delete failed. Status: $status"
[[ "$VERBOSE" == "true" ]] && echo "Response: $(parse_response "$raw")"
fi
}
# ─── MENU AND INTERACTION LOGIC ─────────────────────────────────────────
show_menu() {
echo "===== Map System and Connection API Tests ====="
echo "1. Run all tests in sequence (with pauses)"
echo "2. Create systems"
echo "3. Create connections"
echo "4. Update systems"
echo "5. Update connections"
echo "6. List systems and connections"
echo "7. Delete everything"
echo "8. Exit"
echo "================================================"
echo "Enter your choice [1-8]: "
}
# ─── MAIN EXECUTION FLOW ─────────────────────────────────────────────────
# Main execution based on command
case "$COMMAND" in
"all")
# If no specific command was provided, show the menu
if [ -t 0 ]; then # Only show menu if running interactively
# Interactive mode with menu
while true; do
show_menu
read -r choice
case $choice in
1)
# Run all tests in sequence with pauses
create_systems || echo "System creation failed/skipped"
echo "Press Enter to continue with connection creation..."
read -r
create_connections || echo "Connection creation failed/skipped"
echo "Press Enter to continue with system updates..."
read -r
update_systems || echo "System update failed/skipped"
echo "Press Enter to continue with connection updates..."
read -r
update_connections || echo "Connection update failed/skipped"
echo "Press Enter to continue with listing tests..."
read -r
list_systems_and_connections || echo "Listing failed/skipped"
echo "Press Enter to continue with deletion..."
read -r
delete_everything || echo "Cleanup failed/skipped"
echo "All tests completed."
;;
2)
create_systems
;;
3)
create_connections
;;
4)
update_systems
;;
5)
update_connections
;;
6)
list_systems_and_connections
;;
7)
delete_everything
;;
8)
# Offer to clean up before exiting
read -p "Clean up any remaining test data before exiting? (y/n): " confirm
if [[ "$confirm" =~ ^[Yy] ]]; then
delete_everything
fi
exit 0
;;
*)
echo "Invalid option. Please try again."
;;
esac
done
else
# Non-interactive mode, run all tests in sequence
create_systems || echo "System creation failed/skipped"
create_connections || echo "Connection creation failed/skipped"
update_systems || echo "System update failed/skipped"
update_connections || echo "Connection update failed/skipped"
list_systems_and_connections || echo "Listing failed/skipped"
delete_everything || echo "Cleanup failed/skipped"
fi
;;
"create")
create_systems
create_connections
;;
"update")
update_systems
update_connections
list_systems_and_connections
;;
"delete")
delete_everything
;;
*)
echo "Invalid command: $COMMAND"
echo "Use -h for help"
exit 1
;;
esac
exit 0

167
test/manual/api/utils.sh Executable file
View File

@@ -0,0 +1,167 @@
#!/bin/bash
set -eu
# ─── Dependencies ─────────────────────────────────────────────────────────────
for cmd in curl jq; do
if ! command -v "$cmd" > /dev/null 2>&1; then
echo "Error: '$cmd' is required" >&2
exit 1
fi
done
# ─── Load .env if present ─────────────────────────────────────────────────────
load_env_file() {
echo "📄 Loading env file: $1"
set -o allexport
source "$1"
set +o allexport
}
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
if [ -f "$SCRIPT_DIR/.env" ]; then
load_env_file "$SCRIPT_DIR/.env"
fi
# Check if API_TOKEN is set
: "${API_TOKEN:?Error: API_TOKEN environment variable not set}"
# ─── HTTP Request Helper ──────────────────────────────────────────────────────
make_request() {
local method=$1 url=$2 data=${3:-}
local curl_cmd=(curl -s -w $'\n%{http_code}' -H "Authorization: Bearer $API_TOKEN")
if [ "$method" != "GET" ]; then
curl_cmd+=(-X "$method" -H "Content-Type: application/json")
fi
if [ -n "$data" ]; then
curl_cmd+=(-d "$data")
fi
"${curl_cmd[@]}" "$url"
}
# ─── Response Parsers ─────────────────────────────────────────────────────────
parse_response() { # strips the final newline+status line
local raw="$1"
echo "${raw%$'\n'*}"
}
parse_status() { # returns only the status code (last line)
local raw="$1"
echo "${raw##*$'\n'}"
}
# ─── Assertion Helper ─────────────────────────────────────────────────────────
verify_http_code() {
local got=$1 want=$2 label=$3
if [ "$got" -eq "$want" ]; then
return 0
else
echo "🚫 $label: expected HTTP $want, got $got" >&2
return 1
fi
}
# ─── Test Runner & Summary ────────────────────────────────────────────────────
# Only initialize counters once to accumulate across multiple suite sources
if [ -z "${TOTAL_TESTS+x}" ]; then
TOTAL_TESTS=0
PASSED_TESTS=0
FAILED_TESTS=0
FAILED_LIST=""
fi
run_test() {
local label=$1 fn=$2
TOTAL_TESTS=$((TOTAL_TESTS+1))
if "$fn"; then
echo "$label"
PASSED_TESTS=$((PASSED_TESTS+1))
else
echo "$label"
FAILED_TESTS=$((FAILED_TESTS+1))
FAILED_LIST="$FAILED_LIST $label"
fi
}
# ─── Cleanup on Exit ──────────────────────────────────────────────────────────
CREATED_SYSTEM_IDS=""
CREATED_CONNECTION_IDS=""
cleanup_map_systems() {
# First delete connections
if [ -n "$CREATED_CONNECTION_IDS" ]; then
echo "Cleaning up connections..."
for conn_id in $CREATED_CONNECTION_IDS; do
# Try with a direct DELETE request to the connection endpoint
make_request DELETE "$API_BASE_URL/api/maps/$MAP_SLUG/connections/$conn_id" > /dev/null 2>&1 || true
done
fi
# Then delete systems
if [ -n "$CREATED_SYSTEM_IDS" ]; then
echo "Cleaning up systems..."
# First try batch delete if we have multiple systems
if [ $(echo "$CREATED_SYSTEM_IDS" | wc -w) -gt 1 ]; then
echo "Attempting batch delete of systems..."
# Use the official batch_delete endpoint
local payload=$(echo "$CREATED_SYSTEM_IDS" | tr ' ' '\n' | jq -R . | jq -s '{system_ids: .}')
local raw
raw=$(make_request POST "$API_BASE_URL/api/maps/$MAP_SLUG/systems/batch_delete" "$payload" 2>/dev/null) || true
# Check if batch delete was successful by looking for systems
sleep 1
local success=1
for sys_id in $CREATED_SYSTEM_IDS; do
# Check if system still exists and is visible
local check=$(curl -s -H "Authorization: Bearer $API_TOKEN" "$API_BASE_URL/api/maps/$MAP_SLUG/systems")
if echo "$check" | grep -q "\"solar_system_id\":$sys_id"; then
if echo "$check" | grep -q "\"solar_system_id\":$sys_id.*\"visible\":true"; then
success=0
else
echo "System $sys_id exists but is not visible (batch delete worked)"
fi
else
echo "System $sys_id no longer found (batch delete worked)"
fi
done
# If batch delete was successful for all systems, we're done
if [ $success -eq 1 ]; then
echo "✅ Batch delete successful for all systems"
return 0
fi
fi
# If batch delete failed or we have only one system, try individual deletes
echo "Performing individual system deletions..."
for sys_id in $CREATED_SYSTEM_IDS; do
echo "Deleting system $sys_id..."
# Try standard DELETE request
make_request DELETE "$API_BASE_URL/api/maps/$MAP_SLUG/systems/$sys_id" > /dev/null 2>&1 || true
# Verify the system was deleted or at least made invisible
sleep 1
local check=$(curl -s -H "Authorization: Bearer $API_TOKEN" "$API_BASE_URL/api/maps/$MAP_SLUG/systems")
if echo "$check" | grep -q "\"solar_system_id\":$sys_id"; then
if echo "$check" | grep -q "\"solar_system_id\":$sys_id.*\"visible\":true"; then
echo "⚠️ System $sys_id is still visible after all deletion attempts"
else
echo "System $sys_id exists but is not visible (deletion worked)"
fi
else
echo "System $sys_id no longer found (deletion worked)"
fi
done
fi
}
trap cleanup_map_systems EXIT