Files
wanderer/lib/wanderer_app_web/controllers/map_webhooks_api_controller.ex
2025-07-16 23:17:47 +00:00

697 lines
21 KiB
Elixir

defmodule WandererAppWeb.MapWebhooksAPIController do
use WandererAppWeb, :controller
use OpenApiSpex.ControllerSpecs
alias WandererApp.Api.MapWebhookSubscription
alias WandererAppWeb.Schemas.{ApiSchemas, ResponseSchemas}
require Logger
# -----------------------------------------------------------------
# V1 API Actions (for compatibility with versioned API router)
# -----------------------------------------------------------------
def index_v1(conn, params) do
# Convert map_id to map_identifier for existing implementation
updated_params =
case params do
%{"map_id" => map_id} -> Map.put(params, "map_identifier", map_id)
_ -> params
end
index(conn, updated_params)
end
def show_v1(conn, params) do
# Convert map_id to map_identifier for existing implementation
updated_params =
case params do
%{"map_id" => map_id} -> Map.put(params, "map_identifier", map_id)
_ -> params
end
show(conn, updated_params)
end
def create_v1(conn, params) do
# Convert map_id to map_identifier for existing implementation
updated_params =
case params do
%{"map_id" => map_id} -> Map.put(params, "map_identifier", map_id)
_ -> params
end
create(conn, updated_params)
end
def update_v1(conn, params) do
# Convert map_id to map_identifier for existing implementation
updated_params =
case params do
%{"map_id" => map_id} -> Map.put(params, "map_identifier", map_id)
_ -> params
end
update(conn, updated_params)
end
def delete_v1(conn, params) do
# Convert map_id to map_identifier for existing implementation
updated_params =
case params do
%{"map_id" => map_id} -> Map.put(params, "map_identifier", map_id)
_ -> params
end
delete(conn, updated_params)
end
# -----------------------------------------------------------------
# Schema Definitions
# -----------------------------------------------------------------
@webhook_subscription_schema %OpenApiSpex.Schema{
type: :object,
properties: %{
id: %OpenApiSpex.Schema{type: :string, description: "Webhook subscription UUID"},
map_id: %OpenApiSpex.Schema{type: :string, description: "Map UUID"},
url: %OpenApiSpex.Schema{
type: :string,
description: "HTTPS webhook endpoint URL",
example: "https://example.com/webhook"
},
events: %OpenApiSpex.Schema{
type: :array,
items: %OpenApiSpex.Schema{type: :string},
description: "Array of event types to subscribe to, or ['*'] for all",
example: ["add_system", "map_kill", "*"]
},
active: %OpenApiSpex.Schema{type: :boolean, description: "Whether webhook is active"},
last_delivery_at: %OpenApiSpex.Schema{
type: :string,
format: :date_time,
description: "Last successful delivery timestamp",
nullable: true
},
last_error: %OpenApiSpex.Schema{
type: :string,
description: "Last error message if delivery failed",
nullable: true
},
consecutive_failures: %OpenApiSpex.Schema{
type: :integer,
description: "Number of consecutive delivery failures"
},
inserted_at: %OpenApiSpex.Schema{type: :string, format: :date_time},
updated_at: %OpenApiSpex.Schema{type: :string, format: :date_time}
},
required: [:id, :map_id, :url, :events, :active, :consecutive_failures],
example: %{
id: "550e8400-e29b-41d4-a716-446655440000",
map_id: "550e8400-e29b-41d4-a716-446655440001",
url: "https://example.com/wanderer-webhook",
events: ["add_system", "map_kill"],
active: true,
last_delivery_at: "2025-06-21T12:34:56Z",
last_error: nil,
consecutive_failures: 0,
inserted_at: "2025-06-21T10:00:00Z",
updated_at: "2025-06-21T12:34:56Z"
}
}
@webhook_create_schema %OpenApiSpex.Schema{
type: :object,
properties: %{
url: %OpenApiSpex.Schema{
type: :string,
description: "HTTPS webhook endpoint URL (max 2000 characters)",
example: "https://example.com/wanderer-webhook"
},
events: %OpenApiSpex.Schema{
type: :array,
items: %OpenApiSpex.Schema{type: :string},
description: "Array of event types to subscribe to, or ['*'] for all events",
example: ["add_system", "map_kill"]
},
active: %OpenApiSpex.Schema{
type: :boolean,
description: "Whether webhook should be active (default: true)",
default: true
}
},
required: [:url, :events],
example: %{
url: "https://example.com/wanderer-webhook",
events: ["add_system", "signatures_updated", "map_kill"],
active: true
}
}
@webhook_update_schema %OpenApiSpex.Schema{
type: :object,
properties: %{
url: %OpenApiSpex.Schema{
type: :string,
description: "HTTPS webhook endpoint URL (max 2000 characters)"
},
events: %OpenApiSpex.Schema{
type: :array,
items: %OpenApiSpex.Schema{type: :string},
description: "Array of event types to subscribe to, or ['*'] for all events"
},
active: %OpenApiSpex.Schema{
type: :boolean,
description: "Whether webhook should be active"
}
},
example: %{
events: ["*"],
active: false
}
}
@webhook_secret_response_schema %OpenApiSpex.Schema{
type: :object,
properties: %{
secret: %OpenApiSpex.Schema{
type: :string,
description: "New webhook secret for HMAC signature verification"
}
},
required: [:secret],
example: %{
secret: "abc123def456ghi789jkl012mno345pqr678stu901vwx234yz="
}
}
@webhooks_response_schema ApiSchemas.data_wrapper(%OpenApiSpex.Schema{
type: :array,
items: @webhook_subscription_schema
})
@webhook_response_schema ApiSchemas.data_wrapper(@webhook_subscription_schema)
@secret_response_schema ApiSchemas.data_wrapper(@webhook_secret_response_schema)
# -----------------------------------------------------------------
# OpenApiSpex Operations
# -----------------------------------------------------------------
operation(:index,
summary: "List webhook subscriptions for a map",
description: "Retrieves all webhook subscriptions configured for the specified map.",
tags: ["Webhook Management"],
parameters: [
map_identifier: [
in: :path,
description: "Map UUID or slug",
type: :string,
required: true
]
],
responses: %{
200 => {"Success", "application/json", @webhooks_response_schema},
401 => ResponseSchemas.bad_request("Unauthorized"),
404 => ResponseSchemas.not_found("Map not found"),
500 => ResponseSchemas.internal_server_error("Internal server error")
}
)
operation(:show,
summary: "Get a specific webhook subscription",
description: "Retrieves details of a specific webhook subscription.",
tags: ["Webhook Management"],
parameters: [
map_identifier: [
in: :path,
description: "Map UUID or slug",
type: :string,
required: true
],
id: [
in: :path,
description: "Webhook subscription UUID",
type: :string,
required: true
]
],
responses: %{
200 => {"Success", "application/json", @webhook_response_schema},
401 => ResponseSchemas.bad_request("Unauthorized"),
404 => ResponseSchemas.not_found("Webhook not found"),
500 => ResponseSchemas.internal_server_error("Internal server error")
}
)
operation(:create,
summary: "Create a new webhook subscription",
description: """
Creates a new webhook subscription for the map. The webhook will receive HTTP POST
requests for the specified event types. A secret is automatically generated for
HMAC signature verification.
""",
tags: ["Webhook Management"],
parameters: [
map_identifier: [
in: :path,
description: "Map UUID or slug",
type: :string,
required: true
]
],
request_body: {"Webhook subscription data", "application/json", @webhook_create_schema},
responses: %{
201 => {"Created", "application/json", @webhook_response_schema},
400 => ResponseSchemas.bad_request("Invalid webhook data"),
401 => ResponseSchemas.bad_request("Unauthorized"),
409 => ResponseSchemas.bad_request("Webhook URL already exists for this map"),
500 => ResponseSchemas.internal_server_error("Internal server error")
}
)
operation(:update,
summary: "Update a webhook subscription",
description: "Updates an existing webhook subscription. Partial updates are supported.",
tags: ["Webhook Management"],
parameters: [
map_identifier: [
in: :path,
description: "Map UUID or slug",
type: :string,
required: true
],
id: [
in: :path,
description: "Webhook subscription UUID",
type: :string,
required: true
]
],
request_body: {"Webhook update data", "application/json", @webhook_update_schema},
responses: %{
200 => {"Updated", "application/json", @webhook_response_schema},
400 => ResponseSchemas.bad_request("Invalid webhook data"),
401 => ResponseSchemas.bad_request("Unauthorized"),
404 => ResponseSchemas.not_found("Webhook not found"),
409 => ResponseSchemas.bad_request("Webhook URL already exists for this map"),
500 => ResponseSchemas.internal_server_error("Internal server error")
}
)
operation(:delete,
summary: "Delete a webhook subscription",
description: "Permanently deletes a webhook subscription.",
tags: ["Webhook Management"],
parameters: [
map_identifier: [
in: :path,
description: "Map UUID or slug",
type: :string,
required: true
],
id: [
in: :path,
description: "Webhook subscription UUID",
type: :string,
required: true
]
],
responses: %{
204 => {"Deleted", "application/json", nil},
401 => ResponseSchemas.bad_request("Unauthorized"),
404 => ResponseSchemas.not_found("Webhook not found"),
500 => ResponseSchemas.internal_server_error("Internal server error")
}
)
operation(:rotate_secret,
summary: "Rotate webhook secret",
description: """
Generates a new secret for the webhook subscription. The old secret will be
invalidated immediately. Update your webhook endpoint to use the new secret
for HMAC signature verification.
""",
tags: ["Webhook Management"],
parameters: [
map_identifier: [
in: :path,
description: "Map UUID or slug",
type: :string,
required: true
],
map_webhooks_api_id: [
in: :path,
description: "Webhook subscription UUID",
type: :string,
required: true
]
],
responses: %{
200 => {"Secret rotated", "application/json", @secret_response_schema},
401 => ResponseSchemas.bad_request("Unauthorized"),
404 => ResponseSchemas.not_found("Webhook not found"),
500 => ResponseSchemas.internal_server_error("Internal server error")
}
)
# -----------------------------------------------------------------
# Controller Actions
# -----------------------------------------------------------------
def index(conn, %{"map_identifier" => map_identifier}) do
with {:ok, map} <- get_map(conn, map_identifier) do
webhooks = MapWebhookSubscription.by_map!(map.id)
json_webhooks = Enum.map(webhooks, &webhook_to_json/1)
json(conn, %{data: json_webhooks})
else
{:error, :map_not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "Map not found"})
{:error, reason} ->
Logger.error("Failed to list webhooks: #{inspect(reason)}")
conn
|> put_status(:internal_server_error)
|> json(%{error: "Internal server error"})
end
end
def show(conn, %{"map_identifier" => map_identifier, "id" => webhook_id}) do
with {:ok, map} <- get_map(conn, map_identifier),
{:ok, webhook} <- get_webhook(webhook_id, map.id) do
json(conn, %{data: webhook_to_json(webhook)})
else
{:error, :map_not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "Map not found"})
{:error, :webhook_not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "Webhook not found"})
{:error, reason} ->
Logger.error("Failed to get webhook: #{inspect(reason)}")
conn
|> put_status(:internal_server_error)
|> json(%{error: "Internal server error"})
end
end
def create(conn, %{"map_identifier" => map_identifier} = params) do
# Check if webhooks are enabled
if not Application.get_env(:wanderer_app, :external_events, [])[:webhooks_enabled] do
conn
|> put_status(:service_unavailable)
|> json(%{error: "Webhooks are disabled on this server"})
else
do_create_webhook(conn, map_identifier, params)
end
end
defp do_create_webhook(conn, map_identifier, params) do
with {:ok, map} <- get_map(conn, map_identifier),
{:ok, webhook_params} <- validate_create_params(params, map.id) do
case MapWebhookSubscription.create(webhook_params) do
{:ok, webhook} ->
conn
|> put_status(:created)
|> json(%{data: webhook_to_json(webhook)})
{:error, %Ash.Error.Invalid{errors: errors}} ->
error_messages =
Enum.map(errors, fn error ->
case error do
%{message: message} ->
message
%Ash.Error.Changes.NoSuchAttribute{attribute: attr} ->
"Invalid attribute: #{attr}"
_ ->
inspect(error)
end
end)
conn
|> put_status(:bad_request)
|> json(%{error: "Validation failed", details: error_messages})
{:error, reason} ->
Logger.error("Failed to create webhook: #{inspect(reason)}")
conn
|> put_status(:internal_server_error)
|> json(%{error: "Internal server error"})
end
else
{:error, :map_not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "Map not found"})
{:error, :invalid_params} ->
conn
|> put_status(:bad_request)
|> json(%{error: "Invalid webhook parameters"})
{:error, reason} ->
Logger.error("Failed to create webhook: #{inspect(reason)}")
conn
|> put_status(:internal_server_error)
|> json(%{error: "Internal server error"})
end
end
def update(conn, %{"map_identifier" => map_identifier, "id" => webhook_id} = params) do
with {:ok, map} <- get_map(conn, map_identifier),
{:ok, webhook} <- get_webhook(webhook_id, map.id),
{:ok, update_params} <- validate_update_params(params) do
case MapWebhookSubscription.update(webhook, update_params) do
{:ok, updated_webhook} ->
json(conn, %{data: webhook_to_json(updated_webhook)})
{:error, %Ash.Error.Invalid{errors: errors}} ->
error_messages =
Enum.map(errors, fn error ->
case error do
%{message: message} ->
message
%Ash.Error.Changes.NoSuchAttribute{attribute: attr} ->
"Invalid attribute: #{attr}"
_ ->
inspect(error)
end
end)
conn
|> put_status(:bad_request)
|> json(%{error: "Validation failed", details: error_messages})
{:error, reason} ->
Logger.error("Failed to update webhook: #{inspect(reason)}")
conn
|> put_status(:internal_server_error)
|> json(%{error: "Internal server error"})
end
else
{:error, :map_not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "Map not found"})
{:error, :webhook_not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "Webhook not found"})
{:error, :invalid_params} ->
conn
|> put_status(:bad_request)
|> json(%{error: "Invalid webhook parameters"})
{:error, reason} ->
Logger.error("Failed to update webhook: #{inspect(reason)}")
conn
|> put_status(:internal_server_error)
|> json(%{error: "Internal server error"})
end
end
def delete(conn, %{"map_identifier" => map_identifier, "id" => webhook_id}) do
with {:ok, map} <- get_map(conn, map_identifier),
{:ok, webhook} <- get_webhook(webhook_id, map.id) do
case MapWebhookSubscription.destroy(webhook) do
:ok ->
conn |> put_status(:no_content)
{:error, reason} ->
Logger.error("Failed to delete webhook: #{inspect(reason)}")
conn
|> put_status(:internal_server_error)
|> json(%{error: "Internal server error"})
end
else
{:error, :map_not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "Map not found"})
{:error, :webhook_not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "Webhook not found"})
{:error, reason} ->
Logger.error("Failed to delete webhook: #{inspect(reason)}")
conn
|> put_status(:internal_server_error)
|> json(%{error: "Internal server error"})
end
end
def rotate_secret(conn, %{
"map_identifier" => map_identifier,
"map_webhooks_api_id" => webhook_id
}) do
with {:ok, map} <- get_map(conn, map_identifier),
{:ok, webhook} <- get_webhook(webhook_id, map.id) do
case MapWebhookSubscription.rotate_secret(webhook) do
{:ok, updated_webhook} ->
# Return the new secret (this is the only time it's exposed)
json(conn, %{data: %{secret: updated_webhook.secret}})
{:error, reason} ->
Logger.error("Failed to rotate webhook secret: #{inspect(reason)}")
conn
|> put_status(:internal_server_error)
|> json(%{error: "Internal server error"})
end
else
{:error, :map_not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "Map not found"})
{:error, :webhook_not_found} ->
conn
|> put_status(:not_found)
|> json(%{error: "Webhook not found"})
{:error, reason} ->
Logger.error("Failed to rotate webhook secret: #{inspect(reason)}")
conn
|> put_status(:internal_server_error)
|> json(%{error: "Internal server error"})
end
end
# -----------------------------------------------------------------
# Private Functions
# -----------------------------------------------------------------
defp get_map(conn, map_identifier) do
# The map should already be loaded by the CheckMapApiKey plug
case conn.assigns[:map] do
nil -> {:error, :map_not_found}
map -> {:ok, map}
end
end
defp get_webhook(webhook_id, map_id) do
try do
case MapWebhookSubscription.by_id(webhook_id) do
nil ->
{:error, :webhook_not_found}
{:ok, webhook} ->
if webhook.map_id == map_id do
{:ok, webhook}
else
{:error, :webhook_not_found}
end
{:error, _error} ->
{:error, :webhook_not_found}
webhook ->
if webhook.map_id == map_id do
{:ok, webhook}
else
{:error, :webhook_not_found}
end
end
rescue
# Only catch specific Ash-related exceptions
error in [Ash.Error.Query.NotFound, Ash.Error.Invalid] ->
Logger.debug("Webhook lookup error: #{inspect(error)}")
{:error, :webhook_not_found}
end
end
defp validate_create_params(params, map_id) do
required_fields = ["url", "events"]
if Enum.all?(required_fields, &Map.has_key?(params, &1)) do
webhook_params = %{
map_id: map_id,
url: params["url"],
events: params["events"],
active?: Map.get(params, "active", true)
}
{:ok, webhook_params}
else
{:error, :invalid_params}
end
end
defp validate_update_params(params) do
# Filter out non-updatable fields and map identifier
allowed_fields = ["url", "events", "active"]
update_params =
params
|> Map.take(allowed_fields)
|> Enum.reduce(%{}, fn {k, v}, acc ->
case k do
"active" -> Map.put(acc, :active?, v)
"url" -> Map.put(acc, :url, v)
"events" -> Map.put(acc, :events, v)
_ -> acc
end
end)
{:ok, update_params}
end
defp webhook_to_json(webhook) do
%{
id: webhook.id,
map_id: webhook.map_id,
url: webhook.url,
events: webhook.events,
active: webhook.active?,
last_delivery_at: webhook.last_delivery_at,
last_error: webhook.last_error,
consecutive_failures: webhook.consecutive_failures,
inserted_at: webhook.inserted_at,
updated_at: webhook.updated_at
}
end
end