towerops/lib/towerops/sonar/client.ex

185 lines
5.2 KiB
Elixir

defmodule Towerops.Sonar.Client do
@moduledoc """
GraphQL client for the Sonar API.
Uses Req with page-based pagination for all list endpoints.
"""
require Logger
@accounts_query """
query ListAccounts($page: Int!, $records_per_page: Int!) {
accounts(paginator: {page: $page, records_per_page: $records_per_page}) {
entities {
id
name
account_status { name }
account_type { name }
addresses { line1 city state zip country latitude longitude }
account_services {
name
service { name }
price_override
next_bill_date
}
}
page_info { page total_pages records_per_page total_count }
}
}
"""
@network_sites_query """
query ListNetworkSites($page: Int!, $records_per_page: Int!) {
network_sites(paginator: {page: $page, records_per_page: $records_per_page}) {
entities {
id
name
latitude
longitude
ip_assignments { ip_address subnet description }
}
page_info { page total_pages records_per_page total_count }
}
}
"""
@inventory_items_query """
query ListInventoryItems($page: Int!, $records_per_page: Int!) {
inventory_items(paginator: {page: $page, records_per_page: $records_per_page}) {
entities {
id
name
inventory_model { name manufacturer { name } }
assignee { id __typename }
}
page_info { page total_pages records_per_page total_count }
}
}
"""
@doc "Test the connection to Sonar by fetching a single account."
def test_connection(instance_url, api_token) do
test_query = """
{ accounts(paginator: {page: 1, records_per_page: 1}) {
entities { id }
}
}
"""
case query(instance_url, api_token, test_query) do
{:ok, _data} -> {:ok, %{}}
{:error, reason} -> {:error, reason}
end
end
@doc "List all accounts with automatic pagination."
def list_accounts(instance_url, api_token) do
paginate(instance_url, api_token, @accounts_query, "accounts")
end
@doc "List all network sites with automatic pagination."
def list_network_sites(instance_url, api_token) do
paginate(instance_url, api_token, @network_sites_query, "network_sites")
end
@doc "List all inventory items with automatic pagination."
def list_inventory_items(instance_url, api_token) do
paginate(instance_url, api_token, @inventory_items_query, "inventory_items")
end
@doc "Execute a raw GraphQL query against the Sonar API."
def query(instance_url, api_token, query_string, variables \\ %{}) do
case request(instance_url, api_token, query_string, variables) do
{:ok, %{status: status, body: %{"data" => data}}} when status in 200..299 ->
{:ok, data}
{:ok, %{status: status, body: %{"errors" => errors}}} when status in [200, 400] ->
{: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("Sonar API unexpected status #{status}: #{inspect(body)}")
{:error, {:unexpected_status, status}}
{:error, reason} ->
Logger.error("Sonar API connection error: #{inspect(reason)}")
{:error, reason}
end
end
defp paginate(instance_url, api_token, query_string, root_key, page \\ 1, acc \\ []) do
variables = %{"page" => page, "records_per_page" => 100}
case query(instance_url, api_token, query_string, variables) do
{:ok, data} ->
root = data[root_key]
entities = root["entities"] || []
all_entities = acc ++ entities
page_info = root["page_info"]
if page < page_info["total_pages"] do
paginate(instance_url, api_token, query_string, root_key, page + 1, all_entities)
else
{:ok, all_entities}
end
{:error, reason} ->
{:error, reason}
end
end
defp request(instance_url, api_token, query_string, variables) do
url = String.trim_trailing(instance_url, "/") <> "/api/graphql"
req_opts = [
method: :post,
url: url,
headers: [
{"authorization", "Bearer #{api_token}"},
{"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