towerops/lib/towerops_web/controllers/api/v1/sites_controller.ex

248 lines
5.7 KiB
Elixir

defmodule ToweropsWeb.Api.V1.SitesController do
@moduledoc """
API controller for managing sites.
All endpoints require API token authentication and operations are scoped
to the organization associated with the token.
"""
use ToweropsWeb, :controller
alias Towerops.Sites
@doc """
GET /api/v1/sites
Lists all sites for the authenticated organization.
Response:
{
"sites": [
{
"id": "uuid",
"name": "Main Office",
"location": "New York, NY",
"snmp_community": "public",
"inserted_at": "2026-01-15T19:44:25Z"
}
]
}
"""
def index(conn, _params) do
organization_id = conn.assigns.current_organization_id
sites =
organization_id
|> Sites.list_organization_sites()
|> Enum.map(&format_site/1)
json(conn, %{sites: sites})
end
@doc """
POST /api/v1/sites
Creates a new site for the authenticated organization.
Request body:
{
"site": {
"name": "Main Office",
"location": "New York, NY",
"snmp_community": "public" # optional
}
}
Response:
{
"id": "uuid",
"name": "Main Office",
"location": "New York, NY",
"snmp_community": "public",
"inserted_at": "2026-01-15T19:44:25Z"
}
"""
def create(conn, %{"site" => site_params}) do
organization_id = conn.assigns.current_organization_id
attrs = Map.put(site_params, "organization_id", organization_id)
case Sites.create_site(attrs) do
{:ok, site} ->
conn
|> put_status(:created)
|> json(format_site(site))
{:error, %Ecto.Changeset{} = changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: translate_errors(changeset)})
end
end
def create(conn, _params) do
conn
|> put_status(:bad_request)
|> json(%{error: "Missing 'site' parameter"})
end
@doc """
GET /api/v1/sites/:id
Gets a single site by ID.
Response:
{
"id": "uuid",
"name": "Main Office",
"location": "New York, NY",
"snmp_community": "public",
"inserted_at": "2026-01-15T19:44:25Z"
}
"""
def show(conn, %{"id" => id}) do
organization_id = conn.assigns.current_organization_id
site = Sites.get_site!(id)
if site.organization_id == organization_id do
json(conn, format_site(site))
else
conn
|> put_status(:forbidden)
|> json(%{error: "Access denied to this site"})
end
rescue
Ecto.NoResultsError ->
conn
|> put_status(:not_found)
|> json(%{error: "Site not found"})
end
@doc """
PATCH /api/v1/sites/:id
Updates a site.
Request body:
{
"site": {
"name": "Updated Name",
"location": "Boston, MA"
}
}
Response:
{
"id": "uuid",
"name": "Updated Name",
"location": "Boston, MA",
"snmp_community": "public",
"inserted_at": "2026-01-15T19:44:25Z"
}
"""
def update(conn, %{"id" => id, "site" => site_params}) do
organization_id = conn.assigns.current_organization_id
site = Sites.get_site!(id)
if site.organization_id == organization_id do
case Sites.update_site(site, site_params) do
{:ok, updated_site} ->
json(conn, format_site(updated_site))
{:error, %Ecto.Changeset{} = changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: translate_errors(changeset)})
end
else
conn
|> put_status(:forbidden)
|> json(%{error: "Access denied to this site"})
end
rescue
Ecto.NoResultsError ->
conn
|> put_status(:not_found)
|> json(%{error: "Site not found"})
end
def update(conn, _params) do
conn
|> put_status(:bad_request)
|> json(%{error: "Missing 'site' parameter"})
end
@doc """
DELETE /api/v1/sites/:id
Deletes a site.
Response:
{
"success": true
}
"""
def delete(conn, %{"id" => id}) do
organization_id = conn.assigns.current_organization_id
site = Sites.get_site!(id)
if site.organization_id == organization_id do
case Sites.delete_site(site) do
{:ok, _site} ->
json(conn, %{success: true})
{:error, %Ecto.Changeset{} = changeset} ->
conn
|> put_status(:unprocessable_entity)
|> json(%{errors: translate_errors(changeset)})
end
else
conn
|> put_status(:forbidden)
|> json(%{error: "Access denied to this site"})
end
rescue
Ecto.NoResultsError ->
conn
|> put_status(:not_found)
|> json(%{error: "Site not found"})
end
# Private helpers
defp format_site(site) do
%{
id: site.id,
name: site.name,
description: site.description,
location: site.location,
address: site.address,
latitude: site.latitude,
longitude: site.longitude,
display_order: site.display_order,
snmp_community: site.snmp_community,
snmp_version: site.snmp_version,
snmp_port: site.snmp_port,
snmp_transport: site.snmp_transport,
agent_token_id: site.agent_token_id,
parent_site_id: site.parent_site_id,
inserted_at: site.inserted_at
}
end
defp translate_errors(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
Regex.replace(~r"%{(\w+)}", msg, fn _, key ->
safe_translate_key(key, opts)
end)
end)
end
# Safely translate error message keys to prevent atom exhaustion
defp safe_translate_key(key, opts) do
# Validate key length and format before converting to atom to prevent abuse
if String.length(key) <= 50 and String.match?(key, ~r/^[a-z_]+$/) do
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
else
key
end
end
end