prop/lib/microwaveprop/qrz/client.ex
Graham McIntire 99e7560601
Drop gridmap.org dependency, resolve callsigns locally
Instead of shelling out to https://gridmap.org/locate/:callsign for
callsign → lat/lon lookups, ports the resolver pipeline from gridmap-web
into this project so the whole flow runs in-process.

New modules:

- Microwaveprop.Qrz — cache facade over the QRZ.com XML callsign API.
  Looks up from qrz_callsigns first, falls back to a live fetch, and
  upserts the result with a configurable cache_ttl_hours (default
  168h / 7 days).
- Microwaveprop.Qrz.Client — HTTP/XML client against
  https://xmldata.qrz.com/xml/current/. Holds the session key in an
  Agent, transparently re-logs-in on :session_expired, and parses
  responses via xmerl.
- Microwaveprop.Qrz.Callsign — Ecto schema for the qrz_callsigns
  cache table, binary_id primary key per project convention.
- Microwaveprop.Qrz.Record — slim struct with only the 11 fields
  we actually consume (identity, name, grid, address, lat/lon).
  The full XML payload stays in the raw :data jsonb column for
  anyone who wants the other ~40 QRZ fields.
- Microwaveprop.Geocoder — Req-based client against the Google Maps
  Geocoding API. Only called as a fallback when QRZ has no explicit
  <lat>/<lon> for the callsign.
- Microwaveprop.CallsignLocation — orchestrator. Reads the
  callsign_locations cache, on miss calls Qrz then either uses QRZ's
  coords directly or geocodes the formatted address, snaps to an
  8-char Maidenhead grid via Microwaveprop.Radio.Maidenhead, and
  upserts the result.

Microwaveprop.Radio.CallsignClient.locate/1 is rewritten to delegate
to CallsignLocation.lookup/1 and shape the response back to the
existing {:ok, %{callsign, gridsquare, lat, lon}} contract so the
callers in PathLive and RoverLive don't change.

Wiring:

- priv/repo/migrations/20260413000000_create_qrz_callsigns_and_callsign_locations.exs
  creates qrz_callsigns and callsign_locations with unique indexes
  on :callsign.
- Microwaveprop.Qrz.Client added to the application supervision tree
  so the session Agent is started.
- :xmerl added to extra_applications so the release bundles it.
- config/test.exs wires Req.Test plugs for Microwaveprop.Qrz.Client
  and Microwaveprop.Geocoder, forces cache_ttl_hours: 0 so the cache
  never short-circuits test-level stubs, and supplies dummy QRZ
  credentials.
- config/runtime.exs pulls QRZ_USERNAME / QRZ_PASSWORD / QRZ_AGENT
  and GOOGLE_API_KEY from the environment so prod and dev can
  configure both upstream keys out of band.

Tests (ported verbatim from gridmap-web):

- test/microwaveprop/qrz/callsign_test.exs — schema/changeset
- test/microwaveprop/qrz_test.exs — cache hit, cache miss, TTL
  behavior, upsert on stale, error passthrough, case-insensitive
  input
- test/microwaveprop/geocoder_test.exs — success, zero results,
  request denied, transport error
- test/microwaveprop/callsign_location_test.exs — end-to-end flow
  including the QRZ-lat/lon shortcut and the missing-address error
  path

All 1294 tests still pass. Credo strict clean.
2026-04-12 17:43:29 -05:00

189 lines
4.6 KiB
Elixir

