Files
wanderer/lib/wanderer_app_web/helpers/api_utils.ex
2025-06-13 21:14:07 -04:00

311 lines
10 KiB
Elixir

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
# Coerce to boolean; accept "true"/"false", 1/0, etc.
locked =
case normalized_params["locked"] do
val when val in [true, "true", 1, "1"] -> true
val when val in [false, "false", 0, "0"] -> false
nil -> false
other -> other # keep unknowns for caller-side validation
end
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
original = get_original_name(system.solar_system_id)
# Determine the actual custom_name: if name differs from original, use it as custom_name
actual_custom_name = if system.name != original and system.name not in [nil, ""], do: system.name, else: system.custom_name
base =
Map.take(system, ~w(
id map_id solar_system_id temporary_name description tag labels
locked visible status position_x position_y inserted_at updated_at
)a)
|> Map.put(:custom_name, actual_custom_name)
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, name: n, solar_system_id: id} = system) do
original = get_original_name(id)
cond do
t not in [nil, ""] -> t
c not in [nil, ""] -> c
# If name differs from original, it's a custom name
n not in [nil, ""] and n != original -> n
true -> original
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