lib/authoritex/geonames.ex

defmodule Authoritex.GeoNames do
  @moduledoc "Authoritex implementation for GeoNames webservice"
  @behaviour Authoritex

  import HTTPoison.Retry

  @http_uri_base "https://sws.geonames.org/"

  @error_codes %{
    "10" => "Authorization Exception",
    "11" => "record does not exist",
    "12" => "other error",
    "13" => "database timeout",
    "14" => "invalid parameter",
    "15" => "no result found",
    "16" => "duplicate exception",
    "17" => "postal code not found",
    "18" => "daily limit of credits exceeded",
    "19" => "hourly limit of credits exceeded",
    "20" => "weekly limit of credits exceeded",
    "21" => "invalid input",
    "22" => "server overloaded exception",
    "23" => "service not implemented",
    "24" => "radius too large",
    "27" => "maxRows too large"
  }

  @impl Authoritex
  def can_resolve?(@http_uri_base <> _), do: true
  def can_resolve?(_), do: false

  @impl Authoritex
  def code, do: "geonames"

  @impl Authoritex
  def description, do: "GeoNames geographical database"

  @impl Authoritex
  def fetch(id) do
    case parse_geonames_uri(id) do
      :error ->
        {:error, 404}

      geoname_id ->
        request =
          HTTPoison.get(
            "http://api.geonames.org/getJSON",
            [{"User-Agent", "Authoritex"}],
            params: [
              geonameId: geoname_id,
              username: username()
            ]
          )
          |> autoretry()

        case request do
          {:ok, %{body: response, status_code: 200}} ->
            parse_fetch_result(response)

          {:ok, %{body: response, status_code: status_code}} ->
            {:error, parse_geonames_error(response, status_code)}

          {:error, error} ->
            {:error, error}
        end
    end
  end

  @impl Authoritex
  def search(query, max_results \\ 30) do
    request =
      HTTPoison.get(
        "http://api.geonames.org/searchJSON",
        [{"User-Agent", "Authoritex"}],
        params: [
          q: query,
          username: username(),
          maxRows: max_results
        ]
      )
      |> autoretry()

    case request do
      {:ok, %{body: response, status_code: 200}} ->
        {:ok, parse_search_result(response)}

      {:ok, %{body: response, status_code: status_code}} ->
        {:error, parse_geonames_error(response, status_code)}

      {:error, error} ->
        {:error, error}
    end
  end

  defp parse_search_result(response) do
    response
    |> Jason.decode!()
    |> Map.get("geonames")
    |> Enum.map(fn result ->
      %{
        id: make_geonames_uri(result["geonameId"]),
        label: result["name"],
        hint: parse_hint(result)
      }
    end)
  end

  defp parse_fetch_result(%{"status" => %{"message" => message, "value" => error_code}}) do
    {:error, "#{error_description(to_string(error_code))} (#{to_string(error_code)}). #{message}"}
  end

  defp parse_fetch_result(%{"geonameId" => geoname_id, "name" => name} = response) do
    hint = parse_hint(response)

    {:ok,
     Enum.into(
       [
         id: make_geonames_uri(geoname_id),
         label: name,
         hint: hint,
         qualified_label: Enum.join(Enum.filter([name, hint], & &1), ", "),
         variants: []
       ],
       %{}
     )}
  end

  defp parse_fetch_result(response) do
    case Jason.decode(response) do
      {:ok, response} ->
        parse_fetch_result(response)

      {:error, error} ->
        {:error, {:bad_response, error}}
    end
  end

  defp make_geonames_uri(geoname_id), do: @http_uri_base <> to_string(geoname_id) <> "/"

  defp parse_geonames_uri(uri) do
    with @http_uri_base <> result <- uri do
      if String.ends_with?(result, "/"),
        do: String.slice(result, 0..-2),
        else: :error
    end
  end

  defp parse_geonames_error(response, status_code) do
    case Jason.decode(response) do
      {:ok, %{"status" => %{"value" => 11}}} ->
        status_code

      {:ok, %{"status" => %{"message" => message, "value" => error_code}}} ->
        "Status #{status_code}: #{error_description(to_string(error_code))} (#{to_string(error_code)}). #{message}"

      {:error, error} ->
        {:bad_response, error}
    end
  end

  defp parse_hint(%{"fcode" => "PCLI"}), do: nil
  defp parse_hint(%{"fcode" => "RGN", "countryName" => countryName}), do: countryName
  defp parse_hint(%{"fcode" => "ADM1", "countryName" => countryName}), do: countryName

  defp parse_hint(%{"fcode" => _, "countryName" => country_name, "adminName1" => admin_name}) do
    case Enum.join(Enum.reject([admin_name, country_name], &(&1 == "")), ", ") do
      "" ->
        nil

      hint ->
        hint
    end
  end

  defp parse_hint(_), do: nil

  defp error_description(code) do
    @error_codes
    |> Map.get(code)
  end

  # coveralls-ignore-start
  defp username do
    System.get_env("GEONAMES_USERNAME") ||
      Application.get_env(:authoritex, :geonames_username)
  end

  # coveralls-ignore-stop
end