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.
189 lines
4.6 KiB
Elixir
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
|