towerops/lib/towerops/gaiia/client.ex
Graham McIntire 47b364a112
Fix Gaiia inventory matching, mapping UX, and logo link
- Fetch MAC address from Gaiia custom fields during inventory sync
- Remove name-based device matching (model names caused false matches)
- Keep IP-only matching for device suggestions
- Make unmapped rows clickable in the Gaiia mapping table
- Point authenticated logo link to /dashboard instead of /orgs
- Fix flaky monitor test teardown race condition
2026-02-15 17:41:43 -06:00

350 lines
8.4 KiB
Elixir

defmodule Towerops.Gaiia.Client do
@moduledoc """
GraphQL client for the Gaiia API.
Uses Req with built-in test support via `Req.Test`. Handles Relay-style
cursor pagination for all list endpoints.
"""
require Logger
@default_endpoint "https://api.gaiia.com/api/v1"
@accounts_query """
query ListAccounts($first: Int!, $after: String) {
accounts(first: $first, after: $after) {
edges {
node {
id
readableId
name
status {
id
name
type
}
type {
id
name
}
physicalAddress {
line1
locality
region
postalCode
country
latitude
longitude
}
billingSubscriptions(first: 100) {
edges {
node {
id
status
}
}
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
"""
@network_sites_query """
query ListNetworkSites($first: Int!, $after: String) {
networkSites(first: $first, after: $after) {
edges {
node {
id
name
address {
line1
locality
region
postalCode
country
latitude
longitude
}
ipBlocks(first: 100) {
edges {
node {
id
block
}
}
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
"""
@inventory_items_query """
query ListInventoryItems($first: Int!, $after: String) {
inventoryItems(first: $first, after: $after) {
edges {
node {
id
status
ipAddressV4
model {
name
manufacturer {
name
}
category {
name
}
}
assignation {
assigneeType
assignee {
... on Account { id }
... on NetworkSite { id }
}
}
fields(first: 10) {
edges {
node {
data
modelField { name }
}
}
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
"""
@billing_subscriptions_query """
query ListBillingSubscriptions($first: Int!, $after: String) {
billingSubscriptions(first: $first, after: $after) {
edges {
node {
id
status
productVersion {
product {
name
}
price
currency
}
entity {
... on Account { id }
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
"""
# --- Mutations ---
@create_ticket_mutation """
mutation CreateExternalTicket($subject: String!, $description: String!, $entityId: ID, $entityType: String) {
createExternalTicket(input: {
subject: $subject,
description: $description,
entityId: $entityId,
entityType: $entityType
}) {
id
subject
}
}
"""
@create_note_mutation """
mutation CreateNote($entityId: ID!, $entityType: String!, $body: String!) {
createNote(input: {
entityId: $entityId,
entityType: $entityType,
body: $body
}) {
id
}
}
"""
@update_inventory_item_mutation """
mutation UpdateInventoryItem($id: ID!, $input: UpdateInventoryItemInput!) {
updateInventoryItem(id: $id, input: $input) {
id
ipAddressV4
}
}
"""
@doc "Create an external ticket in Gaiia."
def create_ticket(api_key, attrs, opts \\ []) do
query(api_key, @create_ticket_mutation, attrs, opts)
end
@doc "Create a note on a Gaiia entity (account, inventory item, etc)."
def create_note(api_key, entity_type, entity_id, body, opts \\ []) do
variables = %{
"entityId" => entity_id,
"entityType" => entity_type,
"body" => body
}
query(api_key, @create_note_mutation, variables, opts)
end
@doc "Update an inventory item in Gaiia."
def update_inventory_item(api_key, item_id, input, opts \\ []) do
query(api_key, @update_inventory_item_mutation, %{"id" => item_id, "input" => input}, opts)
end
# --- Queries ---
@doc "Test the connection to Gaiia by fetching a single account."
def test_connection(api_key, opts \\ []) do
case query(api_key, "{ accounts(first: 1) { edges { node { id } } } }", %{}, opts) do
{:ok, _data} -> {:ok, %{}}
{:error, reason} -> {:error, reason}
end
end
@doc "List all accounts with automatic pagination."
def list_accounts(api_key, opts \\ []) do
paginate(api_key, @accounts_query, "accounts", opts)
end
@doc "List all network sites with automatic pagination."
def list_network_sites(api_key, opts \\ []) do
paginate(api_key, @network_sites_query, "networkSites", opts)
end
@doc "List all inventory items with automatic pagination."
def list_inventory_items(api_key, opts \\ []) do
paginate(api_key, @inventory_items_query, "inventoryItems", opts)
end
@doc "List all billing subscriptions with automatic pagination."
def list_billing_subscriptions(api_key, opts \\ []) do
paginate(api_key, @billing_subscriptions_query, "billingSubscriptions", opts)
end
@doc "Execute a raw GraphQL query against the Gaiia API."
def query(api_key, query_string, variables \\ %{}, opts \\ []) do
case request(api_key, query_string, variables, opts) do
{:ok, %{status: status, body: %{"data" => data}}} when status in 200..299 ->
{:ok, data}
{:ok, %{status: status, body: %{"errors" => errors}}} when status in 200..299 ->
{:error, {:graphql_errors, errors}}
{:ok, %{status: 400, body: %{"errors" => errors}}} ->
{:error, {:graphql_errors, errors}}
{:ok, %{status: 401}} ->
{:error, :unauthorized}
{:ok, %{status: 403}} ->
{:error, :forbidden}
{:ok, %{status: 429, headers: headers}} ->
retry_after = get_retry_after(headers)
{:error, {:rate_limited, retry_after}}
{:ok, %{status: status, body: body}} ->
Logger.warning("Gaiia API unexpected status #{status}: #{inspect(body)}")
{:error, {:unexpected_status, status}}
{:error, reason} ->
Logger.error("Gaiia API connection error: #{inspect(reason)}")
{:error, reason}
end
end
defp paginate(api_key, query_string, root_key, opts, cursor \\ nil, acc \\ []) do
variables =
%{"first" => 100, "after" => cursor}
|> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Map.new()
case query(api_key, query_string, variables, opts) do
{:ok, data} ->
root = data[root_key]
edges = (root && root["edges"]) || []
nodes = Enum.map(edges, & &1["node"])
all_nodes = acc ++ nodes
page_info = root && root["pageInfo"]
if page_info && page_info["hasNextPage"] do
paginate(api_key, query_string, root_key, opts, page_info["endCursor"], all_nodes)
else
{:ok, all_nodes}
end
{:error, reason} ->
{:error, reason}
end
end
defp request(api_key, query_string, variables, opts) do
endpoint = Keyword.get(opts, :endpoint, @default_endpoint)
req_opts = [
method: :post,
url: endpoint,
headers: [
{"x-gaiia-api-key", api_key},
{"content-type", "application/json"}
],
json: %{"query" => query_string, "variables" => variables}
]
req_opts =
if Application.get_env(:towerops, :env) == :test do
Keyword.put(req_opts, :plug, {Req.Test, __MODULE__})
else
req_opts
end
Req.request(req_opts)
rescue
exception ->
{:error, Exception.message(exception)}
end
defp get_retry_after(headers) when is_map(headers) do
case Map.get(headers, "retry-after") do
[value | _] -> parse_retry_after(value)
_ -> 60
end
end
defp get_retry_after(_headers), do: 60
defp parse_retry_after(value) when is_binary(value) do
case Integer.parse(value) do
{seconds, _} -> seconds
:error -> 60
end
end
defp parse_retry_after(value) when is_integer(value), do: value
defp parse_retry_after(_), do: 60
end