From c8fc31257b54a968d16b959d7a3f89ad12afab4e Mon Sep 17 00:00:00 2001 From: guarzo Date: Thu, 6 Mar 2025 15:31:31 -0500 Subject: [PATCH] Add api specs (#217) --- .gitignore | 1 + .../images/news/03-05-api/swagger-ui.png | Bin 0 -> 81143 bytes lib/wanderer_app_web/api_spec.ex | 32 + .../controllers/access_list_api_controller.ex | 412 +++++++- .../access_list_member_api_controller.ex | 188 +++- .../controllers/character_api_controller.ex | 39 +- .../controllers/common_api_controller.ex | 63 +- .../controllers/map_api_controller.ex | 423 ++++++++- .../controllers/plugs/check_map_api_key.ex | 11 +- lib/wanderer_app_web/router.ex | 156 ++- mix.exs | 1 + mix.lock | 2 + priv/posts/2025/02-20-acl-api.md | 81 +- priv/posts/2025/03-05-api.md | 837 ++++++++++++++++ test/manual/.api_test_config | 13 + test/manual/.api_test_config.example | 13 + test/manual/.auto_api_test_config | 13 + test/manual/auto_test_api.sh | 894 ++++++++++++++++++ test/manual/test_api_calls.sh | 198 ---- 19 files changed, 3071 insertions(+), 306 deletions(-) create mode 100755 assets/static/images/news/03-05-api/swagger-ui.png create mode 100644 lib/wanderer_app_web/api_spec.ex create mode 100644 priv/posts/2025/03-05-api.md create mode 100644 test/manual/.api_test_config create mode 100644 test/manual/.api_test_config.example create mode 100644 test/manual/.auto_api_test_config create mode 100755 test/manual/auto_test_api.sh delete mode 100755 test/manual/test_api_calls.sh diff --git a/.gitignore b/.gitignore index 0c7beae4..d5b16e17 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ .env *.local.env +test/manual/.auto* .direnv/ .cache/ diff --git a/assets/static/images/news/03-05-api/swagger-ui.png b/assets/static/images/news/03-05-api/swagger-ui.png new file mode 100755 index 0000000000000000000000000000000000000000..c034b73456f199f4f87bdea0e9fc647c46b15660 GIT binary patch literal 81143 zcmeFYXH-*b^f!nV1(9n31f(cnK>?8_9YIP!I-wWo9i-O)iipw;(tC%55a~6bB1L)& zEfgX25D-E@0)#oa-v2wZX07@1u9rHga2PEL9De)j&A=X})CP^P0{p`oIpq64YC z(4nHb&`m{k&gIfY;2U=*wMO928801Wd8&$m+v~v2dHd(; ?<7tmAF97?OU#l2- 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 C@iTb< literal 0 HcmV?d00001 diff --git a/lib/wanderer_app_web/api_spec.ex b/lib/wanderer_app_web/api_spec.ex new file mode 100644 index 00000000..2d0a2fc6 --- /dev/null +++ b/lib/wanderer_app_web/api_spec.ex @@ -0,0 +1,32 @@ +defmodule WandererAppWeb.ApiSpec do + @behaviour OpenApiSpex.OpenApi + + alias OpenApiSpex.{OpenApi, Info, Paths, Components, SecurityScheme, Server} + alias WandererAppWeb.{Endpoint, Router} + + @impl OpenApiSpex.OpenApi + def spec do + %OpenApi{ + info: %Info{ + title: "WandererApp API", + version: "1.0.0", + description: "API documentation for WandererApp" + }, + servers: [ + Server.from_endpoint(Endpoint) + ], + paths: Paths.from_router(Router), + components: %Components{ + securitySchemes: %{ + "bearerAuth" => %SecurityScheme{ + type: "http", + scheme: "bearer", + bearerFormat: "JWT" + } + } + }, + security: [%{"bearerAuth" => []}] + } + |> OpenApiSpex.resolve_schema_modules() + end +end diff --git a/lib/wanderer_app_web/controllers/access_list_api_controller.ex b/lib/wanderer_app_web/controllers/access_list_api_controller.ex index 1f85562a..b9229e6c 100644 --- a/lib/wanderer_app_web/controllers/access_list_api_controller.ex +++ b/lib/wanderer_app_web/controllers/access_list_api_controller.ex @@ -7,26 +7,247 @@ defmodule WandererAppWeb.MapAccessListAPIController do - POST /api/map/acls (create ACL) - GET /api/acls/:id (show ACL) - PUT /api/acls/:id (update ACL) - - ACL members are managed via a separate controller. """ use WandererAppWeb, :controller + use OpenApiSpex.ControllerSpecs + alias WandererApp.Api.{AccessList, Character} alias WandererAppWeb.UtilAPIController, as: Util import Ash.Query require Logger + # ------------------------------------------------------------------------ + # Inline Schemas for OpenApiSpex + # ------------------------------------------------------------------------ + + # Used in operation :index => the response "List of ACLs" + @acl_index_response_schema %OpenApiSpex.Schema{ + type: :object, + properties: %{ + data: %OpenApiSpex.Schema{ + type: :array, + items: %OpenApiSpex.Schema{ + type: :object, + properties: %{ + id: %OpenApiSpex.Schema{type: :string}, + name: %OpenApiSpex.Schema{type: :string}, + description: %OpenApiSpex.Schema{type: :string}, + owner_eve_id: %OpenApiSpex.Schema{type: :string}, + inserted_at: %OpenApiSpex.Schema{type: :string, format: :date_time}, + updated_at: %OpenApiSpex.Schema{type: :string, format: :date_time} + }, + required: ["id", "name"] + } + } + }, + required: ["data"] + } + + # Used in operation :create => the request body "ACL parameters" + @acl_create_request_schema %OpenApiSpex.Schema{ + type: :object, + properties: %{ + acl: %OpenApiSpex.Schema{ + type: :object, + properties: %{ + owner_eve_id: %OpenApiSpex.Schema{ + type: :string, + description: "EVE character ID of the owner (must match an existing character)" + }, + name: %OpenApiSpex.Schema{ + type: :string, + description: "Name of the access list" + }, + description: %OpenApiSpex.Schema{ + type: :string, + description: "Optional description of the access list" + } + }, + required: ["owner_eve_id", "name"], + example: %{ + "owner_eve_id" => "2112073677", + "name" => "My Access List", + "description" => "Optional description" + } + } + }, + required: ["acl"] + } + + # Used in operation :create => the response "Created ACL" + @acl_create_response_schema %OpenApiSpex.Schema{ + type: :object, + properties: %{ + data: %OpenApiSpex.Schema{ + type: :object, + properties: %{ + id: %OpenApiSpex.Schema{type: :string}, + name: %OpenApiSpex.Schema{type: :string}, + description: %OpenApiSpex.Schema{type: :string}, + owner_id: %OpenApiSpex.Schema{type: :string}, + api_key: %OpenApiSpex.Schema{type: :string}, + inserted_at: %OpenApiSpex.Schema{type: :string, format: :date_time}, + updated_at: %OpenApiSpex.Schema{type: :string, format: :date_time} + }, + required: ["id", "name"] + } + }, + required: ["data"] + } + + # Used in operation :show => the response "ACL details" + @acl_show_response_schema %OpenApiSpex.Schema{ + type: :object, + properties: %{ + data: %OpenApiSpex.Schema{ + type: :object, + properties: %{ + id: %OpenApiSpex.Schema{type: :string}, + name: %OpenApiSpex.Schema{type: :string}, + description: %OpenApiSpex.Schema{type: :string}, + owner_id: %OpenApiSpex.Schema{type: :string}, + api_key: %OpenApiSpex.Schema{type: :string}, + inserted_at: %OpenApiSpex.Schema{type: :string, format: :date_time}, + updated_at: %OpenApiSpex.Schema{type: :string, format: :date_time}, + members: %OpenApiSpex.Schema{ + type: :array, + items: %OpenApiSpex.Schema{ + type: :object, + properties: %{ + id: %OpenApiSpex.Schema{type: :string}, + name: %OpenApiSpex.Schema{type: :string}, + role: %OpenApiSpex.Schema{type: :string}, + eve_character_id: %OpenApiSpex.Schema{type: :string}, + eve_corporation_id: %OpenApiSpex.Schema{type: :string}, + eve_alliance_id: %OpenApiSpex.Schema{type: :string}, + inserted_at: %OpenApiSpex.Schema{type: :string, format: :date_time}, + updated_at: %OpenApiSpex.Schema{type: :string, format: :date_time} + }, + required: ["id", "name", "role"] + } + } + }, + required: ["id", "name"] + } + }, + required: ["data"] + } + + # Used in operation :update => the request body "ACL update payload" + @acl_update_request_schema %OpenApiSpex.Schema{ + type: :object, + properties: %{ + acl: %OpenApiSpex.Schema{ + type: :object, + properties: %{ + name: %OpenApiSpex.Schema{type: :string}, + description: %OpenApiSpex.Schema{type: :string} + } + # If "name" is truly required, add it to required: ["name"] here + } + }, + required: ["acl"] + } + + # Used in operation :update => the response "Updated ACL" + @acl_update_response_schema %OpenApiSpex.Schema{ + type: :object, + properties: %{ + data: %OpenApiSpex.Schema{ + type: :object, + properties: %{ + id: %OpenApiSpex.Schema{type: :string}, + name: %OpenApiSpex.Schema{type: :string}, + description: %OpenApiSpex.Schema{type: :string}, + owner_id: %OpenApiSpex.Schema{type: :string}, + api_key: %OpenApiSpex.Schema{type: :string}, + inserted_at: %OpenApiSpex.Schema{type: :string, format: :date_time}, + updated_at: %OpenApiSpex.Schema{type: :string, format: :date_time}, + members: %OpenApiSpex.Schema{ + type: :array, + items: %OpenApiSpex.Schema{ + type: :object, + properties: %{ + id: %OpenApiSpex.Schema{type: :string}, + name: %OpenApiSpex.Schema{type: :string}, + role: %OpenApiSpex.Schema{type: :string}, + eve_character_id: %OpenApiSpex.Schema{type: :string}, + eve_corporation_id: %OpenApiSpex.Schema{type: :string}, + eve_alliance_id: %OpenApiSpex.Schema{type: :string}, + inserted_at: %OpenApiSpex.Schema{type: :string, format: :date_time}, + updated_at: %OpenApiSpex.Schema{type: :string, format: :date_time} + }, + required: ["id", "name", "role"] + } + } + }, + required: ["id", "name"] + } + }, + required: ["data"] + } + + # ------------------------------------------------------------------------ + # ENDPOINTS + # ------------------------------------------------------------------------ + @doc """ GET /api/map/acls?map_id=... or ?slug=... Lists the ACLs for a given map. """ + @spec index(Plug.Conn.t(), map()) :: Plug.Conn.t() + operation :index, + summary: "List ACLs for a Map", + description: "Lists the ACLs for a given map. Requires either 'map_id' or 'slug' as a query parameter to identify the map.", + parameters: [ + map_id: [ + in: :query, + description: "Map identifier (UUID) - Either map_id or slug must be provided", + type: :string, + required: false, + example: "00000000-0000-0000-0000-000000000000" + ], + slug: [ + in: :query, + description: "Map slug - Either map_id or slug must be provided", + type: :string, + required: false, + example: "map-name" + ] + ], + responses: [ + ok: { + "List of ACLs", + "application/json", + @acl_index_response_schema + }, + bad_request: {"Error", "application/json", %OpenApiSpex.Schema{ + type: :object, + properties: %{ + error: %OpenApiSpex.Schema{type: :string} + }, + required: ["error"], + example: %{ + "error" => "Must provide either ?map_id=UUID or ?slug=SLUG as a query parameter" + } + }}, + not_found: {"Error", "application/json", %OpenApiSpex.Schema{ + type: :object, + properties: %{ + error: %OpenApiSpex.Schema{type: :string} + }, + required: ["error"], + example: %{ + "error" => "Map not found. Please provide a valid map_id or slug as a query parameter." + } + }} + ] def index(conn, params) do case Util.fetch_map_id(params) do {:ok, map_identifier} -> with {:ok, map} <- get_map(map_identifier), - # Load ACLs and each ACL's :owner in a single pass: {:ok, loaded_map} <- Ash.load(map, acls: [:owner]) do acls = loaded_map.acls || [] json(conn, %{data: Enum.map(acls, &acl_to_list_json/1)}) @@ -34,7 +255,7 @@ defmodule WandererAppWeb.MapAccessListAPIController do {:error, :map_not_found} -> conn |> put_status(:not_found) - |> json(%{error: "Map not found"}) + |> json(%{error: "Map not found. Please provide a valid map_id or slug as a query parameter."}) {:error, error} -> conn @@ -42,10 +263,10 @@ defmodule WandererAppWeb.MapAccessListAPIController do |> json(%{error: inspect(error)}) end - {:error, msg} -> + {:error, _msg} -> conn |> put_status(:bad_request) - |> json(%{error: msg}) + |> json(%{error: "Must provide either ?map_id=UUID or ?slug=SLUG as a query parameter"}) end end @@ -54,6 +275,50 @@ defmodule WandererAppWeb.MapAccessListAPIController do Creates a new ACL for a map. """ + @spec create(Plug.Conn.t(), map()) :: Plug.Conn.t() + operation :create, + summary: "Create a new ACL", + description: "Creates a new ACL for a map. Requires either 'map_id' or 'slug' as a query parameter to identify the map.", + parameters: [ + map_id: [ + in: :query, + description: "Map identifier (UUID) - Either map_id or slug must be provided", + type: :string, + required: false, + example: "00000000-0000-0000-0000-000000000000" + ], + slug: [ + in: :query, + description: "Map slug - Either map_id or slug must be provided", + type: :string, + required: false, + example: "map-name" + ] + ], + request_body: {"Access List parameters", "application/json", @acl_create_request_schema}, + responses: [ + ok: {"Access List", "application/json", @acl_create_response_schema}, + bad_request: {"Error", "application/json", %OpenApiSpex.Schema{ + type: :object, + properties: %{ + error: %OpenApiSpex.Schema{type: :string} + }, + required: ["error"], + example: %{ + "error" => "Must provide either ?map_id=UUID or ?slug=SLUG as a query parameter" + } + }}, + not_found: {"Error", "application/json", %OpenApiSpex.Schema{ + type: :object, + properties: %{ + error: %OpenApiSpex.Schema{type: :string} + }, + required: ["error"], + example: %{ + "error" => "Map not found. Please provide a valid map_id or slug as a query parameter." + } + }} + ] def create(conn, params) do with {:ok, map_identifier} <- Util.fetch_map_id(params), {:ok, map} <- get_map(map_identifier), @@ -71,6 +336,16 @@ defmodule WandererAppWeb.MapAccessListAPIController do {:ok, _updated_map} <- associate_acl_with_map(map, new_acl) do json(conn, %{data: acl_to_json(new_acl)}) else + {:error, :map_not_found} -> + conn + |> put_status(:not_found) + |> json(%{error: "Map not found. Please provide a valid map_id or slug as a query parameter."}) + + {:error, "Must provide either ?map_id=UUID or ?slug=SLUG"} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Must provide either ?map_id=UUID or ?slug=SLUG as a query parameter"}) + nil -> conn |> put_status(:bad_request) @@ -79,9 +354,13 @@ defmodule WandererAppWeb.MapAccessListAPIController do {:error, "owner_eve_id does not match any existing character"} = error -> conn |> put_status(:bad_request) - |> json(%{error: inspect(error)}) + |> json(%{error: "Character not found: The provided owner_eve_id does not match any existing character"}) + + %{} -> + conn + |> put_status(:bad_request) + |> json(%{error: "Missing required 'acl' object in request body"}) - # For any other error, also a bad request—adjust if you want a different code error -> conn |> put_status(:bad_request) @@ -94,6 +373,46 @@ defmodule WandererAppWeb.MapAccessListAPIController do Shows a specific ACL (with its members). """ + @spec show(Plug.Conn.t(), map()) :: Plug.Conn.t() + operation :show, + summary: "Get ACL details", + description: "Retrieves details for a specific ACL by its ID.", + parameters: [ + id: [ + in: :path, + description: "ACL identifier (UUID)", + type: :string, + required: true, + example: "00000000-0000-0000-0000-000000000000" + ] + ], + responses: [ + ok: { + "ACL details", + "application/json", + @acl_show_response_schema + }, + not_found: {"Error", "application/json", %OpenApiSpex.Schema{ + type: :object, + properties: %{ + error: %OpenApiSpex.Schema{type: :string} + }, + required: ["error"], + example: %{ + "error" => "ACL not found" + } + }}, + internal_server_error: {"Error", "application/json", %OpenApiSpex.Schema{ + type: :object, + properties: %{ + error: %OpenApiSpex.Schema{type: :string} + }, + required: ["error"], + example: %{ + "error" => "Failed to load ACL members: reason" + } + }} + ] def show(conn, %{"id" => id}) do query = AccessList @@ -102,7 +421,6 @@ defmodule WandererAppWeb.MapAccessListAPIController do case WandererApp.Api.read(query) do {:ok, [acl]} -> - # We load members for a single ACL case Ash.load(acl, :members) do {:ok, loaded_acl} -> json(conn, %{data: acl_to_json(loaded_acl)}) @@ -130,6 +448,51 @@ defmodule WandererAppWeb.MapAccessListAPIController do Updates an ACL. """ + @spec update(Plug.Conn.t(), map()) :: Plug.Conn.t() + operation :update, + summary: "Update an ACL", + description: "Updates an existing ACL by its ID.", + parameters: [ + id: [ + in: :path, + description: "ACL identifier (UUID)", + type: :string, + required: true, + example: "00000000-0000-0000-0000-000000000000" + ] + ], + request_body: { + "ACL update payload", + "application/json", + @acl_update_request_schema + }, + responses: [ + ok: { + "Updated ACL", + "application/json", + @acl_update_response_schema + }, + bad_request: {"Error", "application/json", %OpenApiSpex.Schema{ + type: :object, + properties: %{ + error: %OpenApiSpex.Schema{type: :string} + }, + required: ["error"], + example: %{ + "error" => "Failed to update ACL: invalid parameters" + } + }}, + not_found: {"Error", "application/json", %OpenApiSpex.Schema{ + type: :object, + properties: %{ + error: %OpenApiSpex.Schema{type: :string} + }, + required: ["error"], + example: %{ + "error" => "ACL not found" + } + }} + ] def update(conn, %{"id" => id, "acl" => acl_params}) do with {:ok, acl} <- AccessList.by_id(id), {:ok, updated_acl} <- AccessList.update(acl, acl_params), @@ -147,9 +510,10 @@ defmodule WandererAppWeb.MapAccessListAPIController do # Private / Helper Functions # --------------------------------------------------------------------------- defp get_map(map_identifier) do - # If your WandererApp.Api.Map.by_id/1 returns :map_not_found or - # returns {:ok, map}/{:error, ...}, you can handle that here - WandererApp.Api.Map.by_id(map_identifier) + case WandererApp.Api.Map.by_id(map_identifier) do + {:ok, map} -> {:ok, map} + {:error, _} -> {:error, :map_not_found} + end end defp acl_to_json(acl) do @@ -173,7 +537,6 @@ defmodule WandererAppWeb.MapAccessListAPIController do end defp acl_to_list_json(acl) do - # Because we loaded :owner for each ACL in index/2, we can reference it here owner_eve_id = case acl.owner do %Character{eve_id: eid} -> eid @@ -191,17 +554,22 @@ defmodule WandererAppWeb.MapAccessListAPIController do end defp member_to_json(member) do - %{ + base = %{ id: member.id, name: member.name, role: member.role, - eve_character_id: member.eve_character_id, inserted_at: member.inserted_at, updated_at: member.updated_at } + + cond do + member.eve_character_id -> Map.put(base, :eve_character_id, member.eve_character_id) + member.eve_corporation_id -> Map.put(base, :eve_corporation_id, member.eve_corporation_id) + member.eve_alliance_id -> Map.put(base, :eve_alliance_id, member.eve_alliance_id) + true -> base + end end - # Helper to find a character by external EVE id. defp find_character_by_eve_id(eve_id) do query = Character @@ -225,8 +593,16 @@ defmodule WandererAppWeb.MapAccessListAPIController do with {:ok, api_map} <- WandererApp.Api.Map.by_id(map.id), {:ok, loaded_map} <- Ash.load(api_map, :acls) do new_acl_id = if is_binary(new_acl), do: new_acl, else: new_acl.id - current_acls = loaded_map.acls || [] - updated_acls = current_acls ++ [new_acl_id] + + # Extract IDs from current ACLs to ensure we're working with UUIDs only + current_acl_ids = loaded_map.acls + |> Kernel.||([]) + |> Enum.map(fn + acl when is_binary(acl) -> acl + acl -> acl.id + end) + + updated_acls = current_acl_ids ++ [new_acl_id] case WandererApp.Api.Map.update_acls(loaded_map, %{acls: updated_acls}) do {:ok, updated_map} -> diff --git a/lib/wanderer_app_web/controllers/access_list_member_api_controller.ex b/lib/wanderer_app_web/controllers/access_list_member_api_controller.ex index 43949b7a..9d1a6cd5 100644 --- a/lib/wanderer_app_web/controllers/access_list_member_api_controller.ex +++ b/lib/wanderer_app_web/controllers/access_list_member_api_controller.ex @@ -1,23 +1,132 @@ defmodule WandererAppWeb.AccessListMemberAPIController do @moduledoc """ Handles creation, role updates, and deletion of individual ACL members. - - This controller supports creation of members by accepting one of the following keys: - - "eve_character_id" - - "eve_corporation_id" - - "eve_alliance_id" - - For corporation and alliance members, roles "admin" and "manager" are disallowed. """ use WandererAppWeb, :controller + use OpenApiSpex.ControllerSpecs + alias WandererApp.Api.AccessListMember import Ash.Query require Logger + # ------------------------------------------------------------------------ + # Inline Schemas + # ------------------------------------------------------------------------ + @acl_member_create_request_schema %OpenApiSpex.Schema{ + type: :object, + properties: %{ + member: %OpenApiSpex.Schema{ + type: :object, + properties: %{ + eve_character_id: %OpenApiSpex.Schema{type: :string}, + eve_corporation_id: %OpenApiSpex.Schema{type: :string}, + eve_alliance_id: %OpenApiSpex.Schema{type: :string}, + role: %OpenApiSpex.Schema{type: :string} + } + # no 'required' fields if you truly allow any of them + } + }, + required: ["member"] + } + + @acl_member_create_response_schema %OpenApiSpex.Schema{ + type: :object, + properties: %{ + data: %OpenApiSpex.Schema{ + type: :object, + properties: %{ + id: %OpenApiSpex.Schema{type: :string}, + name: %OpenApiSpex.Schema{type: :string}, + role: %OpenApiSpex.Schema{type: :string}, + eve_character_id: %OpenApiSpex.Schema{type: :string}, + eve_corporation_id: %OpenApiSpex.Schema{type: :string}, + eve_alliance_id: %OpenApiSpex.Schema{type: :string}, + inserted_at: %OpenApiSpex.Schema{type: :string, format: :date_time}, + updated_at: %OpenApiSpex.Schema{type: :string, format: :date_time} + }, + required: ["id", "name", "role"] + } + }, + required: ["data"] + } + + @acl_member_update_request_schema %OpenApiSpex.Schema{ + type: :object, + properties: %{ + member: %OpenApiSpex.Schema{ + type: :object, + properties: %{ + role: %OpenApiSpex.Schema{type: :string} + }, + required: ["role"] + } + }, + required: ["member"] + } + + @acl_member_update_response_schema %OpenApiSpex.Schema{ + type: :object, + properties: %{ + data: %OpenApiSpex.Schema{ + type: :object, + properties: %{ + id: %OpenApiSpex.Schema{type: :string}, + name: %OpenApiSpex.Schema{type: :string}, + role: %OpenApiSpex.Schema{type: :string}, + eve_character_id: %OpenApiSpex.Schema{type: :string}, + eve_corporation_id: %OpenApiSpex.Schema{type: :string}, + eve_alliance_id: %OpenApiSpex.Schema{type: :string}, + inserted_at: %OpenApiSpex.Schema{type: :string, format: :date_time}, + updated_at: %OpenApiSpex.Schema{type: :string, format: :date_time} + }, + required: ["id", "name", "role"] + } + }, + required: ["data"] + } + + @acl_member_delete_response_schema %OpenApiSpex.Schema{ + type: :object, + properties: %{ + ok: %OpenApiSpex.Schema{type: :boolean} + }, + required: ["ok"] + } + + # ------------------------------------------------------------------------ + # ENDPOINTS + # ------------------------------------------------------------------------ + @doc """ POST /api/acls/:acl_id/members + + Creates a new ACL member. """ + @spec create(Plug.Conn.t(), map()) :: Plug.Conn.t() + operation :create, + summary: "Create ACL Member", + description: "Creates a new ACL member for a given ACL.", + parameters: [ + acl_id: [ + in: :path, + description: "Access List ID", + type: :string, + required: true + ] + ], + request_body: { + "ACL Member parameters", + "application/json", + @acl_member_create_request_schema + }, + responses: [ + ok: { + "Created ACL Member", + "application/json", + @acl_member_create_response_schema + } + ] def create(conn, %{"acl_id" => acl_id, "member" => member_params}) do chosen = cond do @@ -44,7 +153,7 @@ defmodule WandererAppWeb.AccessListMemberAPIController do else {key, type} = chosen raw_id = Map.get(member_params, key) - id_str = to_string(raw_id) # handle string/integer input + id_str = to_string(raw_id) role = Map.get(member_params, "role", "viewer") if type in ["corporation", "alliance"] and role in ["admin", "manager"] do @@ -93,13 +202,44 @@ defmodule WandererAppWeb.AccessListMemberAPIController do @doc """ PUT /api/acls/:acl_id/members/:member_id + + Updates the role of an ACL member. """ + @spec update_role(Plug.Conn.t(), map()) :: Plug.Conn.t() + operation :update_role, + summary: "Update ACL Member Role", + description: "Updates the role of an ACL member identified by ACL ID and member external ID.", + parameters: [ + acl_id: [ + in: :path, + description: "Access List ID", + type: :string, + required: true + ], + member_id: [ + in: :path, + description: "Member external ID", + type: :string, + required: true + ] + ], + request_body: { + "ACL Member update payload", + "application/json", + @acl_member_update_request_schema + }, + responses: [ + ok: { + "Updated ACL Member", + "application/json", + @acl_member_update_response_schema + } + ] def update_role(conn, %{ "acl_id" => acl_id, "member_id" => external_id, "member" => member_params }) do - # Convert external_id to string if you expect it may come in as integer external_id_str = to_string(external_id) membership_query = @@ -157,7 +297,34 @@ defmodule WandererAppWeb.AccessListMemberAPIController do @doc """ DELETE /api/acls/:acl_id/members/:member_id + + Deletes an ACL member. """ + @spec delete(Plug.Conn.t(), map()) :: Plug.Conn.t() + operation :delete, + summary: "Delete ACL Member", + description: "Deletes an ACL member identified by ACL ID and member external ID.", + parameters: [ + acl_id: [ + in: :path, + description: "Access List ID", + type: :string, + required: true + ], + member_id: [ + in: :path, + description: "Member external ID", + type: :string, + required: true + ] + ], + responses: [ + ok: { + "ACL Member deletion confirmation", + "application/json", + @acl_member_delete_response_schema + } + ] def delete(conn, %{"acl_id" => acl_id, "member_id" => external_id}) do external_id_str = to_string(external_id) @@ -204,6 +371,9 @@ defmodule WandererAppWeb.AccessListMemberAPIController do id: member.id, name: member.name, role: member.role, + eve_character_id: member.eve_character_id, + eve_corporation_id: member.eve_corporation_id, + eve_alliance_id: member.eve_alliance_id, inserted_at: member.inserted_at, updated_at: member.updated_at } diff --git a/lib/wanderer_app_web/controllers/character_api_controller.ex b/lib/wanderer_app_web/controllers/character_api_controller.ex index 60e97ccc..f741738a 100644 --- a/lib/wanderer_app_web/controllers/character_api_controller.ex +++ b/lib/wanderer_app_web/controllers/character_api_controller.ex @@ -1,20 +1,47 @@ defmodule WandererAppWeb.CharactersAPIController do @moduledoc """ Exposes an endpoint for listing ALL characters in the database - - Endpoint: - GET /api/characters """ use WandererAppWeb, :controller + use OpenApiSpex.ControllerSpecs alias WandererApp.Api.Character + @characters_index_response_schema %OpenApiSpex.Schema{ + type: :object, + properties: %{ + data: %OpenApiSpex.Schema{ + type: :array, + items: %OpenApiSpex.Schema{ + type: :object, + properties: %{ + id: %OpenApiSpex.Schema{type: :string}, + eve_id: %OpenApiSpex.Schema{type: :string}, + name: %OpenApiSpex.Schema{type: :string}, + corporation_name: %OpenApiSpex.Schema{type: :string}, + alliance_name: %OpenApiSpex.Schema{type: :string} + }, + required: ["id", "eve_id", "name"] + } + } + }, + required: ["data"] + } + @doc """ GET /api/characters - - Lists ALL characters in the database - Returns an array of objects, each with `id`, `eve_id`, `name`, etc. """ + @spec index(Plug.Conn.t(), map()) :: Plug.Conn.t() + operation :index, + summary: "List Characters", + description: "Lists ALL characters in the database.", + responses: [ + ok: { + "List of characters", + "application/json", + @characters_index_response_schema + } + ] def index(conn, _params) do case WandererApp.Api.read(Character) do {:ok, characters} -> diff --git a/lib/wanderer_app_web/controllers/common_api_controller.ex b/lib/wanderer_app_web/controllers/common_api_controller.ex index b735fa88..9405f317 100644 --- a/lib/wanderer_app_web/controllers/common_api_controller.ex +++ b/lib/wanderer_app_web/controllers/common_api_controller.ex @@ -1,17 +1,64 @@ defmodule WandererAppWeb.CommonAPIController do use WandererAppWeb, :controller + use OpenApiSpex.ControllerSpecs alias WandererApp.CachedInfo alias WandererAppWeb.UtilAPIController, as: Util + @system_static_response_schema %OpenApiSpex.Schema{ + type: :object, + properties: %{ + data: %OpenApiSpex.Schema{ + type: :object, + properties: %{ + solar_system_id: %OpenApiSpex.Schema{type: :integer}, + region_id: %OpenApiSpex.Schema{type: :integer}, + constellation_id: %OpenApiSpex.Schema{type: :integer}, + solar_system_name: %OpenApiSpex.Schema{type: :string}, + solar_system_name_lc: %OpenApiSpex.Schema{type: :string}, + constellation_name: %OpenApiSpex.Schema{type: :string}, + region_name: %OpenApiSpex.Schema{type: :string}, + system_class: %OpenApiSpex.Schema{type: :integer}, + security: %OpenApiSpex.Schema{type: :string}, + type_description: %OpenApiSpex.Schema{type: :string}, + class_title: %OpenApiSpex.Schema{type: :string}, + is_shattered: %OpenApiSpex.Schema{type: :boolean}, + effect_name: %OpenApiSpex.Schema{type: :string}, + effect_power: %OpenApiSpex.Schema{type: :integer}, + statics: %OpenApiSpex.Schema{type: :array, items: %OpenApiSpex.Schema{type: :string}}, + wandering: %OpenApiSpex.Schema{type: :array, items: %OpenApiSpex.Schema{type: :string}}, + triglavian_invasion_status: %OpenApiSpex.Schema{type: :string}, + sun_type_id: %OpenApiSpex.Schema{type: :integer} + }, + required: ["solar_system_id", "solar_system_name"] + } + }, + required: ["data"] + } + @doc """ - GET /api/common/system_static?id= - - Requires 'id' (the solar_system_id). - - Example: - GET /api/common/system_static?id=31002229 + GET /api/common/system-static-info?id= """ + @spec show_system_static(Plug.Conn.t(), map()) :: Plug.Conn.t() + operation :show_system_static, + summary: "Get System Static Information", + description: "Retrieves static information for a given solar system.", + parameters: [ + id: [ + in: :query, + description: "Solar system ID", + type: :string, + example: "30000142", + required: true + ] + ], + responses: [ + ok: { + "System static info", + "application/json", + @system_static_response_schema + } + ] def show_system_static(conn, params) do with {:ok, solar_system_str} <- Util.require_param(params, "id"), {:ok, solar_system_id} <- Util.parse_int(solar_system_str) do @@ -33,10 +80,6 @@ defmodule WandererAppWeb.CommonAPIController do end end - # ---------------------------------------------- - # Private helpers - # ---------------------------------------------- - defp static_system_to_json(system) do system |> Map.take([ diff --git a/lib/wanderer_app_web/controllers/map_api_controller.ex b/lib/wanderer_app_web/controllers/map_api_controller.ex index 423fc4fb..a8893b15 100644 --- a/lib/wanderer_app_web/controllers/map_api_controller.ex +++ b/lib/wanderer_app_web/controllers/map_api_controller.ex @@ -1,11 +1,13 @@ defmodule WandererAppWeb.MapAPIController do use WandererAppWeb, :controller + use OpenApiSpex.ControllerSpecs import Ash.Query, only: [filter: 2] require Logger alias WandererApp.Api alias WandererApp.Api.Character + alias WandererApp.Api.MapSolarSystem alias WandererApp.MapSystemRepo alias WandererApp.MapCharacterSettingsRepo @@ -13,6 +15,166 @@ defmodule WandererAppWeb.MapAPIController do alias WandererAppWeb.UtilAPIController, as: Util + # ----------------------------------------------------------------- + # Inline Schemas + # ----------------------------------------------------------------- + + @map_system_schema %OpenApiSpex.Schema{ + type: :object, + properties: %{ + id: %OpenApiSpex.Schema{type: :string}, + map_id: %OpenApiSpex.Schema{type: :string}, + solar_system_id: %OpenApiSpex.Schema{type: :integer}, + original_name: %OpenApiSpex.Schema{type: :string}, + name: %OpenApiSpex.Schema{type: :string}, + custom_name: %OpenApiSpex.Schema{type: :string}, + temporary_name: %OpenApiSpex.Schema{type: :string}, + description: %OpenApiSpex.Schema{type: :string}, + tag: %OpenApiSpex.Schema{type: :string}, + labels: %OpenApiSpex.Schema{type: :array, items: %OpenApiSpex.Schema{type: :string}}, + locked: %OpenApiSpex.Schema{type: :boolean}, + visible: %OpenApiSpex.Schema{type: :boolean}, + status: %OpenApiSpex.Schema{type: :string}, + position_x: %OpenApiSpex.Schema{type: :integer}, + position_y: %OpenApiSpex.Schema{type: :integer}, + inserted_at: %OpenApiSpex.Schema{type: :string, format: :date_time}, + updated_at: %OpenApiSpex.Schema{type: :string, format: :date_time} + }, + required: ["id", "solar_system_id", "original_name", "name"] + } + + @list_map_systems_response_schema %OpenApiSpex.Schema{ + type: :object, + properties: %{ + data: %OpenApiSpex.Schema{ + type: :array, + items: @map_system_schema + } + }, + required: ["data"] + } + + @show_map_system_response_schema %OpenApiSpex.Schema{ + type: :object, + properties: %{ + data: @map_system_schema + }, + required: ["data"] + } + + # For operation :tracked_characters_with_info + @character_schema %OpenApiSpex.Schema{ + type: :object, + properties: %{ + id: %OpenApiSpex.Schema{type: :string}, + eve_id: %OpenApiSpex.Schema{type: :string}, + name: %OpenApiSpex.Schema{type: :string}, + corporation_id: %OpenApiSpex.Schema{type: :string}, + corporation_name: %OpenApiSpex.Schema{type: :string}, + corporation_ticker: %OpenApiSpex.Schema{type: :string}, + alliance_id: %OpenApiSpex.Schema{type: :string}, + alliance_name: %OpenApiSpex.Schema{type: :string}, + alliance_ticker: %OpenApiSpex.Schema{type: :string}, + inserted_at: %OpenApiSpex.Schema{type: :string, format: :date_time}, + updated_at: %OpenApiSpex.Schema{type: :string, format: :date_time} + }, + required: ["id", "eve_id", "name"] + } + + @tracked_char_schema %OpenApiSpex.Schema{ + type: :object, + properties: %{ + id: %OpenApiSpex.Schema{type: :string}, + map_id: %OpenApiSpex.Schema{type: :string}, + character_id: %OpenApiSpex.Schema{type: :string}, + tracked: %OpenApiSpex.Schema{type: :boolean}, + inserted_at: %OpenApiSpex.Schema{type: :string, format: :date_time}, + updated_at: %OpenApiSpex.Schema{type: :string, format: :date_time}, + character: @character_schema + }, + required: ["id", "map_id", "character_id", "tracked"] + } + + @tracked_characters_response_schema %OpenApiSpex.Schema{ + type: :object, + properties: %{ + data: %OpenApiSpex.Schema{ + type: :array, + items: @tracked_char_schema + } + }, + required: ["data"] + } + + # For operation :show_structure_timers + @structure_timer_schema %OpenApiSpex.Schema{ + type: :object, + properties: %{ + system_id: %OpenApiSpex.Schema{type: :string}, + solar_system_name: %OpenApiSpex.Schema{type: :string}, + solar_system_id: %OpenApiSpex.Schema{type: :integer}, + structure_type_id: %OpenApiSpex.Schema{type: :integer}, + structure_type: %OpenApiSpex.Schema{type: :string}, + character_eve_id: %OpenApiSpex.Schema{type: :string}, + name: %OpenApiSpex.Schema{type: :string}, + notes: %OpenApiSpex.Schema{type: :string}, + owner_name: %OpenApiSpex.Schema{type: :string}, + owner_ticker: %OpenApiSpex.Schema{type: :string}, + owner_id: %OpenApiSpex.Schema{type: :string}, + status: %OpenApiSpex.Schema{type: :string}, + end_time: %OpenApiSpex.Schema{type: :string, format: :date_time} + }, + required: ["system_id", "solar_system_id", "name", "status"] + } + + @structure_timers_response_schema %OpenApiSpex.Schema{ + type: :object, + properties: %{ + data: %OpenApiSpex.Schema{ + type: :array, + items: @structure_timer_schema + } + }, + required: ["data"] + } + + # For operation :list_systems_kills + @kill_item_schema %OpenApiSpex.Schema{ + type: :object, + description: "Kill detail object", + properties: %{ + kill_id: %OpenApiSpex.Schema{type: :integer, description: "Unique identifier for the kill"}, + kill_time: %OpenApiSpex.Schema{type: :string, format: :date_time, description: "Time when the kill occurred"}, + victim_id: %OpenApiSpex.Schema{type: :integer, description: "ID of the victim character"}, + victim_name: %OpenApiSpex.Schema{type: :string, description: "Name of the victim character"}, + ship_type_id: %OpenApiSpex.Schema{type: :integer, description: "Type ID of the destroyed ship"}, + ship_name: %OpenApiSpex.Schema{type: :string, description: "Name of the destroyed ship"} + } + } + + @system_kills_schema %OpenApiSpex.Schema{ + type: :object, + properties: %{ + solar_system_id: %OpenApiSpex.Schema{type: :integer}, + kills: %OpenApiSpex.Schema{ + type: :array, + items: @kill_item_schema + } + }, + required: ["solar_system_id", "kills"] + } + + @systems_kills_response_schema %OpenApiSpex.Schema{ + type: :object, + properties: %{ + data: %OpenApiSpex.Schema{ + type: :array, + items: @system_kills_schema + } + }, + required: ["data"] + } + # ----------------------------------------------------------------- # MAP endpoints # ----------------------------------------------------------------- @@ -28,6 +190,43 @@ defmodule WandererAppWeb.MapAPIController do GET /api/map/systems?map_id=466e922b-e758-485e-9b86-afae06b88363 GET /api/map/systems?slug=my-unique-wormhole-map """ + @spec list_systems(Plug.Conn.t(), map()) :: Plug.Conn.t() + operation :list_systems, + summary: "List Map Systems", + description: "Lists all visible systems for a map. Requires either 'map_id' or 'slug' as a query parameter to identify the map.", + parameters: [ + map_id: [ + in: :query, + description: "Map identifier (UUID) - Either map_id or slug must be provided", + type: :string, + required: false, + example: "" + ], + slug: [ + in: :query, + description: "Map slug - Either map_id or slug must be provided", + type: :string, + required: false, + example: "map-name" + ] + ], + responses: [ + ok: { + "List of map systems", + "application/json", + @list_map_systems_response_schema + }, + bad_request: {"Error", "application/json", %OpenApiSpex.Schema{ + type: :object, + properties: %{ + error: %OpenApiSpex.Schema{type: :string} + }, + required: ["error"], + example: %{ + "error" => "Must provide either ?map_id=UUID or ?slug=SLUG" + } + }} + ] def list_systems(conn, params) do with {:ok, map_id} <- Util.fetch_map_id(params), {:ok, systems} <- MapSystemRepo.get_visible_by_map(map_id) do @@ -56,6 +255,60 @@ defmodule WandererAppWeb.MapAPIController do GET /api/map/system?id=31002229&map_id=466e922b-e758-485e-9b86-afae06b88363 GET /api/map/system?id=31002229&slug=my-unique-wormhole-map """ + @spec show_system(Plug.Conn.t(), map()) :: Plug.Conn.t() + operation :show_system, + summary: "Show Map System", + description: "Retrieves details for a specific map system (by solar_system_id + map). Requires either 'map_id' or 'slug' as a query parameter to identify the map.", + parameters: [ + id: [ + in: :query, + description: "System ID", + type: :string, + required: true, + example: "30000142" + ], + map_id: [ + in: :query, + description: "Map identifier (UUID) - Either map_id or slug must be provided", + type: :string, + required: false, + example: "" + ], + slug: [ + in: :query, + description: "Map slug - Either map_id or slug must be provided", + type: :string, + required: false, + example: "map-name" + ] + ], + responses: [ + ok: { + "Map system details", + "application/json", + @show_map_system_response_schema + }, + bad_request: {"Error", "application/json", %OpenApiSpex.Schema{ + type: :object, + properties: %{ + error: %OpenApiSpex.Schema{type: :string} + }, + required: ["error"], + example: %{ + "error" => "Must provide either ?map_id=UUID or ?slug=SLUG as a query parameter" + } + }}, + not_found: {"Error", "application/json", %OpenApiSpex.Schema{ + type: :object, + properties: %{ + error: %OpenApiSpex.Schema{type: :string} + }, + required: ["error"], + example: %{ + "error" => "System not found" + } + }} + ] def show_system(conn, params) do with {:ok, solar_system_str} <- Util.require_param(params, "id"), {:ok, solar_system_id} <- Util.parse_int(solar_system_str), @@ -90,6 +343,43 @@ defmodule WandererAppWeb.MapAPIController do Returns a list of tracked records, plus their fully-loaded `character` data. """ + @spec tracked_characters_with_info(Plug.Conn.t(), map()) :: Plug.Conn.t() + operation :tracked_characters_with_info, + summary: "List Tracked Characters with Info", + description: "Lists all tracked characters for a map with their information. Requires either 'map_id' or 'slug' as a query parameter to identify the map.", + parameters: [ + map_id: [ + in: :query, + description: "Map identifier (UUID) - Either map_id or slug must be provided", + type: :string, + required: false, + example: "" + ], + slug: [ + in: :query, + description: "Map slug - Either map_id or slug must be provided", + type: :string, + required: false, + example: "map-name" + ] + ], + responses: [ + ok: { + "List of tracked characters", + "application/json", + @tracked_characters_response_schema + }, + bad_request: {"Error", "application/json", %OpenApiSpex.Schema{ + type: :object, + properties: %{ + error: %OpenApiSpex.Schema{type: :string} + }, + required: ["error"], + example: %{ + "error" => "Must provide either ?map_id=UUID or ?slug=SLUG" + } + }} + ] def tracked_characters_with_info(conn, params) do with {:ok, map_id} <- Util.fetch_map_id(params), {:ok, settings_list} <- get_tracked_by_map_ids(map_id), @@ -151,6 +441,50 @@ defmodule WandererAppWeb.MapAPIController do GET /api/map/structure_timers?map_id=&system_id=31002229 ``` """ + @spec show_structure_timers(Plug.Conn.t(), map()) :: Plug.Conn.t() + operation :show_structure_timers, + summary: "Show Structure Timers", + description: "Retrieves structure timers for a map. Requires either 'map_id' or 'slug' as a query parameter to identify the map.", + parameters: [ + map_id: [ + in: :query, + description: "Map identifier (UUID) - Either map_id or slug must be provided", + type: :string, + required: false, + example: "" + ], + slug: [ + in: :query, + description: "Map slug - Either map_id or slug must be provided", + type: :string, + required: false, + example: "map-name" + ], + system_id: [ + in: :query, + description: "System ID", + type: :string, + required: false, + example: "30000142" + ] + ], + responses: [ + ok: { + "Structure timers", + "application/json", + @structure_timers_response_schema + }, + bad_request: {"Error", "application/json", %OpenApiSpex.Schema{ + type: :object, + properties: %{ + error: %OpenApiSpex.Schema{type: :string} + }, + required: ["error"], + example: %{ + "error" => "Must provide either ?map_id=UUID or ?slug=SLUG as a query parameter" + } + }} + ] def show_structure_timers(conn, params) do with {:ok, map_id} <- Util.fetch_map_id(params) do system_id_str = params["system_id"] @@ -191,6 +525,50 @@ defmodule WandererAppWeb.MapAPIController do GET /api/map/systems_kills?slug= GET /api/map/systems_kills?map_id=&hours_ago= """ + @spec list_systems_kills(Plug.Conn.t(), map()) :: Plug.Conn.t() + operation :list_systems_kills, + summary: "List Systems Kills", + description: "Returns kills data for all visible systems on the map, optionally filtered by hours_ago. Requires either 'map_id' or 'slug' as a query parameter to identify the map.", + parameters: [ + map_id: [ + in: :query, + description: "Map identifier (UUID) - Either map_id or slug must be provided", + type: :string, + required: false, + example: "" + ], + slug: [ + in: :query, + description: "Map slug - Either map_id or slug must be provided", + type: :string, + required: false, + example: "map-name" + ], + hours: [ + in: :query, + description: "Number of hours to look back for kills", + type: :string, + required: false, + example: "24" + ] + ], + responses: [ + ok: { + "Systems kills data", + "application/json", + @systems_kills_response_schema + }, + bad_request: {"Error", "application/json", %OpenApiSpex.Schema{ + type: :object, + properties: %{ + error: %OpenApiSpex.Schema{type: :string} + }, + required: ["error"], + example: %{ + "error" => "Must provide either ?map_id=UUID or ?slug=SLUG as a query parameter" + } + }} + ] def list_systems_kills(conn, params) do with {:ok, map_id} <- Util.fetch_map_id(params), # fetch visible systems from the repo @@ -245,15 +623,6 @@ defmodule WandererAppWeb.MapAPIController do end end - @doc """ - GET /api/map/systems-kills - - This is an alias for list_systems_kills to support the hyphenated URL format. - See list_systems_kills for full documentation. - """ - def list_systems_kills_hyphenated(conn, params) do - list_systems_kills(conn, params) - end # If hours_str is present and valid, parse it. Otherwise return nil (no filter). defp parse_hours_ago(nil), do: nil @@ -409,11 +778,14 @@ defmodule WandererAppWeb.MapAPIController do end defp map_system_to_json(system) do - Map.take(system, [ + # Get the original system name from the database + original_name = get_original_system_name(system.solar_system_id) + + # Start with the basic system data + result = Map.take(system, [ :id, :map_id, :solar_system_id, - :name, :custom_name, :temporary_name, :description, @@ -427,6 +799,35 @@ defmodule WandererAppWeb.MapAPIController do :inserted_at, :updated_at ]) + + # Add the original name + result = Map.put(result, :original_name, original_name) + + # Set the name field based on the display priority: + # 1. If temporary_name is set, use that + # 2. If custom_name is set, use that + # 3. Otherwise, use the original system name + display_name = cond do + not is_nil(system.temporary_name) and system.temporary_name != "" -> + system.temporary_name + not is_nil(system.custom_name) and system.custom_name != "" -> + system.custom_name + true -> + original_name + end + + # Add the display name as the "name" field + Map.put(result, :name, display_name) + end + + defp get_original_system_name(solar_system_id) do + # Fetch the original system name from the MapSolarSystem resource + case WandererApp.Api.MapSolarSystem.by_solar_system_id(solar_system_id) do + {:ok, system} -> + system.solar_system_name + _error -> + "Unknown System" + end end defp character_to_json(ch) do diff --git a/lib/wanderer_app_web/controllers/plugs/check_map_api_key.ex b/lib/wanderer_app_web/controllers/plugs/check_map_api_key.ex index e1cc14eb..711a7896 100644 --- a/lib/wanderer_app_web/controllers/plugs/check_map_api_key.ex +++ b/lib/wanderer_app_web/controllers/plugs/check_map_api_key.ex @@ -1,7 +1,7 @@ defmodule WandererAppWeb.Plugs.CheckMapApiKey do @moduledoc """ A plug that checks the "Authorization: Bearer " header - against the map’s stored public_api_key. Halts with 401 if invalid. + against the map's stored public_api_key. Halts with 401 if invalid. """ import Plug.Conn @@ -20,19 +20,22 @@ defmodule WandererAppWeb.Plugs.CheckMapApiKey do conn else conn - |> send_resp(401, "Unauthorized (invalid token for map)") + |> put_resp_content_type("application/json") + |> send_resp(401, Jason.encode!(%{error: "Unauthorized (invalid token for map)"})) |> halt() end {:error, _reason} -> conn - |> send_resp(404, "Map not found") + |> put_resp_content_type("application/json") + |> send_resp(404, Jason.encode!(%{error: "Map not found"})) |> halt() end _ -> conn - |> send_resp(401, "Missing or invalid 'Bearer' token") + |> put_resp_content_type("application/json") + |> send_resp(401, Jason.encode!(%{error: "Missing or invalid 'Bearer' token"})) |> halt() end end diff --git a/lib/wanderer_app_web/router.ex b/lib/wanderer_app_web/router.ex index f4a3856b..7c021f31 100644 --- a/lib/wanderer_app_web/router.ex +++ b/lib/wanderer_app_web/router.ex @@ -18,23 +18,78 @@ defmodule WandererAppWeb.Router do [WandererAppWeb.Endpoint, :code_reloader], false ) - @frame_src if(@code_reloading, do: ~w('self'), else: ~w()) - @style_src ~w('self' 'unsafe-inline' https://fonts.googleapis.com) - @img_src ~w('self' data: https://images.evetech.net https://web.ccpgamescdn.com https://images.ctfassets.net https://w.appzi.io) - @font_src ~w('self' https://fonts.gstatic.com data: https://web.ccpgamescdn.com https://w.appzi.io ) - @script_src ~w('self' ) + @frame_src_values if(@code_reloading, do: ["'self'"], else: []) + + # Define style sources individually to ensure proper spacing + @style_src_values [ + "'self'", + "'unsafe-inline'", + "https://fonts.googleapis.com", + "https://cdn.jsdelivr.net/npm/", + "https://cdnjs.cloudflare.com/ajax/libs/" + ] + + # Define image sources individually to ensure proper spacing + @img_src_values [ + "'self'", + "data:", + "https://images.evetech.net", + "https://web.ccpgamescdn.com", + "https://images.ctfassets.net", + "https://w.appzi.io" + ] + + # Define font sources individually to ensure proper spacing + @font_src_values [ + "'self'", + "https://fonts.gstatic.com", + "data:", + "https://web.ccpgamescdn.com", + "https://w.appzi.io" + ] + + # Define script sources individually to ensure proper spacing + @script_src_values [ + "'self'", + "'unsafe-inline'", + "https://cdn.jsdelivr.net/npm/", + "https://cdnjs.cloudflare.com/ajax/libs/", + "https://unpkg.com", + "https://cdn.jsdelivr.net", + "https://w.appzi.io", + "https://www.googletagmanager.com", + "https://cdnjs.cloudflare.com" + ] + + # Define connect sources individually to ensure proper spacing + @connect_src_values [ + "'self'", + "https://api.appzi.io", + "https://www.googletagmanager.com", + "https://www.google-analytics.com" + ] + + # Define sandbox values individually to ensure proper spacing + @sandbox_values [ + "allow-forms", + "allow-scripts", + "allow-modals", + "allow-same-origin", + "allow-downloads", + "allow-popups" + ] pipeline :admin_bauth do plug :admin_basic_auth end pipeline :browser do - plug(:accepts, ["html"]) - plug(:fetch_session) - plug(:fetch_live_flash) - plug(:put_root_layout, html: {WandererAppWeb.Layouts, :root}) - plug(:protect_from_forgery) - plug(:put_secure_browser_headers) + plug :accepts, ["html"] + plug :fetch_session + plug :fetch_live_flash + plug :put_root_layout, html: {WandererAppWeb.Layouts, :root} + plug :protect_from_forgery + plug :put_secure_browser_headers dynamic_plug PlugContentSecurityPolicy, reevaluate: :first_usage do URI.default_port("wss", 443) @@ -51,41 +106,37 @@ defmodule WandererAppWeb.Router do |> Map.put(:path, "") |> URI.to_string() + # Get the HTTP URL from home_url + http_url = URI.to_string(home_url) + + # Only add script-src-elem when in development mode + script_src_elem = if(@code_reloading, do: + @script_src_values ++ [ws_url, http_url], + else: @script_src_values) + directives = %{ default_src: ~w('none'), - script_src: [ - @script_src, - ~w('unsafe-inline'), - ~w(https://unpkg.com), - ~w(https://cdn.jsdelivr.net), - ~w(https://w.appzi.io), - ~w(https://www.googletagmanager.com), - ~w(https://cdnjs.cloudflare.com) - ], - style_src: @style_src, - img_src: @img_src, - font_src: @font_src, - connect_src: [ - ws_url, - ~w('self'), - ~w(https://api.appzi.io), - ~w(https://www.googletagmanager.com), - ~w(https://www.google-analytics.com) - ], + script_src: @script_src_values ++ [ws_url], + style_src: @style_src_values, + img_src: @img_src_values, + font_src: @font_src_values, + connect_src: @connect_src_values ++ [ws_url], media_src: ~w('none'), object_src: ~w('none'), child_src: ~w('none'), - frame_src: [@frame_src], + frame_src: @frame_src_values, worker_src: ~w('none'), frame_ancestors: ~w('none'), form_action: ~w('self'), block_all_mixed_content: ~w(), - sandbox: - ~w(allow-forms allow-scripts allow-modals allow-same-origin allow-downloads allow-popups), + sandbox: @sandbox_values, base_uri: ~w('none'), manifest_src: ~w('self') } + # Only add script-src-elem to directives when in development mode + directives = Map.put(directives, :script_src_elem, script_src_elem) + directives = case home_url do %URI{scheme: "http"} -> directives @@ -101,11 +152,11 @@ defmodule WandererAppWeb.Router do end pipeline :blog do - plug(:put_layout, html: {WandererAppWeb.Layouts, :blog}) + plug :put_layout, html: {WandererAppWeb.Layouts, :blog} end pipeline :api do - plug(:accepts, ["json"]) + plug :accepts, ["json"] plug WandererAppWeb.Plugs.CheckApiDisabled end @@ -126,6 +177,12 @@ defmodule WandererAppWeb.Router do plug WandererAppWeb.Plugs.CheckAclApiKey end + pipeline :api_spec do + plug OpenApiSpex.Plug.PutApiSpec, + otp_app: :wanderer_app, + module: WandererAppWeb.ApiSpec + end + scope "/api/map/systems-kills", WandererAppWeb do pipe_through [:api, :api_map, :api_kills] @@ -162,6 +219,11 @@ defmodule WandererAppWeb.Router do get "/system-static-info", CommonAPIController, :show_system_static end + scope "/api" do + pipe_through [:browser, :api, :api_spec] + get "/openapi", OpenApiSpex.Plug.RenderSpec, :show + end + # # Browser / blog stuff # @@ -191,6 +253,30 @@ defmodule WandererAppWeb.Router do get "/", BlogController, :license end + scope "/swaggerui" do + pipe_through [:browser, :api, :api_spec] + + get "/", OpenApiSpex.Plug.SwaggerUI, + path: "/api/openapi", + title: "WandererApp API Docs", + css_urls: [ + # Standard Swagger UI CSS + "https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.5.0/swagger-ui.min.css", + # Material theme from swagger-ui-themes (v3.x): + "https://cdn.jsdelivr.net/npm/swagger-ui-themes@3.0.0/themes/3.x/theme-material.css" + ], + js_urls: [ + # We need both main JS & standalone preset for full styling + "https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.5.0/swagger-ui-bundle.min.js", + "https://cdnjs.cloudflare.com/ajax/libs/swagger-ui/4.5.0/swagger-ui-standalone-preset.min.js" + ], + favicon_url: "https://example.com/my_favicon.ico", + swagger_ui_config: %{ + "docExpansion" => "none", + "deepLinking" => true + } + end + # # Auth # diff --git a/mix.exs b/mix.exs index fdc9f3ad..1e1b1abb 100644 --- a/mix.exs +++ b/mix.exs @@ -54,6 +54,7 @@ defmodule WandererApp.MixProject do {:sobelow, ">= 0.0.0", only: [:dev], runtime: false}, {:mix_audit, ">= 0.0.0", only: [:dev], runtime: false}, {:ex_check, "~> 0.14.0", only: [:dev], runtime: false}, + {:open_api_spex, github: "mbuhot/open_api_spex", branch: "master"}, {:ex_rated, "~> 2.0"}, {:retry, "~> 0.18.0"}, {:phoenix, "~> 1.7.12"}, diff --git a/mix.lock b/mix.lock index eded1995..9472b5f5 100644 --- a/mix.lock +++ b/mix.lock @@ -78,6 +78,7 @@ "nimble_publisher": {:hex, :nimble_publisher, "1.1.0", "49dee0f30536140268996660a5927d0282946949c35c88ccc6da11a19231b4b6", [:mix], [{:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "80fb42d8d1e34f41ff29fc2a1ae6ab86ea7b764b3c2d38e5268a43cf33825782"}, "oauth2": {:hex, :oauth2, "2.1.0", "beb657f393814a3a7a8a15bd5e5776ecae341fd344df425342a3b6f1904c2989", [:mix], [{:tesla, "~> 1.5", [hex: :tesla, repo: "hexpm", optional: false]}], "hexpm", "8ac07f85b3307dd1acfeb0ec852f64161b22f57d0ce0c15e616a1dfc8ebe2b41"}, "octo_fetch": {:hex, :octo_fetch, "0.4.0", "074b5ecbc08be10b05b27e9db08bc20a3060142769436242702931c418695b19", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "cf8be6f40cd519d7000bb4e84adcf661c32e59369ca2827c4e20042eda7a7fc6"}, + "open_api_spex": {:git, "https://github.com/mbuhot/open_api_spex.git", "abe90e3db0cab2e75ede364ee24f26c9e490f74f", [branch: "master"]}, "owl": {:hex, :owl, "0.11.0", "2cd46185d330aa2400f1c8c3cddf8d2ff6320baeff23321d1810e58127082cae", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "73f5783f0e963cc04a061be717a0dbb3e49ae0c4bfd55fb4b78ece8d33a65efe"}, "parent": {:hex, :parent, "0.12.1", "495c4386f06de0df492e0a7a7199c10323a55e9e933b27222060dd86dccd6d62", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2ab589ef1f37bfcedbfb5ecfbab93354972fb7391201b8907a866dadd20b39d1"}, "pathex": {:hex, :pathex, "2.5.3", "0f2674c7cb52ae9220766cae2653b4013578349ae5ec07cb0c31b92684b3f19a", [:mix], [], "hexpm", "767aefc27d0303f583ba2064f0a49546067ab5de3c42b89f014a0ba32ea04830"}, @@ -103,6 +104,7 @@ "quantum": {:hex, :quantum, "3.5.3", "ee38838a07761663468145f489ad93e16a79440bebd7c0f90dc1ec9850776d99", [:mix], [{:crontab, "~> 1.1", [hex: :crontab, repo: "hexpm", optional: false]}, {:gen_stage, "~> 0.14 or ~> 1.0", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_registry, "~> 0.2", [hex: :telemetry_registry, repo: "hexpm", optional: false]}], "hexpm", "500fd3fa77dcd723ed9f766d4a175b684919ff7b6b8cfd9d7d0564d58eba8734"}, "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, "reactor": {:hex, :reactor, "0.10.0", "1206113c21ba69b889e072b2c189c05a7aced523b9c3cb8dbe2dab7062cb699a", [:mix], [{:igniter, "~> 0.2", [hex: :igniter, repo: "hexpm", optional: false]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4003c33e4c8b10b38897badea395e404d74d59a31beb30469a220f2b1ffe6457"}, + "redoc_ui_plug": {:hex, :redoc_ui_plug, "0.2.1", "5e9760c17ed450fc9df671d5fbc70a6f06179c41d9d04ae3c33f16baca3a5b19", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "7be01db31f210887e9fc18f8fbccc7788de32c482b204623556e415ed1fe714b"}, "req": {:hex, :req, "0.4.14", "103de133a076a31044e5458e0f850d5681eef23dfabf3ea34af63212e3b902e2", [:mix], [{:aws_signature, "~> 0.3.2", [hex: :aws_signature, repo: "hexpm", optional: true]}, {:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:nimble_ownership, "~> 0.2.0 or ~> 0.3.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "2ddd3d33f9ab714ced8d3c15fd03db40c14dbf129003c4a3eb80fac2cc0b1b08"}, "retry": {:hex, :retry, "0.18.0", "dc58ebe22c95aa00bc2459f9e0c5400e6005541cf8539925af0aa027dc860543", [:mix], [], "hexpm", "9483959cc7bf69c9e576d9dfb2b678b71c045d3e6f39ab7c9aa1489df4492d73"}, "rewrite": {:hex, :rewrite, "0.10.5", "6afadeae0b9d843b27ac6225e88e165884875e0aed333ef4ad3bf36f9c101bed", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "51cc347a4269ad3a1e7a2c4122dbac9198302b082f5615964358b4635ebf3d4f"}, diff --git a/priv/posts/2025/02-20-acl-api.md b/priv/posts/2025/02-20-acl-api.md index bc98c72a..cdb3e576 100644 --- a/priv/posts/2025/02-20-acl-api.md +++ b/priv/posts/2025/02-20-acl-api.md @@ -10,7 +10,7 @@ ## Introduction -Wanderer’s expanded public API now lets you retrieve **all characters** in the system and manage “Access Lists” (ACLs) for controlling visibility or permissions. These endpoints allow you to: +Wanderer's expanded public API now lets you retrieve **all characters** in the system and manage "Access Lists" (ACLs) for controlling visibility or permissions. These endpoints allow you to: - Fetch a list of **all** EVE characters known to the system. - List ACLs for a given map. @@ -30,8 +30,8 @@ Unless otherwise noted, these endpoints require a valid **Bearer** token. Pass i Authorization: Bearer ``` -If the token is missing or invalid, you’ll receive a `401 Unauthorized` error. -_(No API key is required for some “common” endpoints, but ACL- and character-related endpoints require a valid token.)_ +If the token is missing or invalid, you'll receive a `401 Unauthorized` error. +_(No API key is required for some "common" endpoints, but ACL- and character-related endpoints require a valid token.)_ There are two types of tokens in use: @@ -152,17 +152,35 @@ curl -H "Authorization: Bearer " \ "members": [ { "id": "8d63ab1e-b44f-4e81-8227-8fb8d928dad8", - "name": "Other Character", + "name": "Character Name", "role": "admin", + "eve_character_id": "2122019111", "inserted_at": "2025-02-13T03:33:32.332598Z", "updated_at": "2025-02-13T03:33:36.644520Z" }, - ... + { + "id": "7e52ab1e-c33f-5e81-9338-7fb8d928ebc9", + "name": "Corporation Name", + "role": "viewer", + "eve_corporation_id": "98140648", + "inserted_at": "2025-02-13T03:33:32.332598Z", + "updated_at": "2025-02-13T03:33:36.644520Z" + }, + { + "id": "6f41bc2f-d44e-6f92-8449-8ec9e039fad7", + "name": "Alliance Name", + "role": "viewer", + "eve_alliance_id": "99013806", + "inserted_at": "2025-02-13T03:33:32.332598Z", + "updated_at": "2025-02-13T03:33:36.644520Z" + } ] } } ``` +**Note:** The response for each member will include only one of `eve_character_id`, `eve_corporation_id`, or `eve_alliance_id` depending on the type of member. + --- ### 4. Create a New ACL Associated with a Map @@ -295,14 +313,13 @@ POST /api/acls/:acl_id/members ```json { "member": { - "name": "New Member", "eve_character_id": "EXTERNAL_EVE_ID", "role": "viewer" } } ``` -- **Example Request:** +- **Example Request for Character:** ```bash curl -X POST \ @@ -310,7 +327,6 @@ curl -X POST \ -H "Content-Type: application/json" \ -d '{ "member": { - "name": "New Member", "eve_character_id": "EXTERNAL_EVE_ID", "role": "viewer" } @@ -318,14 +334,45 @@ curl -X POST \ "https://wanderer.example.com/api/acls/ACL_UUID/members" ``` -- **Example Response (redacted):** +- **Example Request for Corporation:** + +```bash +curl -X POST \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "member": { + "eve_corporation_id": "CORPORATION_ID", + "role": "viewer" + } + }' \ + "https://wanderer.example.com/api/acls/ACL_UUID/members" +``` + +- **Example Response for Character (redacted):** ```json { "data": { "id": "MEMBERSHIP_UUID", - "name": "New Member", + "name": "Character Name", "role": "viewer", + "eve_character_id": "EXTERNAL_EVE_ID", + "inserted_at": "...", + "updated_at": "..." + } +} +``` + +- **Example Response for Corporation (redacted):** + +```json +{ + "data": { + "id": "MEMBERSHIP_UUID", + "name": "Corporation Name", + "role": "viewer", + "eve_corporation_id": "CORPORATION_ID", "inserted_at": "...", "updated_at": "..." } @@ -334,13 +381,13 @@ curl -X POST \ --- -### 7. Change a Member’s Role +### 7. Change a Member's Role ```bash PUT /api/acls/:acl_id/members/:member_id ``` -- **Description:** Updates an ACL member’s role (e.g. from `viewer` to `admin`). +- **Description:** Updates an ACL member's role (e.g. from `viewer` to `admin`). The `:member_id` is the external EVE id (or corp/alliance id) used when creating the membership. - **Authentication:** Requires the ACL API Token. - **Request Body Example:** @@ -373,13 +420,17 @@ curl -X PUT \ { "data": { "id": "MEMBERSHIP_UUID", - "name": "New Member", + "name": "Character Name", "role": "admin", - ... + "eve_character_id": "EXTERNAL_EVE_ID", + "inserted_at": "...", + "updated_at": "..." } } ``` +**Note:** The response will include only one of `eve_character_id`, `eve_corporation_id`, or `eve_alliance_id` depending on the type of member. + --- ### 8. Remove a Member from an ACL @@ -416,7 +467,7 @@ This guide outlines how to: 4. **Create** a new ACL for a map (`POST /api/map/acls`), which generates a new ACL API key. 5. **Update** an existing ACL (`PUT /api/acls/:id`). 6. **Add** members (characters, corporations, alliances) to an ACL (`POST /api/acls/:acl_id/members`). -7. **Change** a member’s role (`PUT /api/acls/:acl_id/members/:member_id`). +7. **Change** a member's role (`PUT /api/acls/:acl_id/members/:member_id`). 8. **Remove** a member from an ACL (`DELETE /api/acls/:acl_id/members/:member_id`). By following these request patterns, you can manage your ACL resources in a fully programmatic fashion. If you have any questions, feel free to reach out to the Wanderer Team. diff --git a/priv/posts/2025/03-05-api.md b/priv/posts/2025/03-05-api.md new file mode 100644 index 00000000..2464bcdd --- /dev/null +++ b/priv/posts/2025/03-05-api.md @@ -0,0 +1,837 @@ +%{ + title: "Comprehensive Guide: Wanderer API Documentation", + author: "Wanderer Team", + cover_image_uri: "/images/news/03-05-api/swagger-ui.png", + tags: ~w(api map acl characters documentation swagger), + description: "Complete documentation for Wanderer's public APIs, including map data, character information, and access control management. Includes interactive API documentation with Swagger UI." +} + +--- + +# Comprehensive Guide to Wanderer's API + +## Introduction + +Wanderer provides a comprehensive set of public APIs that allow you to programmatically interact with the platform. This guide consolidates all available API endpoints, authentication methods, and includes interactive documentation options. + +With these APIs, you can: + +- Retrieve map data, including systems and their properties +- Access system static information +- Track character locations and activities +- Monitor kill activity in systems +- Manage Access Control Lists (ACLs) for permissions +- Add, update, and remove ACL members + +This guide provides step-by-step instructions, request/response examples, and details on how to authenticate each call. + +--- + +## Interactive API Documentation + +For a more interactive experience, Wanderer provides a way to explore the API: + +### Swagger UI + +Access our Swagger UI documentation at: + +``` +/swaggerui +``` + +This interactive interface allows you to: +- Browse all available endpoints +- See request parameters and response schemas +- Test API calls directly from your browser +- View authentication requirements + +![Swagger UI](/images/news/03-04-api/swagger-ui.png "Swagger UI Documentation") + +--- + +## Authentication + +Wanderer uses Bearer token authentication for API access. There are two types of tokens in use: + +1. **Map API Token:** Available in the map settings. This token is used for map-specific endpoints. + + ![Generate Map API Key](/images/news/01-05-map-public-api/generate-key.png "Generate Map API Key") + +2. **ACL API Token:** Available in the create/edit ACL screen. This token is used for ACL member management endpoints. + + ![Generate ACL API Key](/images/news/02-20-acl-api/generate-key.png "Generate ACL API Key") + +Pass the appropriate token in the `Authorization` header: + +```bash +Authorization: Bearer +``` + +If the token is missing or invalid, you'll receive a `401 Unauthorized` error. + +**Note:** Some "common" endpoints (like system static information) don't require authentication. + +--- + +## Map Data Endpoints + +### 1. List Systems + +```bash +GET /api/map/systems?map_id= +GET /api/map/systems?slug= +``` + +- **Description:** Retrieves a list of systems associated with the specified map. +- **Authentication:** Requires Map API Token. +- **Parameters:** + - `map_id` (optional if `slug` is provided) — the UUID of the map. + - `slug` (optional if `map_id` is provided) — the slug identifier of the map. + - `all=true` (optional) — if set, returns _all_ systems instead of only "visible" systems. + +#### Example Request + +```bash +curl -H "Authorization: Bearer " \ + "https://wanderer.example.com/api/map/systems?slug=some-slug" +``` + +#### Example Response + +```json +{ + "data": [ + { + "id": "", + "name": "", + "status": 0, + "tag": null, + "visible": false, + "description": null, + "labels": "", + "inserted_at": "2025-01-01T13:38:42.875843Z", + "updated_at": "2025-01-01T13:40:16.750234Z", + "locked": false, + "solar_system_id": "", + "map_id": "", + "custom_name": null, + "position_x": 1125, + "position_y": -285 + }, + ... + ] +} +``` + +### 2. Show Single System + +```bash +GET /api/map/system?id=&map_id= +GET /api/map/system?id=&slug= +``` + +- **Description:** Retrieves information for a specific system on the specified map. +- **Authentication:** Requires Map API Token. +- **Parameters:** + - `id` (required) — the `solar_system_id`. + - Either `map_id` or `slug` (required). + +#### Example Request + +```bash +curl -H "Authorization: Bearer " \ + "https://wanderer.example.com/api/map/system?id=&slug=" +``` + +#### Example Response + +```json +{ + "data": { + "id": "", + "name": "", + "status": 0, + "tag": null, + "visible": false, + "description": null, + "labels": "", + "inserted_at": "2025-01-03T06:30:02.069090Z", + "updated_at": "2025-01-03T07:47:07.471051Z", + "locked": false, + "solar_system_id": "", + "map_id": "", + "custom_name": null, + "position_x": 1005, + "position_y": 765 + } +} +``` + +### 3. System Static Information + +```bash +GET /api/common/system-static-info?id= +``` + +- **Description:** Retrieves the static information for a specific system. +- **Authentication:** No authentication required. +- **Parameters:** + - `id` (required) — the `solar_system_id`. + +#### Example Request + +```bash +curl "https://wanderer.example.com/api/common/system-static-info?id=31002229" +``` + +#### Example Response + +```json +{ + "data": { + "solar_system_id": 31002229, + "triglavian_invasion_status": "Normal", + "solar_system_name": "J132946", + "system_class": 5, + "region_id": 11000028, + "constellation_id": 21000278, + "solar_system_name_lc": "j132946", + "constellation_name": "E-C00278", + "region_name": "E-R00028", + "security": "-1.0", + "type_description": "Class 5", + "class_title": "C5", + "is_shattered": false, + "effect_name": null, + "effect_power": 5, + "statics": [ + "H296" + ], + "wandering": [ + "D792", + "C140", + "Z142" + ], + "sun_type_id": 38 + } +} +``` + +### 4. List Tracked Characters + +```bash +GET /api/map/characters?map_id= +GET /api/map/characters?slug= +``` + +- **Description:** Retrieves a list of tracked characters for the specified map. +- **Authentication:** Requires Map API Token. +- **Parameters:** + - `map_id` (optional if `slug` is provided) — the UUID of the map. + - `slug` (optional if `map_id` is provided) — the slug identifier of the map. + +#### Example Request + +```bash +curl -H "Authorization: Bearer " \ + "https://wanderer.example.com/api/map/characters?slug=some-slug" +``` + +#### Example Response + +```json +{ + "data": [ + { + "id": "", + "character": { + "id": "", + "name": "", + "inserted_at": "2025-01-01T05:24:18.461721Z", + "updated_at": "2025-01-03T07:45:52.294052Z", + "alliance_id": "", + "alliance_name": "", + "alliance_ticker": "", + "corporation_id": "", + "corporation_name": "", + "corporation_ticker": "", + "eve_id": "" + }, + "tracked": true, + "map_id": "" + }, + ... + ] +} +``` + +### 5. Kills Activity + +```bash +GET /api/map/systems-kills?map_id= +GET /api/map/systems-kills?slug= +``` + +- **Description:** Retrieves the kill activity for the specified map. +- **Authentication:** Requires Map API Token. +- **Parameters:** + - `map_id` (optional if `slug` is provided) — the UUID of the map. + - `slug` (optional if `map_id` is provided) — the slug identifier of the map. + +#### Example Request + +```bash +curl -H "Authorization: Bearer " \ + "https://wanderer.example.com/api/map/systems-kills?slug=some-slug" +``` + +#### Example Response + +```json +{ + "data": [ + { + "kills": [ + { + "attacker_count": 1, + "final_blow_alliance_id": 99013806, + "final_blow_alliance_ticker": "TCE", + "final_blow_char_id": 2116802670, + "final_blow_char_name": "Bambi Bunny", + "final_blow_corp_id": 98140648, + "final_blow_corp_ticker": "GNK3D", + "final_blow_ship_name": "Thrasher", + "final_blow_ship_type_id": 16242, + "kill_time": "2025-01-21T21:00:59Z", + "killmail_id": 124181782, + "npc": false, + "solar_system_id": 30002768, + "total_value": 10000, + "victim_alliance_id": null, + "victim_char_id": 2121725410, + "victim_char_name": "Bill Drummond", + "victim_corp_id": 98753095, + "victim_corp_ticker": "KSTJK", + "victim_ship_name": "Capsule", + "victim_ship_type_id": 670, + "zkb": { + "awox": false, + "destroyedValue": 10000, + "droppedValue": 0, + "fittedValue": 10000, + "hash": "777148f8bf344bade68a6a0821bfe0a37491a7a6", + "labels": ["cat:6","#:1","pvp","loc:highsec"], + "locationID": 50014064, + "npc": false, + "points": 1, + "solo": false, + "totalValue": 10000 + } + }, + ... + ], + "solar_system_id": 30002768 + }, + ... + ] +} +``` + +### 6. Structure Timers + +```bash +GET /api/map/structure-timers?map_id= +GET /api/map/structure-timers?slug= +``` + +- **Description:** Retrieves structure timers for the specified map. +- **Authentication:** Requires Map API Token. +- **Parameters:** + - `map_id` (optional if `slug` is provided) — the UUID of the map. + - `slug` (optional if `map_id` is provided) — the slug identifier of the map. + +--- + +## Character and ACL Endpoints + +### 1. List All Characters + +```bash +GET /api/characters +``` + +- **Description:** Returns a list of all characters known to Wanderer. +- **Authentication:** Requires a valid API token. +- **Toggle:** Controlled by the environment variable `WANDERER_CHARACTER_API_DISABLED` (default is `false`). + +#### Example Request + +```bash +curl -H "Authorization: Bearer " \ + "https://wanderer.example.com/api/characters" +``` + +#### Example Response + +```json +{ + "data": [ + { + "id": "b374d9e6-47a7-4e20-85ad-d608809827b5", + "name": "Some Character", + "eve_id": "2122825111", + "corporation_name": "School of Applied Knowledge", + "alliance_name": null + }, + { + "id": "6963bee6-eaa1-40e2-8200-4bc2fcbd7350", + "name": "Other Character", + "eve_id": "2122019111", + "corporation_name": "Some Corporation", + "alliance_name": null + }, + ... + ] +} +``` + +Use the `eve_id` when referencing a character in ACL operations. + +### 2. List ACLs for a Map + +```bash +GET /api/map/acls?map_id= +GET /api/map/acls?slug= +``` + +- **Description:** Lists all ACLs associated with a map. +- **Authentication:** Requires Map API Token. +- **Parameters:** + - `map_id` (optional if `slug` is provided) — the UUID of the map. + - `slug` (optional if `map_id` is provided) — the slug identifier of the map. + +#### Example Request + +```bash +curl -H "Authorization: Bearer " \ + "https://wanderer.example.com/api/map/acls?slug=mapname" +``` + +#### Example Response + +```json +{ + "data": [ + { + "id": "19712899-ec3a-47b1-b73b-2bae221c5513", + "name": "aclName", + "description": null, + "owner_eve_id": "11111111111", + "inserted_at": "2025-02-13T03:32:25.144403Z", + "updated_at": "2025-02-13T03:32:25.144403Z" + } + ] +} +``` + +### 3. Show a Specific ACL + +```bash +GET /api/acls/:id +``` + +- **Description:** Fetches a single ACL by ID, with its members preloaded. +- **Authentication:** Requires ACL API Token. +- **Parameters:** + - `id` (required) — the ACL ID. + +#### Example Request + +```bash +curl -H "Authorization: Bearer " \ + "https://wanderer.example.com/api/acls/19712899-ec3a-47b1-b73b-2bae221c5513" +``` + +#### Example Response + +```json +{ + "data": { + "id": "19712899-ec3a-47b1-b73b-2bae221c5513", + "name": "aclName", + "description": null, + "owner_id": "d43a9083-2705-40c9-a314-f7f412346661", + "api_key": "REDACTED_API_KEY", + "inserted_at": "2025-02-13T03:32:25.144403Z", + "updated_at": "2025-02-13T03:32:25.144403Z", + "members": [ + { + "id": "8d63ab1e-b44f-4e81-8227-8fb8d928dad8", + "name": "Character Name", + "role": "admin", + "eve_character_id": "2122019111", + "inserted_at": "2025-02-13T03:33:32.332598Z", + "updated_at": "2025-02-13T03:33:36.644520Z" + }, + { + "id": "7e52ab1e-c33f-5e81-9338-7fb8d928ebc9", + "name": "Corporation Name", + "role": "viewer", + "eve_corporation_id": "98140648", + "inserted_at": "2025-02-13T03:33:32.332598Z", + "updated_at": "2025-02-13T03:33:36.644520Z" + }, + { + "id": "6f41bc2f-d44e-6f92-8449-8ec9e039fad7", + "name": "Alliance Name", + "role": "viewer", + "eve_alliance_id": "99013806", + "inserted_at": "2025-02-13T03:33:32.332598Z", + "updated_at": "2025-02-13T03:33:36.644520Z" + } + ] + } +} +``` + +**Note:** The response for each member will include only one of `eve_character_id`, `eve_corporation_id`, or `eve_alliance_id` depending on the type of member. + +### 4. Create a New ACL + +```bash +POST /api/map/acls +``` + +- **Description:** Creates a new ACL for a map and generates a new ACL API key. +- **Authentication:** Requires Map API Token. +- **Required Query Parameter:** Either `map_id` (UUID) or `slug` (map slug). +- **Request Body Example:** + +```json +{ + "acl": { + "name": "New ACL", + "description": "Optional description", + "owner_eve_id": "EXTERNAL_EVE_ID" + } +} +``` + +- `owner_eve_id` must be the external EVE id (the `eve_id` from `/api/characters`). + +#### Example Request + +```bash +curl -X POST \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "acl": { + "name": "New ACL", + "description": "Optional description", + "owner_eve_id": "EXTERNAL_EVE_ID" + } + }' \ + "https://wanderer.example.com/api/map/acls?slug=mapname" +``` + +#### Example Response + +```json +{ + "data": { + "id": "NEW_ACL_UUID", + "name": "New ACL", + "description": "Optional description", + "owner_id": "OWNER_ID", + "api_key": "GENERATED_ACL_API_KEY", + "inserted_at": "2025-02-14T17:00:00Z", + "updated_at": "2025-02-14T17:00:00Z", + "members": [] + } +} +``` + +### 5. Update an ACL + +```bash +PUT /api/acls/:id +``` + +- **Description:** Updates an existing ACL (e.g., name, description). +- **Authentication:** Requires ACL API Token. +- **Parameters:** + - `id` (required) — the ACL ID. +- **Request Body Example:** + +```json +{ + "acl": { + "name": "Updated ACL Name", + "description": "This is the updated description" + } +} +``` + +#### Example Request + +```bash +curl -X PUT \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "acl": { + "name": "Updated ACL Name", + "description": "This is the updated description" + } + }' \ + "https://wanderer.example.com/api/acls/ACL_UUID" +``` + +#### Example Response + +```json +{ + "data": { + "id": "ACL_UUID", + "name": "Updated ACL Name", + "description": "This is the updated description", + "owner_id": "OWNER_ID", + "api_key": "ACL_API_KEY", + "inserted_at": "2025-02-14T16:49:13.423556Z", + "updated_at": "2025-02-14T17:22:51.343784Z", + "members": [] + } +} +``` + +### 6. Add a Member to an ACL + +```bash +POST /api/acls/:acl_id/members +``` + +- **Description:** Adds a new member (character, corporation, or alliance) to the specified ACL. +- **Authentication:** Requires ACL API Token. +- **Parameters:** + - `acl_id` (required) — the ACL ID. +- **Request Body Example:** + +For **character** membership: +```json +{ + "member": { + "eve_character_id": "EXTERNAL_EVE_ID", + "role": "viewer" + } +} +``` + +For **corporation** membership: +```json +{ + "member": { + "eve_corporation_id": "CORPORATION_ID", + "role": "viewer" + } +} +``` + +For **alliance** membership: +```json +{ + "member": { + "eve_alliance_id": "ALLIANCE_ID", + "role": "viewer" + } +} +``` + +#### Example Request for Character + +```bash +curl -X POST \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "member": { + "eve_character_id": "EXTERNAL_EVE_ID", + "role": "viewer" + } + }' \ + "https://wanderer.example.com/api/acls/ACL_UUID/members" +``` + +#### Example Response for Character + +```json +{ + "data": { + "id": "MEMBERSHIP_UUID", + "name": "Character Name", + "role": "viewer", + "eve_character_id": "EXTERNAL_EVE_ID", + "inserted_at": "2025-02-15T12:30:45.123456Z", + "updated_at": "2025-02-15T12:30:45.123456Z" + } +} +``` + +#### Example Request for Corporation + +```bash +curl -X POST \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "member": { + "eve_corporation_id": "CORPORATION_ID", + "role": "viewer" + } + }' \ + "https://wanderer.example.com/api/acls/ACL_UUID/members" +``` + +#### Example Response for Corporation + +```json +{ + "data": { + "id": "MEMBERSHIP_UUID", + "name": "Corporation Name", + "role": "viewer", + "eve_corporation_id": "CORPORATION_ID", + "inserted_at": "2025-02-15T12:30:45.123456Z", + "updated_at": "2025-02-15T12:30:45.123456Z" + } +} +``` + +#### Example Request for Alliance + +```bash +curl -X POST \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "member": { + "eve_alliance_id": "ALLIANCE_ID", + "role": "viewer" + } + }' \ + "https://wanderer.example.com/api/acls/ACL_UUID/members" +``` + +#### Example Response for Alliance + +```json +{ + "data": { + "id": "MEMBERSHIP_UUID", + "name": "Alliance Name", + "role": "viewer", + "eve_alliance_id": "ALLIANCE_ID", + "inserted_at": "2025-02-15T12:30:45.123456Z", + "updated_at": "2025-02-15T12:30:45.123456Z" + } +} +``` + +**Note:** The response will include only one of `eve_character_id`, `eve_corporation_id`, or `eve_alliance_id` depending on the type of member being added. + +### 7. Change a Member's Role + +```bash +PUT /api/acls/:acl_id/members/:member_id +``` + +- **Description:** Updates an ACL member's role. +- **Authentication:** Requires ACL API Token. +- **Parameters:** + - `acl_id` (required) — the ACL ID. + - `member_id` (required) — the external EVE id (or corp/alliance id) used when creating the membership. +- **Request Body Example:** + +```json +{ + "member": { + "role": "admin" + } +} +``` + +#### Example Request + +```bash +curl -X PUT \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "member": { + "role": "admin" + } + }' \ + "https://wanderer.example.com/api/acls/ACL_UUID/members/EXTERNAL_EVE_ID" +``` + +#### Example Response for Character + +```json +{ + "data": { + "id": "MEMBERSHIP_UUID", + "name": "Character Name", + "role": "admin", + "eve_character_id": "EXTERNAL_EVE_ID", + "inserted_at": "2025-02-15T12:30:45.123456Z", + "updated_at": "2025-02-15T12:35:22.654321Z" + } +} +``` + +**Note:** The response will include only one of `eve_character_id`, `eve_corporation_id`, or `eve_alliance_id` depending on the type of member being updated. + +### 8. Remove a Member from an ACL + +```bash +DELETE /api/acls/:acl_id/members/:member_id +``` + +- **Description:** Removes the member with the specified external EVE id (or corp/alliance id) from the ACL. +- **Authentication:** Requires ACL API Token. +- **Parameters:** + - `acl_id` (required) — the ACL ID. + - `member_id` (required) — the external EVE id (or corp/alliance id) used when creating the membership. + +#### Example Request + +```bash +curl -X DELETE \ + -H "Authorization: Bearer " \ + "https://wanderer.example.com/api/acls/ACL_UUID/members/EXTERNAL_EVE_ID" +``` + +#### Example Response + +```json +{ "ok": true } +``` + +--- + +## Conclusion + +This guide provides a comprehensive overview of Wanderer's API capabilities. With these endpoints, you can: + +1. **Explore the API** using interactive documentation at `/swaggerui` +2. **Retrieve map data** including systems, characters, and kill activity +3. **Access system information** with or without authentication +4. **Manage Access Control Lists (ACLs)** for permissions +5. **Add, update, and remove ACL members** with different roles + +For the most up-to-date and interactive documentation, we recommend using the Swagger UI at `/swaggerui` which allows you to explore and test endpoints directly from your browser. + +If you have any questions or need assistance with the API, please reach out to the Wanderer Team. + +Fly safe, +**WANDERER TEAM** \ No newline at end of file diff --git a/test/manual/.api_test_config b/test/manual/.api_test_config new file mode 100644 index 00000000..fbb0becc --- /dev/null +++ b/test/manual/.api_test_config @@ -0,0 +1,13 @@ +# Wanderer API Testing Tool Configuration +# Generated on Thu Mar 6 14:52:00 UTC 2025 + +# Base configuration +HOST="http://localhost:4444" +MAP_SLUG="flygd" +MAP_API_KEY="589016d9-c9ac-48ef-ae74-7a55483b3cc2" +ACL_API_KEY="" + +# Selected IDs +SELECTED_ACL_ID="" +SELECTED_SYSTEM_ID="" +CHARACTER_EVE_ID="" diff --git a/test/manual/.api_test_config.example b/test/manual/.api_test_config.example new file mode 100644 index 00000000..5540985d --- /dev/null +++ b/test/manual/.api_test_config.example @@ -0,0 +1,13 @@ +# Wanderer API Testing Tool Configuration +# Example configuration file - Copy to .api_test_config and modify as needed + +# Base configuration +HOST="http://localhost:4000" +MAP_SLUG="flygd" +MAP_API_KEY="589016d9-c9ac-48ef-ae74-7a55483b3cc2" +ACL_API_KEY="acl-api-key-here" + +# Selected IDs +SELECTED_ACL_ID="123" +SELECTED_SYSTEM_ID="31002019" +CHARACTER_EVE_ID="456" \ No newline at end of file diff --git a/test/manual/.auto_api_test_config b/test/manual/.auto_api_test_config new file mode 100644 index 00000000..90eaaeb7 --- /dev/null +++ b/test/manual/.auto_api_test_config @@ -0,0 +1,13 @@ +# Wanderer API Testing Tool Configuration +# Generated on Thu Mar 6 18:44:20 UTC 2025 + +# Base configuration +HOST="http://localhost:4444" +MAP_SLUG="flygd" +MAP_API_KEY="589016d9-c9ac-48ef-ae74-7a55483b3cc2" +ACL_API_KEY="116bd70e-2bbf-4a99-97ed-1869c09ab5bf" + +# Selected IDs +SELECTED_ACL_ID="9c91d283-f49f-4f45-a21d-9bf53ce9d1fd" +SELECTED_SYSTEM_ID="30002768" +CHARACTER_EVE_ID="2115754172" diff --git a/test/manual/auto_test_api.sh b/test/manual/auto_test_api.sh new file mode 100755 index 00000000..49181535 --- /dev/null +++ b/test/manual/auto_test_api.sh @@ -0,0 +1,894 @@ +#!/bin/bash +#============================================================================== +# Wanderer API Automated Testing Tool +# +# This script tests various endpoints of the Wanderer API. +# +# Features: +# - Uses strict mode (set -euo pipefail) for robust error handling. +# - Contains a DEBUG mode for extra logging (set DEBUG=1 to enable). +# - Validates configuration including a reachability test for the HOST. +# - Outputs a summary in plain text and optionally as JSON. +# - Exits with a nonzero code if any test fails. +# +# Usage: +# ./auto_test_api.sh +# +#============================================================================== + +set -euo pipefail +IFS=$'\n\t' + +# Set DEBUG=1 to enable extra logging +DEBUG=0 +# Set VERBOSE=1 to print raw JSON responses for every test (default 0) +VERBOSE=0 +# Set VERBOSE_SUMMARY=1 to output a JSON summary at the end (default 0) +VERBOSE_SUMMARY=0 + +# Colors for output +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +# Configuration file and default configuration +CONFIG_FILE=".auto_api_test_config" +HOST="http://localhost:4444" # Default host +MAP_SLUG="" +MAP_API_KEY="" +ACL_API_KEY="" +SELECTED_ACL_ID="" +SELECTED_SYSTEM_ID="" +CHARACTER_EVE_ID="" +TEST_RESULTS=() +FAILED_TESTS=() + +# Global variables for last API response +LAST_JSON_RESPONSE="" +LAST_HTTP_CODE="" + +#------------------------------------------------------------------------------ +# Helper Functions +#------------------------------------------------------------------------------ + +debug() { + if [ "$DEBUG" -eq 1 ]; then + echo -e "${YELLOW}[DEBUG] $*${NC}" >&2 + fi +} + +print_header() { + echo -e "\n${BLUE}=== $1 ===${NC}\n" +} + +print_success() { + echo -e "${GREEN}$1${NC}" +} + +print_warning() { + echo -e "${YELLOW}$1${NC}" >&2 +} + +print_error() { + echo -e "${RED}$1${NC}" +} + +# Check if the host is reachable; accept any HTTP status code 200-399. +check_host_reachable() { + debug "Checking if host $HOST is reachable..." + local status + status=$(curl -s -o /dev/null -w "%{http_code}" "$HOST") + debug "HTTP status code for host: $status" + if [[ "$status" -ge 200 && "$status" -lt 400 ]]; then + print_success "Host $HOST is reachable." + else + print_error "Host $HOST is not reachable (HTTP code: $status). Please check the host URL." + exit 1 + fi +} + +# Load configuration from file +load_config() { + if [ -f "$CONFIG_FILE" ]; then + print_success "Loading configuration from $CONFIG_FILE" + source "$CONFIG_FILE" + return 0 + else + print_warning "No configuration file found. Using default values." + return 1 + fi +} + +# Save configuration to file +save_config() { + print_success "Saving configuration to $CONFIG_FILE" + cat > "$CONFIG_FILE" << EOF +# Wanderer API Testing Tool Configuration +# Generated on $(date) + +# Base configuration +HOST="$HOST" +MAP_SLUG="$MAP_SLUG" +MAP_API_KEY="$MAP_API_KEY" +ACL_API_KEY="$ACL_API_KEY" + +# Selected IDs +SELECTED_ACL_ID="$SELECTED_ACL_ID" +SELECTED_SYSTEM_ID="$SELECTED_SYSTEM_ID" +CHARACTER_EVE_ID="$CHARACTER_EVE_ID" +EOF + chmod 600 "$CONFIG_FILE" + print_success "Configuration saved successfully." +} + +# Make an API call using curl and capture response and HTTP code +call_api() { + local method=$1 + local endpoint=$2 + local api_key=$3 + local data=${4:-""} + + local curl_cmd=(curl -s -w "\n%{http_code}" -X "$method" -H "Content-Type: application/json") + if [ -n "$api_key" ]; then + curl_cmd+=(-H "Authorization: Bearer $api_key") + fi + if [ -n "$data" ]; then + curl_cmd+=(-d "$data") + fi + curl_cmd+=("$HOST$endpoint") + + # Print debug command (mask API key) + local debug_cmd + debug_cmd=$(printf "%q " "${curl_cmd[@]}") + debug_cmd=$(echo "$debug_cmd" | sed "s/$api_key/API_KEY_HIDDEN/g") + print_warning "Executing: $debug_cmd" + + local output + output=$("${curl_cmd[@]}") + LAST_HTTP_CODE=$(echo "$output" | tail -n1) + local response + response=$(echo "$output" | sed '$d') + echo "$response" +} + +# Check that required variables are set +check_required_vars() { + local missing=false + if [ $# -eq 0 ]; then + if [ -z "$HOST" ]; then + print_error "HOST is not set. Please set it first." + missing=true + fi + if [ -z "$MAP_SLUG" ]; then + print_error "MAP_SLUG is not set. Please set it first." + missing=true + fi + if [ -z "$MAP_API_KEY" ]; then + print_error "MAP_API_KEY is not set. Please set it first." + missing=true + fi + else + for var in "$@"; do + if [ -z "${!var}" ]; then + print_error "$var is not set. Please set it first." + missing=true + fi + done + fi + $missing && return 1 || return 0 +} + +# Record a test result +record_test_result() { + local endpoint=$1 + local status=$2 + local message=$3 + if [ "$status" = "success" ]; then + TEST_RESULTS+=("${GREEN}✓${NC} $endpoint - $message") + else + TEST_RESULTS+=("${RED}✗${NC} $endpoint - $message") + FAILED_TESTS+=("$endpoint - $message") + fi +} + +# Process and validate the JSON response +check_response() { + local response=$1 + local endpoint=$2 + + if [ -z "$(echo "$response" | xargs)" ]; then + if [ "$LAST_HTTP_CODE" = "200" ] || [ "$LAST_HTTP_CODE" = "204" ]; then + print_success "Received empty response, which is valid" + LAST_JSON_RESPONSE="{}" + return 0 + else + record_test_result "$endpoint" "failure" "Empty response with HTTP code $LAST_HTTP_CODE" + return 1 + fi + fi + + if [ "$VERBOSE" -eq 1 ]; then + echo "Raw response from $endpoint:" + echo "$response" | head -n 20 + fi + + if echo "$response" | jq . > /dev/null 2>&1; then + LAST_JSON_RESPONSE="$response" + return 0 + fi + + local json_part + json_part=$(echo "$response" | grep -o '{.*}' || echo "") + if [ -z "$json_part" ] || ! echo "$json_part" | jq . > /dev/null 2>&1; then + json_part=$(echo "$response" | sed -n '/^{/,$p' | tr -d '\n') + fi + if [ -z "$json_part" ] || ! echo "$json_part" | jq . > /dev/null 2>&1; then + json_part=$(echo "$response" | sed -n '/{/,/}/p' | tr -d '\n') + fi + if [ -z "$json_part" ] || ! echo "$json_part" | jq . > /dev/null 2>&1; then + json_part=$(echo "$response" | awk '!(/^[<>*]/) {print}' | tr -d '\n') + fi + if [ -z "$json_part" ] || ! echo "$json_part" | jq . > /dev/null 2>&1; then + echo "Raw response from $endpoint:" + echo "$response" + record_test_result "$endpoint" "failure" "Invalid JSON response" + return 1 + fi + + local error + error=$(echo "$json_part" | jq -r '.error // empty') + if [ -n "$error" ]; then + echo "Raw response from $endpoint:" + echo "$response" + echo "Parsed JSON response from $endpoint:" + echo "$json_part" | jq '.' + record_test_result "$endpoint" "failure" "Error: $error" + return 1 + fi + + LAST_JSON_RESPONSE="$json_part" + return 0 +} + +# Get a random item from a JSON array using a jq path +get_random_item() { + local json=$1 + local jq_path=$2 + local count + count=$(echo "$json" | jq "$jq_path | length") + if [ "$count" -eq 0 ]; then + echo "" + return 1 + fi + local random_index=$((RANDOM % count)) + echo "$json" | jq -r "$jq_path[$random_index]" +} + +#------------------------------------------------------------------------------ +# API Test Functions +#------------------------------------------------------------------------------ +test_list_characters() { + print_header "Testing GET /api/characters" + print_success "Calling API: GET /api/characters" + local response + response=$(call_api "GET" "/api/characters" "$MAP_API_KEY") + if ! check_response "$response" "GET /api/characters"; then + return 1 + fi + local character_count + character_count=$(echo "$LAST_JSON_RESPONSE" | jq '.data | length') + if [ "$character_count" -gt 0 ]; then + record_test_result "GET /api/characters" "success" "Found $character_count characters" + if [ -z "$CHARACTER_EVE_ID" ]; then + local random_index=$((RANDOM % character_count)) + print_success "Selecting character at index $random_index" + local random_character + random_character=$(echo "$LAST_JSON_RESPONSE" | jq ".data[$random_index]") + CHARACTER_EVE_ID=$(echo "$random_character" | jq -r '.eve_id') + local character_name + character_name=$(echo "$random_character" | jq -r '.name') + print_success "Selected random character: $character_name (EVE ID: $CHARACTER_EVE_ID)" + fi + return 0 + else + record_test_result "GET /api/characters" "success" "No characters found" + return 0 + fi +} + +test_map_systems() { + print_header "Testing GET /api/map/systems" + if ! check_required_vars "MAP_SLUG" "MAP_API_KEY"; then + record_test_result "GET /api/map/systems" "failure" "Missing required variables" + return 1 + fi + print_success "Calling API: GET /api/map/systems?slug=$MAP_SLUG" + local response + response=$(call_api "GET" "/api/map/systems?slug=$MAP_SLUG" "$MAP_API_KEY") + if ! check_response "$response" "GET /api/map/systems"; then + return 1 + fi + local system_count + system_count=$(echo "$LAST_JSON_RESPONSE" | jq '.data | length') + print_success "System count: $system_count" + if [ "$system_count" -gt 0 ]; then + record_test_result "GET /api/map/systems" "success" "Found $system_count systems" + local random_index=$((RANDOM % system_count)) + print_success "Selecting system at index $random_index" + echo "Data structure:" + echo "$LAST_JSON_RESPONSE" | jq '.data[0]' + local random_system + random_system=$(echo "$LAST_JSON_RESPONSE" | jq ".data[$random_index]") + echo "Selected system JSON:" + echo "$random_system" + SELECTED_SYSTEM_ID=$(echo "$random_system" | jq -r '.solar_system_id') + if [ -z "$SELECTED_SYSTEM_ID" ] || [ "$SELECTED_SYSTEM_ID" = "null" ]; then + SELECTED_SYSTEM_ID=$(echo "$random_system" | jq -r '.id // .system_id // empty') + if [ -z "$SELECTED_SYSTEM_ID" ] || [ "$SELECTED_SYSTEM_ID" = "null" ]; then + print_error "Could not find system ID in the response" + echo "Available fields:" + echo "$random_system" | jq 'keys' + record_test_result "GET /api/map/systems" "failure" "Could not extract system ID" + return 1 + fi + fi + local system_name + system_name=$(echo "$random_system" | jq -r '.name // "Unknown"') + print_success "Selected random system: $system_name (ID: $SELECTED_SYSTEM_ID)" + return 0 + else + record_test_result "GET /api/map/systems" "failure" "No systems found" + return 1 + fi +} + +test_map_system() { + print_header "Testing GET /api/map/system" + if [[ -z "$MAP_SLUG" || -z "$SELECTED_SYSTEM_ID" || -z "$MAP_API_KEY" ]]; then + record_test_result "GET /api/map/system" "failure" "Missing required variables" + return + fi + local response + response=$(call_api "GET" "/api/map/system?slug=$MAP_SLUG&id=$SELECTED_SYSTEM_ID" "$MAP_API_KEY") + print_warning "Response: $response" + local trimmed_response + trimmed_response=$(echo "$response" | xargs) + if [[ "$trimmed_response" == "{}" || "$trimmed_response" == '{"data":{}}' ]]; then + print_success "Received empty JSON response, which is valid" + record_test_result "GET /api/map/system" "success" "Received valid empty response" + return + fi + if ! check_response "$response" "GET /api/map/system"; then + return + fi + local json_data="$LAST_JSON_RESPONSE" + local has_data + has_data=$(echo "$json_data" | jq 'has("data")') + if [ "$has_data" != "true" ]; then + print_error "Response does not contain 'data' field" + echo "JSON Response:" + echo "$json_data" | jq . + record_test_result "GET /api/map/system" "failure" "Response does not contain 'data' field" + return + fi + local system_data + system_data=$(echo "$json_data" | jq -r '.data // empty') + if [ -z "$system_data" ] || [ "$system_data" = "null" ]; then + print_error "Could not find system data in response" + echo "JSON Response:" + echo "$json_data" | jq . + record_test_result "GET /api/map/system" "failure" "Could not find system data in response" + return + fi + local system_id + system_id=$(echo "$json_data" | jq -r '.data.solar_system_id // empty') + if [ -z "$system_id" ] || [ "$system_id" = "null" ]; then + print_error "Could not find solar_system_id in the system data" + echo "System Data:" + echo "$system_data" | jq . + record_test_result "GET /api/map/system" "failure" "Could not find solar_system_id in system data" + return + fi + print_success "Found system data with ID: $system_id" + record_test_result "GET /api/map/system" "success" "Found system data with ID: $system_id" +} + +test_map_characters() { + print_header "Testing GET /api/map/characters" + if ! check_required_vars "MAP_SLUG" "MAP_API_KEY"; then + record_test_result "GET /api/map/characters" "failure" "Missing required variables" + return 1 + fi + print_success "Calling API: GET /api/map/characters?slug=$MAP_SLUG" + local response + response=$(call_api "GET" "/api/map/characters?slug=$MAP_SLUG" "$MAP_API_KEY") + if ! check_response "$response" "GET /api/map/characters"; then + return 1 + fi + local character_count + character_count=$(echo "$LAST_JSON_RESPONSE" | jq '.data | length') + record_test_result "GET /api/map/characters" "success" "Found $character_count tracked characters" + return 0 +} + +test_map_structure_timers() { + print_header "Testing GET /api/map/structure-timers" + if [[ -z "$MAP_SLUG" || -z "$MAP_API_KEY" ]]; then + record_test_result "GET /api/map/structure-timers" "failure" "Missing required variables" + return + fi + local response + response=$(call_api "GET" "/api/map/structure-timers?slug=$MAP_SLUG" "$MAP_API_KEY") + local trimmed_response + trimmed_response=$(echo "$response" | xargs) + if [[ "$trimmed_response" == '{"data":[]}' ]]; then + print_success "Found 0 structure timers" + record_test_result "GET /api/map/structure-timers" "success" "Found 0 structure timers" + fi + if ! check_response "$response" "GET /api/map/structure-timers"; then + return + fi + local timer_count + timer_count=$(echo "$LAST_JSON_RESPONSE" | jq '.data | length') + print_success "Found $timer_count structure timers" + record_test_result "GET /api/map/structure-timers" "success" "Found $timer_count structure timers" + if [ -n "$SELECTED_SYSTEM_ID" ]; then + print_header "Testing GET /api/map/structure-timers (filtered)" + local filtered_response + filtered_response=$(call_api "GET" "/api/map/structure-timers?slug=$MAP_SLUG&system_id=$SELECTED_SYSTEM_ID" "$MAP_API_KEY") + print_warning "(Structure Timers) - Filtered response: $filtered_response" + local trimmed_filtered + trimmed_filtered=$(echo "$filtered_response" | xargs) + if [[ "$trimmed_filtered" == '{"data":[]}' ]]; then + print_success "Found 0 filtered structure timers" + record_test_result "GET /api/map/structure-timers (filtered)" "success" "Found 0 filtered structure timers" + return + fi + if ! check_response "$filtered_response" "GET /api/map/structure-timers (filtered)"; then + return + fi + local filtered_count + filtered_count=$(echo "$LAST_JSON_RESPONSE" | jq '.data | length') + print_success "Found $filtered_count filtered structure timers" + record_test_result "GET /api/map/structure-timers (filtered)" "success" "Found $filtered_count filtered structure timers" + fi +} + +test_map_systems_kills() { + print_header "Testing GET /api/map/systems-kills" + if [[ -z "$MAP_SLUG" || -z "$MAP_API_KEY" ]]; then + record_test_result "GET /api/map/systems-kills" "failure" "Missing required variables" + return + fi + # Use the correct parameter name: hours + local response + response=$(call_api "GET" "/api/map/systems-kills?slug=$MAP_SLUG&hours=1" "$MAP_API_KEY") + print_warning "(Systems Kills) - Response: $response" + if ! check_response "$response" "GET /api/map/systems-kills"; then + return + fi + local json_data="$LAST_JSON_RESPONSE" + if [ "$VERBOSE" -eq 1 ]; then + echo "JSON Response:"; echo "$json_data" | jq . + fi + local has_data + has_data=$(echo "$json_data" | jq 'has("data")') + if [ "$has_data" != "true" ]; then + print_error "Response does not contain 'data' field" + if [ "$VERBOSE" -eq 1 ]; then + echo "JSON Response:"; echo "$json_data" | jq . + fi + record_test_result "GET /api/map/systems-kills" "failure" "Response does not contain 'data' field" + return + fi + local systems_count + systems_count=$(echo "$json_data" | jq '.data | length') + print_success "Found kill data for $systems_count systems" + record_test_result "GET /api/map/systems-kills" "success" "Found kill data for $systems_count systems" + print_header "Testing GET /api/map/systems-kills (filtered)" + local filter_url="/api/map/systems-kills?slug=$MAP_SLUG&hours=1" + if [ -n "$SELECTED_SYSTEM_ID" ]; then + filter_url="$filter_url&system_id=$SELECTED_SYSTEM_ID" + print_success "Using system_id filter to reduce response size" + fi + local filtered_response + filtered_response=$(call_api "GET" "$filter_url" "$MAP_API_KEY") + local trimmed_filtered + trimmed_filtered=$(echo "$filtered_response" | xargs) + if [[ "$trimmed_filtered" == '{"data":[]}' ]]; then + print_success "Found 0 filtered systems with kill data" + record_test_result "GET /api/map/systems-kills (filtered)" "success" "Found 0 filtered systems with kill data" + return + fi + if [[ "$trimmed_filtered" == '{"data":'* ]]; then + print_success "Received valid JSON response (large data)" + record_test_result "GET /api/map/systems-kills (filtered)" "success" "Received valid JSON response with kill data" + return + fi + if ! check_response "$filtered_response" "GET /api/map/systems-kills (filtered)"; then + return + fi + local filtered_count + filtered_count=$(echo "$LAST_JSON_RESPONSE" | jq '.data | length') + print_success "Found filtered kill data for $filtered_count systems" + record_test_result "GET /api/map/systems-kills (filtered)" "success" "Found filtered kill data for $filtered_count systems" +} + +test_map_acls() { + print_header "Testing GET /api/map/acls" + if ! check_required_vars "MAP_SLUG" "MAP_API_KEY"; then + record_test_result "GET /api/map/acls" "failure" "Missing required variables" + return 1 + fi + print_success "Calling API: GET /api/map/acls?slug=$MAP_SLUG" + local response + response=$(call_api "GET" "/api/map/acls?slug=$MAP_SLUG" "$MAP_API_KEY") + if ! check_response "$response" "GET /api/map/acls"; then + return 1 + fi + local acl_count + acl_count=$(echo "$LAST_JSON_RESPONSE" | jq '.data | length') + record_test_result "GET /api/map/acls" "success" "Found $acl_count ACLs" + if [ "$acl_count" -gt 0 ]; then + local random_acl + random_acl=$(get_random_item "$LAST_JSON_RESPONSE" ".data") + SELECTED_ACL_ID=$(echo "$random_acl" | jq -r '.id') + local acl_name + acl_name=$(echo "$random_acl" | jq -r '.name') + print_success "Selected random ACL: $acl_name (ID: $SELECTED_ACL_ID)" + else + print_warning "No ACLs found to select for future tests" + fi + return 0 +} + +test_create_acl() { + print_header "Testing POST /api/map/acls" + if ! check_required_vars "MAP_SLUG" "MAP_API_KEY"; then + record_test_result "POST /api/map/acls" "failure" "Missing required variables" + return 1 + fi + if [ -z "$CHARACTER_EVE_ID" ]; then + print_warning "No character EVE ID selected. Fetching characters..." + print_success "Calling API: GET /api/characters" + local characters_response + characters_response=$(call_api "GET" "/api/characters" "$MAP_API_KEY") + if ! check_response "$characters_response" "GET /api/characters"; then + record_test_result "POST /api/map/acls" "failure" "Failed to get characters" + return 1 + fi + local character_count + character_count=$(echo "$LAST_JSON_RESPONSE" | jq '.data | length') + if [ "$character_count" -eq 0 ]; then + record_test_result "POST /api/map/acls" "failure" "No characters found" + return 1 + fi + local random_index=$((RANDOM % character_count)) + print_success "Selecting character at index $random_index" + local random_character + random_character=$(echo "$LAST_JSON_RESPONSE" | jq ".data[$random_index]") + CHARACTER_EVE_ID=$(echo "$random_character" | jq -r '.eve_id') + local character_name + character_name=$(echo "$random_character" | jq -r '.name') + print_success "Selected random character: $character_name (EVE ID: $CHARACTER_EVE_ID)" + fi + local acl_name="Auto Test ACL $(date +%s)" + local acl_description="Created by auto_test_api.sh on $(date)" + local data="{\"acl\": {\"name\": \"$acl_name\", \"owner_eve_id\": $CHARACTER_EVE_ID, \"description\": \"$acl_description\"}}" + print_success "Calling API: POST /api/map/acls?slug=$MAP_SLUG" + print_success "Data: $data" + local response + response=$(call_api "POST" "/api/map/acls?slug=$MAP_SLUG" "$MAP_API_KEY" "$data") + if ! check_response "$response" "POST /api/map/acls"; then + return 1 + fi + local new_acl_id + new_acl_id=$(echo "$LAST_JSON_RESPONSE" | jq -r '.data.id // empty') + local new_api_key + new_api_key=$(echo "$LAST_JSON_RESPONSE" | jq -r '.data.api_key // empty') + if [ -n "$new_acl_id" ] && [ -n "$new_api_key" ]; then + record_test_result "POST /api/map/acls" "success" "Created new ACL with ID: $new_acl_id" + SELECTED_ACL_ID=$new_acl_id + ACL_API_KEY=$new_api_key + print_success "Using the new ACL (ID: $SELECTED_ACL_ID) and its API key for further operations" + save_config + return 0 + else + record_test_result "POST /api/map/acls" "failure" "Failed to extract ACL ID or API key from response" + return 1 + fi +} + +test_show_acl() { + print_header "Testing GET /api/acls/:id" + if [ -z "$SELECTED_ACL_ID" ] || [ -z "$ACL_API_KEY" ]; then + record_test_result "GET /api/acls/:id" "failure" "Missing ACL ID or API key" + return 1 + fi + print_success "Calling API: GET /api/acls/$SELECTED_ACL_ID" + local response + response=$(call_api "GET" "/api/acls/$SELECTED_ACL_ID" "$ACL_API_KEY") + if ! check_response "$response" "GET /api/acls/:id"; then + return 1 + fi + local acl_name + acl_name=$(echo "$LAST_JSON_RESPONSE" | jq -r '.data.name // empty') + if [ -n "$acl_name" ]; then + record_test_result "GET /api/acls/:id" "success" "Found ACL: $acl_name" + return 0 + else + record_test_result "GET /api/acls/:id" "failure" "ACL data not found" + return 1 + fi +} + +test_update_acl() { + print_header "Testing PUT /api/acls/:id" + if [ -z "$SELECTED_ACL_ID" ] || [ -z "$ACL_API_KEY" ]; then + record_test_result "PUT /api/acls/:id" "failure" "Missing ACL ID or API key" + return 1 + fi + local new_name="Updated Auto Test ACL $(date +%s)" + local new_description="Updated by auto_test_api.sh on $(date)" + local data="{\"acl\": {\"name\": \"$new_name\", \"description\": \"$new_description\"}}" + print_success "Calling API: PUT /api/acls/$SELECTED_ACL_ID" + print_success "Data: $data" + local response + response=$(call_api "PUT" "/api/acls/$SELECTED_ACL_ID" "$ACL_API_KEY" "$data") + if ! check_response "$response" "PUT /api/acls/:id"; then + return 1 + fi + local updated_name + updated_name=$(echo "$LAST_JSON_RESPONSE" | jq -r '.data.name // empty') + if [ "$updated_name" = "$new_name" ]; then + record_test_result "PUT /api/acls/:id" "success" "Updated ACL name to: $updated_name" + return 0 + else + record_test_result "PUT /api/acls/:id" "failure" "Failed to update ACL name" + return 1 + fi +} + +test_create_acl_member() { + print_header "Testing POST /api/acls/:acl_id/members" + if [ -z "$SELECTED_ACL_ID" ] || [ -z "$ACL_API_KEY" ]; then + record_test_result "POST /api/acls/:acl_id/members" "failure" "Missing ACL ID or API key" + return 1 + fi + if [ -z "$CHARACTER_EVE_ID" ]; then + print_warning "No character EVE ID selected. Fetching characters..." + print_success "Calling API: GET /api/characters" + local characters_response + characters_response=$(call_api "GET" "/api/characters" "$MAP_API_KEY") + if ! check_response "$characters_response" "GET /api/characters"; then + record_test_result "POST /api/acls/:acl_id/members" "failure" "Failed to get characters" + return 1 + fi + local character_count + character_count=$(echo "$LAST_JSON_RESPONSE" | jq '.data | length') + if [ "$character_count" -eq 0 ]; then + record_test_result "POST /api/acls/:acl_id/members" "failure" "No characters found" + return 1 + fi + local random_index=$((RANDOM % character_count)) + print_success "Selecting character at index $random_index" + local random_character + random_character=$(echo "$LAST_JSON_RESPONSE" | jq ".data[$random_index]") + CHARACTER_EVE_ID=$(echo "$random_character" | jq -r '.eve_id') + local character_name + character_name=$(echo "$random_character" | jq -r '.name') + print_success "Selected random character: $character_name (EVE ID: $CHARACTER_EVE_ID)" + fi + local data="{\"member\": {\"eve_character_id\": $CHARACTER_EVE_ID, \"role\": \"member\"}}" + print_success "Calling API: POST /api/acls/$SELECTED_ACL_ID/members" + print_success "Data: $data" + local response + response=$(call_api "POST" "/api/acls/$SELECTED_ACL_ID/members" "$ACL_API_KEY" "$data") + if ! check_response "$response" "POST /api/acls/:acl_id/members"; then + return 1 + fi + local member_id + member_id=$(echo "$LAST_JSON_RESPONSE" | jq -r '.data.id // empty') + if [ -n "$member_id" ]; then + record_test_result "POST /api/acls/:acl_id/members" "success" "Created new member with ID: $member_id" + MEMBER_ID=$CHARACTER_EVE_ID + return 0 + else + record_test_result "POST /api/acls/:acl_id/members" "failure" "Failed to create member" + return 1 + fi +} + +test_update_acl_member() { + print_header "Testing PUT /api/acls/:acl_id/members/:member_id" + if [ -z "$SELECTED_ACL_ID" ] || [ -z "$ACL_API_KEY" ] || [ -z "$MEMBER_ID" ]; then + record_test_result "PUT /api/acls/:acl_id/members/:member_id" "failure" "Missing ACL ID, API key, or member ID" + return 1 + fi + local data="{\"member\": {\"role\": \"member\"}}" + print_success "Calling API: PUT /api/acls/$SELECTED_ACL_ID/members/$MEMBER_ID" + print_success "Data: $data" + local response + response=$(call_api "PUT" "/api/acls/$SELECTED_ACL_ID/members/$MEMBER_ID" "$ACL_API_KEY" "$data") + if ! check_response "$response" "PUT /api/acls/:acl_id/members/:member_id"; then + return 1 + fi + local updated_role + updated_role=$(echo "$LAST_JSON_RESPONSE" | jq -r '.data.role // empty') + if [ "$updated_role" = "member" ]; then + record_test_result "PUT /api/acls/:acl_id/members/:member_id" "success" "Updated member role to: $updated_role" + return 0 + else + record_test_result "PUT /api/acls/:acl_id/members/:member_id" "failure" "Failed to update member role" + return 1 + fi +} + +test_delete_acl_member() { + print_header "Testing DELETE /api/acls/:acl_id/members/:member_id" + if [ -z "$SELECTED_ACL_ID" ] || [ -z "$ACL_API_KEY" ] || [ -z "$MEMBER_ID" ]; then + record_test_result "DELETE /api/acls/:acl_id/members/:member_id" "failure" "Missing ACL ID, API key, or member ID" + return 1 + fi + print_success "Calling API: DELETE /api/acls/$SELECTED_ACL_ID/members/$MEMBER_ID" + local response + response=$(call_api "DELETE" "/api/acls/$SELECTED_ACL_ID/members/$MEMBER_ID" "$ACL_API_KEY") + if ! check_response "$response" "DELETE /api/acls/:acl_id/members/:member_id"; then + return 1 + fi + record_test_result "DELETE /api/acls/:acl_id/members/:member_id" "success" "Deleted member with ID: $MEMBER_ID" + MEMBER_ID="" + return 0 +} + +test_system_static_info() { + print_header "Testing GET /api/common/system-static-info" + if [ -z "$SELECTED_SYSTEM_ID" ]; then + record_test_result "GET /api/common/system-static-info" "failure" "No system ID selected" + return 1 + fi + print_success "Calling API: GET /api/common/system-static-info?id=$SELECTED_SYSTEM_ID" + local response + response=$(call_api "GET" "/api/common/system-static-info?id=$SELECTED_SYSTEM_ID" "$MAP_API_KEY") + if ! check_response "$response" "GET /api/common/system-static-info"; then + return 1 + fi + local system_count + system_count=$(echo "$LAST_JSON_RESPONSE" | jq 'length') + record_test_result "GET /api/common/system-static-info" "success" "Found static info for $system_count systems" + return 0 +} + +#------------------------------------------------------------------------------ +# Configuration and Main Menu Functions +#------------------------------------------------------------------------------ +set_config() { + print_header "Configuration" + echo -e "Current configuration:" + [ -n "$HOST" ] && echo -e " Host: ${BLUE}$HOST${NC}" + [ -n "$MAP_SLUG" ] && echo -e " Map Slug: ${BLUE}$MAP_SLUG${NC}" + [ -n "$MAP_API_KEY" ] && echo -e " Map API Key: ${BLUE}${MAP_API_KEY:0:8}...${NC}" + read -p "Enter host (default: $HOST): " input_host + [ -n "$input_host" ] && HOST="$input_host" + read -p "Enter map slug: " input_map_slug + [ -n "$input_map_slug" ] && MAP_SLUG="$input_map_slug" + read -p "Enter map API key: " input_map_api_key + [ -n "$input_map_api_key" ] && MAP_API_KEY="$input_map_api_key" + # Reset IDs to force fresh data + SELECTED_SYSTEM_ID="" + SELECTED_ACL_ID="" + ACL_API_KEY="" + CHARACTER_EVE_ID="" + save_config +} + +run_all_tests() { + print_header "Running all API tests" + TEST_RESULTS=() + FAILED_TESTS=() + + if ! command -v jq &> /dev/null; then + print_error "jq is required for this script to work. Please install it first." + exit 1 + fi + + if ! check_required_vars "MAP_SLUG" "MAP_API_KEY"; then + print_error "Please set MAP_SLUG and MAP_API_KEY before running tests." + exit 1 + fi + + check_host_reachable + + test_list_characters + if test_map_systems; then + test_map_system + else + print_error "Skipping test_map_system because test_map_systems failed" + record_test_result "GET /api/map/system" "failure" "Skipped because test_map_systems failed" + fi + test_map_characters + test_map_structure_timers + test_map_systems_kills + test_map_acls + if test_create_acl; then + test_show_acl + test_update_acl + if test_create_acl_member; then + test_update_acl_member + test_delete_acl_member + else + print_error "Skipping ACL member tests because test_create_acl_member failed" + record_test_result "PUT /api/acls/:acl_id/members/:member_id" "failure" "Skipped because test_create_acl_member failed" + record_test_result "DELETE /api/acls/:acl_id/members/:member_id" "failure" "Skipped because test_create_acl_member failed" + fi + else + print_error "Skipping ACL tests because test_create_acl failed" + record_test_result "GET /api/acls/:id" "failure" "Skipped because test_create_acl failed" + record_test_result "PUT /api/acls/:id" "failure" "Skipped because test_create_acl failed" + record_test_result "POST /api/acls/:acl_id/members" "failure" "Skipped because test_create_acl failed" + record_test_result "PUT /api/acls/:acl_id/members/:member_id" "failure" "Skipped because test_create_acl failed" + record_test_result "DELETE /api/acls/:acl_id/members/:member_id" "failure" "Skipped because test_create_acl failed" + fi + test_system_static_info + + print_header "Test Results" + for result in "${TEST_RESULTS[@]}"; do + echo -e "$result" + done + + local total_tests=${#TEST_RESULTS[@]} + local failed_tests=${#FAILED_TESTS[@]} + local passed_tests=$((total_tests - failed_tests)) + print_header "Summary" + echo -e "Total tests: $total_tests" + echo -e "Passed: ${GREEN}$passed_tests${NC}" + echo -e "Failed: ${RED}$failed_tests${NC}" + if [ $failed_tests -gt 0 ]; then + print_header "Failed Tests" + for failed in "${FAILED_TESTS[@]}"; do + echo -e "${RED}✗${NC} $failed" + done + fi + + if [ "$VERBOSE_SUMMARY" -eq 1 ]; then + summary_json=$(jq -n --arg total "$total_tests" --arg passed "$passed_tests" --arg failed "$failed_tests" \ + '{total_tests: $total_tests|tonumber, passed: $passed|tonumber, failed: $failed|tonumber}') + echo "JSON Summary:"; echo "$summary_json" | jq . + fi + + save_config + + if [ $failed_tests -gt 0 ]; then + exit 1 + else + exit 0 + fi +} + +#------------------------------------------------------------------------------ +# Main Menu and Entry Point +#------------------------------------------------------------------------------ +main() { + print_header "Wanderer API Automated Testing Tool" + load_config + if [ -z "$MAP_SLUG" ] || [ -z "$MAP_API_KEY" ]; then + print_warning "MAP_SLUG or MAP_API_KEY not set. Let's configure them now." + set_config + fi + echo -e "What would you like to do?" + echo "1) Run all tests" + echo "2) Set configuration" + echo "3) Exit" + read -p "Enter your choice: " choice + case $choice in + 1) run_all_tests ;; + 2) set_config ;; + 3) exit 0 ;; + *) print_error "Invalid choice"; main ;; + esac +} + +# Start the script +main diff --git a/test/manual/test_api_calls.sh b/test/manual/test_api_calls.sh deleted file mode 100755 index 203040c8..00000000 --- a/test/manual/test_api_calls.sh +++ /dev/null @@ -1,198 +0,0 @@ -#!/usr/bin/env bash -# -# Example script to test your Map & ACL endpoints using curl. -# Requires `jq` to parse JSON responses. - -# If any command fails, this script will exit immediately -set -e - -############################################# -# Environment Variables (must be set before) -############################################# -: "${BASE_URL:?Need to set BASE_URL, e.g. http://localhost:4444}" -: "${MAP_TOKEN:?Need to set MAP_TOKEN (Bearer token for map requests)}" -: "${MAP_SLUG:?Need to set MAP_SLUG (slug for the map to test)}" -: "${EVE_CHARACTER_ID:?Need to set EVE_CHARACTER_ID (e.g. from /api/characters)}" - -echo "Using BASE_URL = $BASE_URL" -echo "Using MAP_TOKEN = $MAP_TOKEN" -echo "Using MAP_SLUG = $MAP_SLUG" -echo "Using EVE_CHARACTER_ID = $EVE_CHARACTER_ID" -echo "-------------------------------------" - -############################################# -# 1) Get list of characters (just to confirm they exist) -############################################# -echo -echo "=== 1) Get All Characters (for reference) ===" -curl -s "$BASE_URL/api/characters" | jq - -############################################# -# 2) Get ACLs for the given map slug -############################################# -echo -echo "=== 2) List ACLs for Map Slug '$MAP_SLUG' ===" -ACL_LIST_JSON=$(curl -s -H "Authorization: Bearer $MAP_TOKEN" \ - "$BASE_URL/api/map/acls?slug=$MAP_SLUG") - -echo "$ACL_LIST_JSON" | jq - -# Attempt to parse out the first ACL ID and token from the JSON data array: -FIRST_ACL_ID=$(echo "$ACL_LIST_JSON" | jq -r '.data[0].id // empty') -FIRST_ACL_TOKEN=$(echo "$ACL_LIST_JSON" | jq -r '.data[0].api_key // empty') - -############################################# -# 3) Decide whether to use an existing ACL or create a new one -############################################# -if [ -z "$FIRST_ACL_ID" ] || [ "$FIRST_ACL_ID" = "null" ]; then - echo "No existing ACL found for map slug: $MAP_SLUG." - USE_EXISTING_ACL=false -else - # We found at least one ACL. But does it have a token? - if [ -z "$FIRST_ACL_TOKEN" ] || [ "$FIRST_ACL_TOKEN" = "null" ]; then - echo "Found an ACL with ID $FIRST_ACL_ID but no api_key in the response." - echo "We cannot do membership actions on it without a token." - USE_EXISTING_ACL=false - else - echo "Parsed ACL_ID from existing ACL: $FIRST_ACL_ID" - echo "Parsed ACL_TOKEN from existing ACL: $FIRST_ACL_TOKEN" - USE_EXISTING_ACL=true - fi -fi - -############################################# -# 4) If we cannot use an existing ACL, create a new one -############################################# -if [ "$USE_EXISTING_ACL" = false ]; then - echo - echo "=== Creating a new ACL for membership testing ===" - NEW_ACL_RESPONSE=$(curl -s -X POST \ - -H "Authorization: Bearer $MAP_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "acl": { - "name": "Auto-Created ACL", - "description": "Created because none with a token was found", - "owner_eve_id": "'"$EVE_CHARACTER_ID"'" - } - }' \ - "$BASE_URL/api/map/acls?slug=$MAP_SLUG") - - echo "New ACL creation response:" - echo "$NEW_ACL_RESPONSE" | jq - - ACL_ID=$(echo "$NEW_ACL_RESPONSE" | jq -r '.data.id // empty') - ACL_TOKEN=$(echo "$NEW_ACL_RESPONSE" | jq -r '.data.api_key // empty') - - if [ -z "$ACL_ID" ] || [ "$ACL_ID" = "null" ] || \ - [ -z "$ACL_TOKEN" ] || [ "$ACL_TOKEN" = "null" ]; then - echo "Failed to create an ACL with a valid token. Exiting..." - exit 1 - fi - - echo "Newly created ACL_ID: $ACL_ID" - echo "Newly created ACL_TOKEN: $ACL_TOKEN" - -else - # Use the existing ACL's details - ACL_ID="$FIRST_ACL_ID" - ACL_TOKEN="$FIRST_ACL_TOKEN" -fi - -############################################# -# 5) Show the details of that ACL -############################################# -echo -echo "=== 5) Show ACL Details ===" -ACL_DETAILS=$(curl -s \ - -H "Authorization: Bearer $ACL_TOKEN" \ - "$BASE_URL/api/acls/$ACL_ID") - -echo "$ACL_DETAILS" | jq || { - echo "ACL details response is not valid JSON. Raw response:" - echo "$ACL_DETAILS" - exit 1 -} - -############################################# -# 6) Create a new ACL member (viewer) -############################################# -echo -echo "=== 6) Create a New ACL Member (viewer) ===" -CREATE_MEMBER_RESP=$(curl -s -X POST \ - -H "Authorization: Bearer $ACL_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "member": { - "eve_character_id": "'"$EVE_CHARACTER_ID"'", - "role": "viewer" - } - }' \ - "$BASE_URL/api/acls/$ACL_ID/members") - -echo "$CREATE_MEMBER_RESP" | jq || { - echo "Create member response is not valid JSON. Raw response:" - echo "$CREATE_MEMBER_RESP" - exit 1 -} - -############################################# -# 7) Update the member's role (e.g., admin) -############################################# -echo -echo "=== 7) Update Member Role to 'admin' ===" -UPDATE_MEMBER_RESP=$(curl -s -X PUT \ - -H "Authorization: Bearer $ACL_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "member": { - "role": "admin" - } - }' \ - "$BASE_URL/api/acls/$ACL_ID/members/$EVE_CHARACTER_ID") - -echo "$UPDATE_MEMBER_RESP" | jq || { - echo "Update member response is not valid JSON. Raw response:" - echo "$UPDATE_MEMBER_RESP" - exit 1 -} - -############################################# -# 8) Delete the member -############################################# -echo -echo "=== 8) Delete the Member ===" -DELETE_MEMBER_RESP=$(curl -s -X DELETE \ - -H "Authorization: Bearer $ACL_TOKEN" \ - "$BASE_URL/api/acls/$ACL_ID/members/$EVE_CHARACTER_ID") - -echo "$DELETE_MEMBER_RESP" | jq || { - echo "Delete member response is not valid JSON. Raw response:" - echo "$DELETE_MEMBER_RESP" - exit 1 -} - -############################################# -# 9) (Optional) Update the ACL itself -############################################# -echo -echo "=== 9) Update the ACL’s name/description ===" -UPDATED_ACL=$(curl -s -X PUT \ - -H "Authorization: Bearer $ACL_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "acl": { - "name": "Updated ACL Name (script)", - "description": "An updated description from test script" - } - }' \ - "$BASE_URL/api/acls/$ACL_ID") - -echo "$UPDATED_ACL" | jq || { - echo "Update ACL response is not valid JSON. Raw response:" - echo "$UPDATED_ACL" - exit 1 -} - -echo -echo "=== Done! ==="