mobilizon/lib/service/geospatial/google_maps.ex
Thomas Citharel 999a33c7c3
Typespec fixes and refactoring
Signed-off-by: Thomas Citharel <tcit@tcit.fr>
2022-05-03 12:23:09 +02:00

204 lines
6.2 KiB
Elixir

defmodule Mobilizon.Service.Geospatial.GoogleMaps do
@moduledoc """
Google Maps [Geocoding service](https://developers.google.com/maps/documentation/geocoding/intro). Only works with addresses.
Note: Endpoint is hardcoded to Google Maps API.
"""
alias Mobilizon.Addresses.Address
alias Mobilizon.Service.Geospatial.Provider
alias Mobilizon.Service.HTTP.GeospatialClient
require Logger
@behaviour Provider
@components [
"street_number",
"route",
"locality",
"administrative_area_level_1",
"country",
"postal_code"
]
@api_key_missing_message "API Key required to use Google Maps"
@geocode_endpoint "https://maps.googleapis.com/maps/api/geocode/json"
@details_endpoint "https://maps.googleapis.com/maps/api/place/details/json"
@impl Provider
@doc """
Google Maps implementation for `c:Mobilizon.Service.Geospatial.Provider.geocode/3`.
"""
@spec geocode(float(), float(), keyword()) :: list(Address.t())
def geocode(lon, lat, options \\ []) do
url = build_url(:geocode, %{lon: lon, lat: lat}, options)
Logger.debug("Asking Google Maps for reverse geocode with #{url}")
%Tesla.Env{status: 200, body: body} = GeospatialClient.get!(url)
case body do
%{"results" => results, "status" => "OK"} ->
Enum.map(results, &process_data(&1, options))
%{"status" => "REQUEST_DENIED", "error_message" => error_message} ->
raise ArgumentError, message: to_string(error_message)
end
end
@impl Provider
@doc """
Google Maps implementation for `c:Mobilizon.Service.Geospatial.Provider.search/2`.
"""
@spec search(String.t(), keyword()) :: list(Address.t())
def search(q, options \\ []) do
url = build_url(:search, %{q: q}, options)
Logger.debug("Asking Google Maps for addresses with #{url}")
%Tesla.Env{status: 200, body: body} = GeospatialClient.get!(url)
case body do
%{"results" => results, "status" => "OK"} ->
results |> Enum.map(fn entry -> process_data(entry, options) end)
%{"status" => "REQUEST_DENIED", "error_message" => error_message} ->
raise ArgumentError, message: to_string(error_message)
%{"results" => [], "status" => "ZERO_RESULTS"} ->
[]
end
end
@spec build_url(:search | :geocode | :place_details, map(), list()) :: String.t() | no_return
defp build_url(method, args, options) do
limit = Keyword.get(options, :limit, 10)
lang = Keyword.get(options, :lang, "en")
api_key = Keyword.get(options, :api_key, api_key())
if is_nil(api_key), do: raise(ArgumentError, message: @api_key_missing_message)
url = "#{@geocode_endpoint}?limit=#{limit}&key=#{api_key}&language=#{lang}"
uri =
case method do
:search ->
"#{url}&address=#{args.q}"
|> add_parameter(options, :type)
:geocode ->
zoom = Keyword.get(options, :zoom, 15)
result_type = if zoom >= 15, do: "street_address", else: "locality"
url <> "&latlng=#{args.lat},#{args.lon}&result_type=#{result_type}"
:place_details ->
"#{@details_endpoint}?key=#{api_key}&placeid=#{args.place_id}"
end
URI.encode(uri)
end
@spec process_data(map(), Keyword.t()) :: Address.t()
defp process_data(
%{
"formatted_address" => description,
"geometry" => %{"location" => %{"lat" => lat, "lng" => lon}},
"address_components" => components,
"place_id" => place_id
},
options
) do
components =
@components
|> Enum.reduce(%{}, fn component, acc ->
Map.put(acc, component, extract_component(components, component))
end)
description =
if Keyword.get(options, :fetch_place_details, fetch_place_details()) == true do
do_fetch_place_details(place_id, options) || description
else
description
end
coordinates = Provider.coordinates([lon, lat])
%Address{
country: Map.get(components, "country"),
locality: Map.get(components, "locality"),
region: Map.get(components, "administrative_area_level_1"),
description: description,
geom: coordinates,
timezone: Provider.timezone(coordinates),
postal_code: Map.get(components, "postal_code"),
street: street_address(components),
origin_id: "gm:" <> to_string(place_id)
}
end
defp extract_component(components, key) do
case components
|> Enum.filter(fn component -> key in component["types"] end)
|> Enum.map(& &1["long_name"]) do
[] -> nil
component -> hd(component)
end
end
defp street_address(body) do
if Map.has_key?(body, "street_number") && !is_nil(Map.get(body, "street_number")) do
Map.get(body, "street_number") <> " " <> Map.get(body, "route")
else
Map.get(body, "route")
end
end
@spec do_fetch_place_details(String.t() | nil, Keyword.t()) :: String.t() | nil
defp do_fetch_place_details(place_id, options) do
url = build_url(:place_details, %{place_id: place_id}, options)
Logger.debug("Asking Google Maps for details with #{url}")
%Tesla.Env{status: 200, body: body} = GeospatialClient.get!(url)
case body do
%{"result" => %{"name" => name}, "status" => "OK"} ->
name
%{"status" => "REQUEST_DENIED", "error_message" => error_message} ->
raise ArgumentError, message: to_string(error_message)
%{"status" => "INVALID_REQUEST"} ->
raise ArgumentError, message: "Invalid Request"
%{"results" => [], "status" => "ZERO_RESULTS"} ->
nil
end
end
@spec add_parameter(String.t(), Keyword.t(), atom()) :: String.t()
defp add_parameter(url, options, key, default \\ nil) do
value = Keyword.get(options, key, default)
if is_nil(value), do: url, else: do_add_parameter(url, key, value)
end
@spec do_add_parameter(String.t(), atom(), any()) :: String.t()
defp do_add_parameter(url, :type, :administrative),
do: "#{url}&components=administrative_area"
defp do_add_parameter(url, :type, _), do: url
defp api_key do
Application.get_env(:mobilizon, __MODULE__) |> get_in([:api_key])
end
defp fetch_place_details do
(Application.get_env(:mobilizon, __MODULE__)
|> get_in([:fetch_place_details])) in [true, "true", "True"]
end
end