feat (api): add additional structure/signature methods (#365)

This commit is contained in:
guarzo
2025-05-06 12:42:47 -04:00
committed by GitHub
parent 6378754c57
commit ccf9c0db22
31 changed files with 2740 additions and 722 deletions

View File

@@ -8,77 +8,145 @@ defmodule WandererAppWeb.MapConnectionAPIController do
use WandererAppWeb, :controller
use OpenApiSpex.ControllerSpecs
require Logger
alias OpenApiSpex.Schema
alias WandererApp.Map, as: MapData
alias WandererApp.Map.Operations
alias WandererAppWeb.Helpers.APIUtils
alias WandererAppWeb.Schemas.{ApiSchemas, ResponseSchemas}
alias WandererAppWeb.Schemas.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)"}
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 status (0-3)", nullable: true},
ship_size_type: %Schema{type: :integer, description: "Ship size limit (0-3)", nullable: true},
locked: %Schema{type: :boolean, description: "Locked flag", nullable: true},
custom_info: %Schema{type: :string, nullable: true, description: "Optional metadata"},
wormhole_type: %Schema{type: :string, nullable: true, description: "Wormhole code"}
},
required: ~w(solar_system_source solar_system_target)a,
example: %{solar_system_source: 30_000_142, solar_system_target: 30_000_144, type: 0}
example: %{
solar_system_source: 30_000_142,
solar_system_target: 30_000_144,
type: 0,
mass_status: 1,
time_status: 2,
ship_size_type: 1,
locked: false,
custom_info: "Frigate only",
wormhole_type: "C2"
}
}
@batch_delete_schema %Schema{
@list_response_schema %Schema{
type: :object,
properties: %{
connection_ids: %Schema{
data: %Schema{
type: :array,
items: %Schema{type: :string, description: "Connection UUID"},
description: "IDs to delete"
items: %Schema{
type: :object,
properties: %{
id: %Schema{type: :string},
map_id: %Schema{type: :string},
solar_system_source: %Schema{type: :integer},
solar_system_target: %Schema{type: :integer},
type: %Schema{type: :integer},
mass_status: %Schema{type: :integer},
time_status: %Schema{type: :integer},
ship_size_type: %Schema{type: :integer},
locked: %Schema{type: :boolean},
custom_info: %Schema{type: :string, nullable: true},
wormhole_type: %Schema{type: :string, nullable: true}
}
}
}
},
required: ["connection_ids"]
example: %{
data: [
%{
id: "conn-uuid-1",
map_id: "map-uuid-1",
solar_system_source: 30_000_142,
solar_system_target: 30_000_144,
type: 0,
mass_status: 1,
time_status: 2,
ship_size_type: 1,
locked: false,
custom_info: "Frigate only",
wormhole_type: "C2"
}
]
}
}
@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"]
@detail_response_schema %Schema{
type: :object,
properties: %{
data: %Schema{
type: :object,
properties: %{
id: %Schema{type: :string},
map_id: %Schema{type: :string},
solar_system_source: %Schema{type: :integer},
solar_system_target: %Schema{type: :integer},
type: %Schema{type: :integer},
mass_status: %Schema{type: :integer},
time_status: %Schema{type: :integer},
ship_size_type: %Schema{type: :integer},
locked: %Schema{type: :boolean},
custom_info: %Schema{type: :string, nullable: true},
wormhole_type: %Schema{type: :string, nullable: true}
}
}
},
example: %{
data: %{
id: "conn-uuid-1",
map_id: "map-uuid-1",
solar_system_source: 30_000_142,
solar_system_target: 30_000_144,
type: 0,
mass_status: 1,
time_status: 2,
ship_size_type: 1,
locked: false,
custom_info: "Frigate only",
wormhole_type: "C2"
}
}
)
}
# -- Actions --
operation :index,
summary: "List Map Connections",
parameters: [
map_slug: [in: :path, type: :string],
map_id: [in: :path, type: :string],
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug). Provide either a UUID or a slug.",
type: :string,
required: true,
example: "00000000-0000-0000-0000-000000000000 or my-map-slug"
],
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)
responses: [
ok: {
"List Map Connections",
"application/json",
@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
@@ -116,7 +184,18 @@ defmodule WandererAppWeb.MapConnectionAPIController do
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]],
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug). Provide either a UUID or a slug.",
type: :string,
required: true,
example: "00000000-0000-0000-0000-000000000000 or my-map-slug"
],
id: [in: :path, type: :string, 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
@@ -136,12 +215,20 @@ defmodule WandererAppWeb.MapConnectionAPIController do
operation :create,
summary: "Create Connection",
parameters: [map_slug: [in: :path], map_id: [in: :path], system_id: [in: :path]],
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug). Provide either a UUID or a slug.",
type: :string,
required: true,
example: "00000000-0000-0000-0000-000000000000 or my-map-slug"
],
system_id: [in: :path, type: :string, required: false]
],
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
case Operations.create_connection(conn, params) do
{:ok, conn_struct} when is_map(conn_struct) ->
conn
|> APIUtils.respond_data(APIUtils.connection_to_json(conn_struct), :created)
@@ -157,73 +244,115 @@ defmodule WandererAppWeb.MapConnectionAPIController do
conn
|> put_status(:bad_request)
|> json(%{error: reason})
other ->
_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]],
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug). Provide either a UUID or a slug.",
type: :string,
required: true,
example: "00000000-0000-0000-0000-000000000000 or my-map-slug"
],
id: [in: :path, type: :string, 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
def delete(%{assigns: %{map_id: _map_id}} = conn, %{"id" => id}) do
delete_connection_id(conn, id)
end
def delete(%{assigns: %{map_id: _map_id}} = conn, %{"solar_system_source" => src, "solar_system_target" => tgt}) do
delete_by_systems(conn, src, tgt)
end
# Private helpers for delete/2
defp delete_connection_id(conn, id) do
case Operations.get_connection(conn, id) do
{:ok, conn_struct} ->
case MapData.remove_connection(map_id, conn_struct) do
source_id = conn_struct.solar_system_source
target_id = conn_struct.solar_system_target
case Operations.delete_connection(conn, source_id, target_id) do
:ok -> {:ok, conn_struct}
error -> error
end
_ -> {:error, :invalid_id}
end
end
defp delete_by_systems(conn, src, tgt) do
with {:ok, source} <- APIUtils.parse_int(src),
{:ok, target} <- APIUtils.parse_int(tgt) do
do_delete_by_systems(conn, source, target, src, tgt)
else
{:error, :not_found} ->
Logger.error("[delete_connection] Connection not found for source=#{inspect(src)}, target=#{inspect(tgt)}")
{:error, :not_found}
{:error, reason} ->
Logger.error("[delete_connection] Error: #{inspect(reason)}")
{:error, reason}
error ->
Logger.error("[delete_connection] Unexpected error: #{inspect(error)}")
{:error, :internal_server_error}
end
end
defp do_delete_by_systems(conn, source, target, src, tgt) do
map_id = conn.assigns.map_id
case Operations.get_connection_by_systems(map_id, source, target) do
{:ok, nil} ->
Logger.error("[delete_connection] No connection found for source=#{inspect(source)}, target=#{inspect(target)}")
try_reverse_delete(conn, source, target, src, tgt)
{:ok, conn_struct} ->
case Operations.delete_connection(conn, conn_struct.solar_system_source, conn_struct.solar_system_target) 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
{:error, _} ->
try_reverse_delete(conn, source, target, src, tgt)
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})
defp try_reverse_delete(conn, source, target, src, tgt) do
map_id = conn.assigns.map_id
case Operations.get_connection_by_systems(map_id, target, source) do
{:ok, nil} ->
Logger.error("[delete_connection] No connection found for source=#{inspect(target)}, target=#{inspect(source)}")
{:error, :not_found}
{:ok, conn_struct} ->
case Operations.delete_connection(conn, conn_struct.solar_system_source, conn_struct.solar_system_target) do
:ok -> send_resp(conn, :no_content, "")
error -> {:error, error}
end
{:error, reason} ->
Logger.error("[delete_connection] Connection not found for source=#{inspect(src)}, target=#{inspect(tgt)} (both orders)")
{:error, reason}
end
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]],
parameters: [
map_identifier: [
in: :path,
description: "Map identifier (UUID or slug). Provide either a UUID or a slug.",
type: :string,
required: true,
example: "00000000-0000-0000-0000-000000000000 or my-map-slug"
],
id: [in: :path, type: :string, 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
@@ -233,11 +362,9 @@ defmodule WandererAppWeb.MapConnectionAPIController do
|> 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
update_by_id(conn, map_id, id, attrs)
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 =
@@ -245,42 +372,82 @@ defmodule WandererAppWeb.MapConnectionAPIController do
|> Map.take(allowed_fields)
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Enum.into(%{})
update_by_systems(conn, map_id, src, tgt, attrs)
end
# Private helpers for update/2
defp update_by_id(conn, _map_id, id, attrs) do
case Operations.update_connection(conn, id, attrs) do
{:ok, updated_conn} -> APIUtils.respond_data(conn, APIUtils.connection_to_json(updated_conn))
err -> err
end
end
defp update_by_systems(conn, _map_id, src, tgt, attrs) do
require Logger
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))
{:ok, target} <- APIUtils.parse_int(tgt) do
do_update_by_systems(conn, source, target, src, tgt, attrs)
else
{:error, :not_found} ->
Logger.error("[update_connection] Connection not found for source=#{inspect(src)}, target=#{inspect(tgt)}")
{:error, :not_found}
{:error, reason} ->
Logger.error("[update_connection] Error: #{inspect(reason)}")
{:error, reason}
error ->
Logger.error("[update_connection] Unexpected error: #{inspect(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
defp do_update_by_systems(conn, source, target, src, tgt, attrs) do
map_id = conn.assigns.map_id
case Operations.get_connection_by_systems(map_id, source, target) do
{:ok, nil} ->
Logger.error("[update_connection] No connection found for source=#{inspect(source)}, target=#{inspect(target)}")
try_reverse_update(conn, source, target, src, tgt, attrs)
{:ok, conn_struct} ->
do_update_connection(conn, conn_struct.id, attrs)
{:error, _} ->
try_reverse_update(conn, source, target, src, tgt, attrs)
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
defp try_reverse_update(conn, source, target, src, tgt, attrs) do
map_id = conn.assigns.map_id
case Operations.get_connection_by_systems(map_id, target, source) do
{:ok, nil} ->
Logger.error("[update_connection] No connection found for source=#{inspect(target)}, target=#{inspect(source)}")
{:error, :not_found}
{:ok, conn_struct} ->
do_update_connection(conn, conn_struct.id, attrs)
{:error, reason} ->
Logger.error("[update_connection] Connection not found for source=#{inspect(src)}, target=#{inspect(tgt)} (both orders)")
{:error, reason}
end
end
defp do_update_connection(conn, id, attrs) do
case Operations.update_connection(conn, id, attrs) do
{:ok, updated_conn} -> APIUtils.respond_data(conn, APIUtils.connection_to_json(updated_conn))
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} | _]}} ->
Logger.error("[update_connection] Ash update NotFound for id=#{id}")
{:error, :not_found}
err -> err
end
end
@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