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 Microwaveprop.Instrument.span([:qrz, :request], %{}, fn -> 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) 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