defmodule Kagi.Maps do
@moduledoc """
Maps response returned by `Kagi.maps/1..3`.
Contains parsed point-of-interest rows. Sorting and limiting are applied
client-side after the response is parsed.
## Fields
* `:results` - point-of-interest rows in result order.
"""
alias Kagi.Client
alias Kagi.Error
alias Kagi.HTTP
alias Kagi.MapsResult
alias Kagi.MapsResult.Coordinates
@typedoc "Maps sort mode passed via the `:sort` option."
@type sort :: :relevance | :rating | :distance | :price
@typedoc "Sort direction passed via the `:order` option."
@type order :: :asc | :desc
@typedoc "A parsed Kagi Maps response."
@type t :: %__MODULE__{results: [MapsResult.t()]}
defstruct results: []
@url "https://kagi.com/maps/api/v1/search"
@doc false
@spec request(Client.t(), String.t() | [String.t()], keyword()) ::
{:ok, t()} | {:error, Error.t()}
def request(%Client{} = client, query, options) when is_list(options) do
with {:ok, query} <- build_query(query),
{:ok, params} <- query_params(query, options),
{:ok, %{body: body}} <-
HTTP.get(client, @url,
params: params,
headers: [
{"cookie", "kagi_session=#{client.session_token}"},
{"referer", "https://kagi.com/maps"}
]
),
{:ok, json} <- normalize_body(body),
{:ok, pois} <- extract_pois(json),
{:ok, limit} <- limit(options) do
results =
pois
|> Enum.map(&parse_poi/1)
|> sort_results(options[:sort], options[:order])
|> Enum.take(limit)
{:ok, %__MODULE__{results: results}}
end
end
@doc false
@spec parse(map(), non_neg_integer()) :: {:ok, t()} | {:error, Error.t()}
def parse(json, limit) when is_map(json) and is_integer(limit) and limit >= 0 do
with {:ok, pois} <- extract_pois(json) do
results = pois |> Enum.map(&parse_poi/1) |> Enum.take(limit)
{:ok, %__MODULE__{results: results}}
end
end
@spec build_query(String.t() | [String.t()]) :: {:ok, String.t()} | {:error, Error.t()}
defp build_query(query) do
query =
query
|> List.wrap()
|> Enum.map_join(" ", &to_string/1)
|> String.trim()
if query == "" do
{:error, Error.new(:invalid_option, "query must not be empty")}
else
{:ok, query}
end
end
@spec query_params(String.t(), keyword()) :: {:ok, keyword()} | {:error, Error.t()}
defp query_params(query, options) do
with :ok <- validate_sort(options[:sort]),
:ok <- validate_order(options[:order]),
{:ok, ll} <- coerce_coordinate(options[:ll]),
{:ok, bbox} <- coerce_bbox(options[:bbox]),
{:ok, zoom} <- coerce_zoom(options[:zoom]) do
params =
[q: query]
|> put_param(:ll, ll)
|> put_param(:bbox, bbox)
|> put_param(:z, zoom)
{:ok, params}
end
end
@spec put_param(keyword(), atom(), term() | nil) :: keyword()
defp put_param(params, _key, nil), do: params
defp put_param(params, key, value), do: Keyword.put(params, key, value)
@spec validate_sort(term()) :: :ok | {:error, Error.t()}
defp validate_sort(nil), do: :ok
defp validate_sort(sort) when sort in [:relevance, :rating, :distance, :price], do: :ok
defp validate_sort(value) do
{:error, Error.new(:invalid_option, "invalid maps sort: #{inspect(value)}")}
end
@spec validate_order(term()) :: :ok | {:error, Error.t()}
defp validate_order(nil), do: :ok
defp validate_order(order) when order in [:asc, :desc], do: :ok
defp validate_order(value) do
{:error, Error.new(:invalid_option, "invalid maps order: #{inspect(value)}")}
end
@spec coerce_coordinate(term()) :: {:ok, String.t() | nil} | {:error, Error.t()}
defp coerce_coordinate(nil), do: {:ok, nil}
defp coerce_coordinate(value) when is_binary(value) do
with {:ok, [lat, lon]} <- parse_numbers(value, 2, "LAT,LON"),
:ok <- ensure_range(lat, -90.0, 90.0, "latitude"),
:ok <- ensure_range(lon, -180.0, 180.0, "longitude") do
{:ok, value}
end
end
defp coerce_coordinate(value) do
{:error,
Error.new(
:invalid_option,
":ll must be a string of the form LAT,LON, got: #{inspect(value)}"
)}
end
@spec coerce_bbox(term()) :: {:ok, String.t() | nil} | {:error, Error.t()}
defp coerce_bbox(nil), do: {:ok, nil}
defp coerce_bbox(value) when is_binary(value) do
with {:ok, [west, south, east, north]} <-
parse_numbers(value, 4, "WEST,SOUTH,EAST,NORTH"),
:ok <- ensure_range(west, -180.0, 180.0, "longitude"),
:ok <- ensure_range(east, -180.0, 180.0, "longitude"),
:ok <- ensure_range(south, -90.0, 90.0, "latitude"),
:ok <- ensure_range(north, -90.0, 90.0, "latitude"),
:ok <- ensure_distinct(west, east),
:ok <- ensure_ordered(south, north) do
{:ok, value}
end
end
defp coerce_bbox(value) do
{:error,
Error.new(
:invalid_option,
":bbox must be a string of the form WEST,SOUTH,EAST,NORTH, got: #{inspect(value)}"
)}
end
@spec coerce_zoom(term()) :: {:ok, String.t() | nil} | {:error, Error.t()}
defp coerce_zoom(nil), do: {:ok, nil}
defp coerce_zoom(value) when is_integer(value) or is_float(value) do
{:ok, to_string(value)}
end
defp coerce_zoom(value) do
{:error, Error.new(:invalid_option, ":zoom must be a number, got: #{inspect(value)}")}
end
@spec parse_numbers(String.t(), pos_integer(), String.t()) ::
{:ok, [float()]} | {:error, Error.t()}
defp parse_numbers(value, expected, format) do
parts = value |> String.split(",") |> Enum.map(&String.trim/1)
if length(parts) == expected do
parts
|> Enum.reduce_while([], &parse_number_part/2)
|> case do
{:error, :invalid} -> {:error, invalid_numbers_error(value, format)}
numbers -> {:ok, Enum.reverse(numbers)}
end
else
{:error, invalid_numbers_error(value, format)}
end
end
@spec parse_number_part(String.t(), [float()]) ::
{:cont, [float()]} | {:halt, {:error, :invalid}}
defp parse_number_part(part, acc) do
case Float.parse(part) do
{number, ""} -> {:cont, [number | acc]}
_other -> {:halt, {:error, :invalid}}
end
end
@spec invalid_numbers_error(String.t(), String.t()) :: Error.t()
defp invalid_numbers_error(value, format) do
Error.new(:invalid_option, "invalid value #{inspect(value)}, expected #{format}")
end
@spec ensure_range(float(), float(), float(), String.t()) :: :ok | {:error, Error.t()}
defp ensure_range(value, min, max, label) do
if value >= min and value <= max do
:ok
else
{:error,
Error.new(:invalid_option, "#{label} #{value} is outside the range #{min}..#{max}")}
end
end
@spec ensure_distinct(float(), float()) :: :ok | {:error, Error.t()}
defp ensure_distinct(west, east) do
if west == east do
{:error, Error.new(:invalid_option, "bbox WEST and EAST must differ")}
else
:ok
end
end
@spec ensure_ordered(float(), float()) :: :ok | {:error, Error.t()}
defp ensure_ordered(south, north) do
if south < north do
:ok
else
{:error, Error.new(:invalid_option, "bbox SOUTH must be less than NORTH")}
end
end
@spec limit(keyword()) :: {:ok, non_neg_integer()} | {:error, Error.t()}
defp limit(options) do
case Keyword.get(options, :limit, 10) do
value when is_integer(value) and value >= 0 ->
{:ok, value}
value ->
{:error,
Error.new(
:invalid_option,
":limit must be a non-negative integer, got: #{inspect(value)}"
)}
end
end
@spec normalize_body(term()) :: {:ok, map()} | {:error, Error.t()}
defp normalize_body(body) when is_map(body), do: {:ok, body}
defp normalize_body(body) when is_binary(body) do
case JSON.decode(body) do
{:ok, value} when is_map(value) ->
{:ok, value}
{:ok, value} ->
{:error,
Error.new(:parse_error, "maps response must be a JSON object, got: #{inspect(value)}")}
{:error, reason} ->
{:error, Error.new(:parse_error, "failed to decode maps JSON: #{inspect(reason)}")}
end
end
defp normalize_body(body) do
{:error,
Error.new(:parse_error, "expected maps response body to be JSON, got: #{inspect(body)}")}
end
@spec extract_pois(map()) :: {:ok, [map()]} | {:error, Error.t()}
defp extract_pois(%{"pois" => pois}) when is_list(pois), do: {:ok, pois}
defp extract_pois(json) do
{:error, Error.new(:parse_error, "maps response missing 'pois' array: #{inspect(json)}")}
end
@spec parse_poi(map()) :: MapsResult.t()
defp parse_poi(poi) when is_map(poi) do
%MapsResult{
name: Map.get(poi, "name"),
address: Map.get(poi, "address"),
coordinates: parse_coordinates(Map.get(poi, "coordinates")),
phone: Map.get(poi, "phone"),
url: Map.get(poi, "url"),
source: Map.get(poi, "source"),
id: Map.get(poi, "id_k") || Map.get(poi, "id"),
rating: Map.get(poi, "rating"),
review_count: Map.get(poi, "reviewCount"),
price: Map.get(poi, "price"),
distance: Map.get(poi, "distance"),
hours_now: Map.get(poi, "hours_now"),
types: Map.get(poi, "types"),
links: Map.get(poi, "links"),
images: Map.get(poi, "images")
}
end
@spec parse_coordinates(term()) :: Coordinates.t() | nil
defp parse_coordinates(%{"latitude" => lat, "longitude" => lon}) do
%Coordinates{latitude: lat, longitude: lon}
end
defp parse_coordinates(_other), do: nil
@doc false
@spec sort_results([MapsResult.t()], sort() | nil, order() | nil) :: [MapsResult.t()]
def sort_results(results, nil, _order), do: results
def sort_results(results, :relevance, _order), do: results
def sort_results(results, sort, order) do
direction = direction(order || default_order(sort))
key_fun = key_fun(sort)
Enum.sort_by(results, key_fun, sort_compare(direction))
end
@spec key_fun(sort()) :: (MapsResult.t() -> term())
defp key_fun(:rating), do: & &1.rating
defp key_fun(:distance), do: & &1.distance
defp key_fun(:price), do: fn result -> result.price && String.length(result.price) end
@spec default_order(sort()) :: order()
defp default_order(:rating), do: :desc
defp default_order(:distance), do: :asc
defp default_order(:price), do: :asc
@spec direction(order()) :: :asc | :desc
defp direction(:asc), do: :asc
defp direction(:desc), do: :desc
@spec sort_compare(:asc | :desc) :: (term(), term() -> boolean())
defp sort_compare(:asc) do
fn left, right ->
case {left, right} do
{nil, nil} -> true
{nil, _right} -> false
{_left, nil} -> true
{left, right} -> left <= right
end
end
end
defp sort_compare(:desc) do
fn left, right ->
case {left, right} do
{nil, nil} -> true
{nil, _right} -> false
{_left, nil} -> true
{left, right} -> left >= right
end
end
end
end