defmodule Microwaveprop.Qrz.Client do
@moduledoc """
HTTP client for the QRZ.com XML callsign API.
Maintains a session key in an `Agent` and transparently re-logs-in
when the upstream returns a `session_expired` response. The session
key is cached process-local because QRZ rate-limits login calls.
"""
use Agent
require Record
Record.defrecord(:xmlElement, Record.extract(:xmlElement, from_lib: "xmerl/include/xmerl.hrl"))
Record.defrecord(:xmlText, Record.extract(:xmlText, from_lib: "xmerl/include/xmerl.hrl"))
@url "https://xmldata.qrz.com/xml/current/"
@spec start_link(keyword()) :: Agent.on_start()
def start_link(_) do
Agent.start_link(fn -> nil end, name: __MODULE__)
end
@spec reset_session() :: :ok
def reset_session do
Agent.update(__MODULE__, fn _ -> nil end)
end
@spec login() :: {:ok, String.t()} | {:error, String.t()}
def login do
config = Application.get_env(:microwaveprop, __MODULE__, [])
username = Keyword.fetch!(config, :username)
password = Keyword.fetch!(config, :password)
agent = Keyword.get(config, :agent, "microwaveprop/1.0")
params = %{username: username, password: password, agent: agent}
with {:ok, xml} <- do_request(params),
{:ok, key} <- parse_session(xml) do
Agent.update(__MODULE__, fn _ -> key end)
{:ok, key}
end
end
@spec lookup(String.t()) :: {:ok, map()} | {:error, String.t()}
def lookup(callsign) do
with {:ok, key} <- ensure_session() do
do_lookup(callsign, key, true)
end
end
defp ensure_session do
case Agent.get(__MODULE__, & &1) do
nil -> login()
key -> {:ok, key}
end
end
defp do_lookup(callsign, key, retry?) do
params = %{s: key, callsign: callsign}
with {:ok, xml} <- do_request(params) do
xml
|> parse_callsign_response()
|> handle_lookup_result(callsign, retry?)
end
end
defp handle_lookup_result({:ok, _} = success, _, _), do: success
defp handle_lookup_result({:error, :session_expired}, callsign, true) do
Agent.update(__MODULE__, fn _ -> nil end)
with {:ok, new_key} <- login() do
do_lookup(callsign, new_key, false)
end
end
defp handle_lookup_result({:error, :session_expired}, _, false) do
{:error, "Session expired after retry"}
end
defp handle_lookup_result({:error, _} = error, _, _), do: error
defp do_request(params) do
config = Application.get_env(:microwaveprop, __MODULE__, [])
req_opts =
[url: @url, params: params]
|> maybe_add_plug(config)
|> maybe_add_retry(config)
case Req.get(req_opts) do
{:ok, %{status: 200, body: body}} ->
{:ok, body}
{:ok, %{status: status}} ->
{:error, "HTTP #{status}"}
{:error, reason} ->
{:error, "Request failed: #{inspect(reason)}"}
end
end
defp maybe_add_plug(opts, config) do
case Keyword.get(config, :plug) do
nil -> opts
plug -> Keyword.put(opts, :plug, plug)
end
end
defp maybe_add_retry(opts, config) do
case Keyword.get(config, :retry) do
nil -> opts
retry -> Keyword.put(opts, :retry, retry)
end
end
defp parse_session(xml) do
bytes = :binary.bin_to_list(xml)
{doc, _} = :xmerl_scan.string(bytes)
case xpath_text(doc, ~c"//Session/Key") do
nil ->
error = xpath_text(doc, ~c"//Session/Error") || "Unknown error"
{:error, error}
key ->
{:ok, key}
end
end
defp parse_callsign_response(xml) do
bytes = :binary.bin_to_list(xml)
{doc, _} = :xmerl_scan.string(bytes)
callsign_elements = :xmerl_xpath.string(~c"//Callsign/*", doc)
if callsign_elements == [] do
case xpath_text(doc, ~c"//Session/Key") do
nil ->
{:error, :session_expired}
_ ->
error = xpath_text(doc, ~c"//Session/Error") || "Not found"
{:error, error}
end
else
data =
callsign_elements
|> Enum.filter(&Record.is_record(&1, :xmlElement))
|> Map.new(fn elem ->
name =
elem
|> xmlElement(:name)
|> to_string()
value = extract_text(elem)
{name, value}
end)
{:ok, data}
end
end
defp xpath_text(doc, path) do
case :xmerl_xpath.string(path, doc) do
[] ->
nil
[element | _] ->
extract_text(element)
end
end
defp extract_text(element) do
element
|> xmlElement(:content)
|> Enum.filter(&Record.is_record(&1, :xmlText))
|> Enum.map_join(fn text_node ->
text_node
|> xmlText(:value)
|> to_string()
end)
|> then(fn
"" -> nil
text -> text
end)
end
end