towerops/lib/towerops/preseem/client.ex
Graham McIntire 74332e2203
fix: Gaiia and Preseem sync bugs from API response mismatches
- Billing subscription and inventory item status fields are scalars, not objects
- IP blocks use "block" key, not "subnet"
- Strip nil variables from GraphQL pagination (Gaiia returns null for after:null)
- Handle nil root keys in paginated responses gracefully
- Handle empty string response from Preseem scores endpoint
2026-02-15 12:23:47 -06:00

132 lines
3.7 KiB
Elixir

defmodule Towerops.Preseem.Client do
@moduledoc """
HTTP client for the Preseem Model API.
Uses Req with built-in test support via `Req.Test`.
Auth: HTTP Basic (API key as username, empty password).
"""
require Logger
@base_url "https://api.preseem.com/model/v1"
@doc """
Tests the connection to the Preseem API by fetching a page of access points.
Returns `{:ok, body}` on success, `{:error, reason}` on failure.
"""
def test_connection(api_key) do
case request(:get, "#{@base_url}/access_points?limit=100", api_key) do
{:ok, %{status: status, body: body}} when status in 200..299 ->
{:ok, body}
{:ok, %{status: 401}} ->
{:error, :unauthorized}
{:ok, %{status: 403}} ->
{:error, :forbidden}
{:ok, %{status: status, body: body}} ->
Logger.warning("Preseem API unexpected status #{status}: #{inspect(body)}")
{:error, {:unexpected_status, status}}
{:error, reason} ->
Logger.error("Preseem API connection error: #{inspect(reason)}")
{:error, reason}
end
end
@doc """
Lists all access points with scores from the Preseem API.
Fetches from `/access_point_scores` which returns rich data per AP.
Returns `{:ok, [access_point_score]}` on success.
"""
def list_access_points(api_key) do
case request(:get, "#{@base_url}/access_point_scores", api_key) do
{:ok, %{status: status, body: body}} when status in 200..299 ->
{:ok, normalize_scores_response(body)}
{:ok, %{status: 401}} ->
{:error, :unauthorized}
{:ok, %{status: 403}} ->
{:error, :forbidden}
{:ok, %{status: status, body: body}} ->
{:error, {:unexpected_status, status, body}}
{:error, reason} ->
{:error, reason}
end
end
@doc """
Lists access points from the basic `/access_points` endpoint with pagination.
Returns `{:ok, [access_point]}` on success.
"""
def list_access_points_basic(api_key) do
fetch_all_pages(api_key, "#{@base_url}/access_points?limit=500", [])
end
defp fetch_all_pages(api_key, url, acc) do
case request(:get, url, api_key) do
{:ok, %{status: status, body: %{"data" => data, "paginator" => paginator}}}
when status in 200..299 ->
all = acc ++ data
if paginator["page"] < paginator["page_count"] do
next_page = paginator["page"] + 1
next_url = "#{@base_url}/access_points?limit=500&page=#{next_page}"
fetch_all_pages(api_key, next_url, all)
else
{:ok, all}
end
{:ok, %{status: 401}} ->
{:error, :unauthorized}
{:ok, %{status: 403}} ->
{:error, :forbidden}
{:ok, %{status: status, body: body}} ->
{:error, {:unexpected_status, status, body}}
{:error, reason} ->
{:error, reason}
end
end
# The scores endpoint may return an array, a wrapped object with "data" key,
# or a single object. Normalize to a list.
defp normalize_scores_response(body) when is_binary(body), do: []
defp normalize_scores_response(body) when is_list(body), do: body
defp normalize_scores_response(%{"data" => data}) when is_list(data), do: data
defp normalize_scores_response(body) when is_map(body), do: [body]
defp request(method, url, api_key) do
opts = [
method: method,
url: url,
auth: {:basic, "#{api_key}:"},
headers: [{"accept", "application/json"}]
]
# Only use Req.Test plug in test environment
opts =
if Application.get_env(:towerops, :env) == :test do
Keyword.put(opts, :plug, {Req.Test, __MODULE__})
else
opts
end
Req.request(opts)
rescue
exception ->
{:error, Exception.message(exception)}
end
end