Add GeoSpatial backends for geocoding

Signed-off-by: Thomas Citharel <tcit@tcit.fr>

Geospatial Backend

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel
2019-03-12 11:52:28 +01:00
parent f7284740e3
commit 98b7618338
29 changed files with 1301 additions and 1 deletions

View File

@@ -0,0 +1,85 @@
defmodule Mobilizon.Service.Geospatial.Addok do
@moduledoc """
[Addok](https://github.com/addok/addok) backend.
"""
alias Mobilizon.Service.Geospatial.Provider
require Logger
alias Mobilizon.Addresses.Address
@behaviour Provider
@endpoint Application.get_env(:mobilizon, __MODULE__) |> get_in([:endpoint])
@impl Provider
@doc """
Addok implementation for `c:Mobilizon.Service.Geospatial.Provider.geocode/3`.
"""
@spec geocode(String.t(), keyword()) :: list(Address.t())
def geocode(lon, lat, options \\ []) do
url = build_url(:geocode, %{lon: lon, lat: lat}, options)
Logger.debug("Asking addok for addresses with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url),
{:ok, %{"features" => features}} <- Poison.decode(body) do
processData(features)
end
end
@impl Provider
@doc """
Addok 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 addok for addresses with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url),
{:ok, %{"features" => features}} <- Poison.decode(body) do
processData(features)
end
end
@spec build_url(atom(), map(), list()) :: String.t()
defp build_url(method, args, options) do
limit = Keyword.get(options, :limit, 10)
coords = Keyword.get(options, :coords, nil)
endpoint = Keyword.get(options, :endpoint, @endpoint)
case method do
:geocode ->
"#{endpoint}/reverse/?lon=#{args.lon}&lat=#{args.lat}&limit=#{limit}"
:search ->
url = "#{endpoint}/search/?q=#{URI.encode(args.q)}&limit=#{limit}"
if is_nil(coords), do: url, else: url <> "&lat=#{coords.lat}&lon=#{coords.lon}"
end
end
defp processData(features) do
features
|> Enum.map(fn %{"geometry" => geometry, "properties" => properties} ->
%Address{
addressCountry: Map.get(properties, "country"),
addressLocality: Map.get(properties, "city"),
addressRegion: Map.get(properties, "state"),
description: Map.get(properties, "name") || streetAddress(properties),
floor: Map.get(properties, "floor"),
geom: Map.get(geometry, "coordinates") |> Provider.coordinates(),
postalCode: Map.get(properties, "postcode"),
streetAddress: properties |> streetAddress()
}
end)
end
defp streetAddress(properties) do
if Map.has_key?(properties, "housenumber") do
Map.get(properties, "housenumber") <> " " <> Map.get(properties, "street")
else
Map.get(properties, "street")
end
end
end

View File

@@ -0,0 +1,15 @@
defmodule Mobilizon.Service.Geospatial do
@moduledoc """
Module to load the service adapter defined inside the configuration
See `Mobilizon.Service.Geospatial.Provider`
"""
@doc """
Returns the appropriate service adapter
According to the config behind `config :mobilizon, Mobilizon.Service.Geospatial, service: Mobilizon.Service.Geospatial.Module`
"""
@spec service() :: module()
def service(), do: Application.get_env(:mobilizon, __MODULE__) |> get_in([:service])
end

View File

@@ -0,0 +1,126 @@
defmodule Mobilizon.Service.Geospatial.GoogleMaps do
@moduledoc """
Google Maps [Geocoding service](https://developers.google.com/maps/documentation/geocoding/intro)
Note: Endpoint is hardcoded to Google Maps API
"""
alias Mobilizon.Service.Geospatial.Provider
alias Mobilizon.Addresses.Address
require Logger
@behaviour Provider
@api_key Application.get_env(:mobilizon, __MODULE__) |> get_in([:api_key])
@components [
"street_number",
"route",
"locality",
"administrative_area_level_1",
"country",
"postal_code"
]
@api_key_missing_message "API Key required to use Google Maps"
@impl Provider
@doc """
Google Maps implementation for `c:Mobilizon.Service.Geospatial.Provider.geocode/3`.
"""
@spec geocode(String.t(), 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}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url),
{:ok, %{"results" => results, "status" => "OK"}} <- Poison.decode(body) do
Enum.map(results, &process_data/1)
else
{:ok, %{"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}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url),
{:ok, %{"results" => results, "status" => "OK"}} <- Poison.decode(body) do
Enum.map(results, fn entry -> process_data(entry) end)
else
{:ok, %{"status" => "REQUEST_DENIED", "error_message" => error_message}} ->
raise ArgumentError, message: to_string(error_message)
end
end
@spec build_url(atom(), map(), list()) :: String.t()
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 =
"https://maps.googleapis.com/maps/api/geocode/json?limit=#{limit}&key=#{api_key}&language=#{
lang
}"
case method do
:search ->
url <> "&address=#{URI.encode(args.q)}"
:geocode ->
url <> "&latlng=#{args.lat},#{args.lon}"
end
end
defp process_data(%{
"formatted_address" => description,
"geometry" => %{"location" => %{"lat" => lat, "lng" => lon}},
"address_components" => components
}) do
components =
@components
|> Enum.reduce(%{}, fn component, acc ->
Map.put(acc, component, extract_component(components, component))
end)
%Address{
addressCountry: Map.get(components, "country"),
addressLocality: Map.get(components, "locality"),
addressRegion: Map.get(components, "administrative_area_level_1"),
description: description,
floor: nil,
geom: [lon, lat] |> Provider.coordinates(),
postalCode: Map.get(components, "postal_code"),
streetAddress: street_address(components)
}
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
end

View File

@@ -0,0 +1,116 @@
defmodule Mobilizon.Service.Geospatial.MapQuest do
@moduledoc """
[MapQuest](https://developer.mapquest.com/documentation) backend.
## Options
In addition to the [the shared options](Mobilizon.Service.Geospatial.Provider.html#module-shared-options),
MapQuest methods support the following options:
* `:open_data` Whether to use [Open Data or Licenced Data](https://developer.mapquest.com/documentation/open/).
Defaults to `true`
"""
alias Mobilizon.Service.Geospatial.Provider
alias Mobilizon.Addresses.Address
require Logger
@behaviour Provider
@api_key Application.get_env(:mobilizon, __MODULE__) |> get_in([:api_key])
@api_key_missing_message "API Key required to use MapQuest"
@impl Provider
@doc """
MapQuest implementation for `c:Mobilizon.Service.Geospatial.Provider.geocode/3`.
"""
@spec geocode(String.t(), keyword()) :: list(Address.t())
def geocode(lon, lat, options \\ []) do
api_key = Keyword.get(options, :api_key, @api_key)
limit = Keyword.get(options, :limit, 10)
open_data = Keyword.get(options, :open_data, true)
prefix = if open_data, do: "open", else: "www"
if is_nil(api_key), do: raise(ArgumentError, message: @api_key_missing_message)
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(
"https://#{prefix}.mapquestapi.com/geocoding/v1/reverse?key=#{api_key}&location=#{
lat
},#{lon}&maxResults=#{limit}"
),
{:ok, %{"results" => results, "info" => %{"statuscode" => 0}}} <- Poison.decode(body) do
results |> Enum.map(&processData/1)
else
{:ok, %HTTPoison.Response{status_code: 403, body: err}} ->
raise(ArgumentError, message: err)
end
end
@impl Provider
@doc """
MapQuest implementation for `c:Mobilizon.Service.Geospatial.Provider.search/2`.
"""
@spec search(String.t(), keyword()) :: list(Address.t())
def search(q, options \\ []) do
limit = Keyword.get(options, :limit, 10)
api_key = Keyword.get(options, :api_key, @api_key)
open_data = Keyword.get(options, :open_data, true)
prefix = if open_data, do: "open", else: "www"
if is_nil(api_key), do: raise(ArgumentError, message: @api_key_missing_message)
url =
"https://#{prefix}.mapquestapi.com/geocoding/v1/address?key=#{api_key}&location=#{
URI.encode(q)
}&maxResults=#{limit}"
Logger.debug("Asking MapQuest for addresses with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url),
{:ok, %{"results" => results, "info" => %{"statuscode" => 0}}} <- Poison.decode(body) do
results |> Enum.map(&processData/1)
else
{:ok, %HTTPoison.Response{status_code: 403, body: err}} ->
raise(ArgumentError, message: err)
end
end
defp processData(
%{
"locations" => addresses,
"providedLocation" => %{"latLng" => %{"lat" => lat, "lng" => lng}}
} = _body
) do
case addresses do
[] -> nil
addresses -> addresses |> hd |> produceAddress(lat, lng)
end
end
defp processData(%{"locations" => addresses}) do
case addresses do
[] -> nil
addresses -> addresses |> hd |> produceAddress()
end
end
defp produceAddress(%{"latLng" => %{"lat" => lat, "lng" => lng}} = address) do
produceAddress(address, lat, lng)
end
defp produceAddress(address, lat, lng) do
%Address{
addressCountry: Map.get(address, "adminArea1"),
addressLocality: Map.get(address, "adminArea5"),
addressRegion: Map.get(address, "adminArea3"),
description: Map.get(address, "street"),
floor: Map.get(address, "floor"),
geom: [lng, lat] |> Provider.coordinates(),
postalCode: Map.get(address, "postalCode"),
streetAddress: Map.get(address, "street")
}
end
end

View File

@@ -0,0 +1,89 @@
defmodule Mobilizon.Service.Geospatial.Nominatim do
@moduledoc """
[Nominatim](https://wiki.openstreetmap.org/wiki/Nominatim) backend.
"""
alias Mobilizon.Service.Geospatial.Provider
alias Mobilizon.Addresses.Address
require Logger
@behaviour Provider
@endpoint Application.get_env(:mobilizon, __MODULE__) |> get_in([:endpoint])
@api_key Application.get_env(:mobilizon, __MODULE__) |> get_in([:api_key])
@impl Provider
@doc """
Nominatim implementation for `c:Mobilizon.Service.Geospatial.Provider.geocode/3`.
"""
@spec geocode(String.t(), keyword()) :: list(Address.t())
def geocode(lon, lat, options \\ []) do
url = build_url(:geocode, %{lon: lon, lat: lat}, options)
Logger.debug("Asking Nominatim for geocode with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url),
{:ok, body} <- Poison.decode(body) do
[process_data(body)]
end
end
@impl Provider
@doc """
Nominatim 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 Nominatim for addresses with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url),
{:ok, body} <- Poison.decode(body) do
Enum.map(body, fn entry -> process_data(entry) end)
end
end
@spec build_url(atom(), map(), list()) :: String.t()
defp build_url(method, args, options) do
limit = Keyword.get(options, :limit, 10)
lang = Keyword.get(options, :lang, "en")
endpoint = Keyword.get(options, :endpoint, @endpoint)
api_key = Keyword.get(options, :api_key, @api_key)
url =
case method do
:search ->
"#{endpoint}/search?format=jsonv2&q=#{URI.encode(args.q)}&limit=#{limit}&accept-language=#{
lang
}&addressdetails=1"
:geocode ->
"#{endpoint}/reverse?format=jsonv2&lat=#{args.lat}&lon=#{args.lon}&addressdetails=1"
end
if is_nil(api_key), do: url, else: url <> "&key=#{api_key}"
end
@spec process_data(map()) :: Address.t()
defp process_data(%{"address" => address} = body) do
%Address{
addressCountry: Map.get(address, "country"),
addressLocality: Map.get(address, "city"),
addressRegion: Map.get(address, "state"),
description: Map.get(body, "display_name"),
floor: Map.get(address, "floor"),
geom: [Map.get(body, "lon"), Map.get(body, "lat")] |> Provider.coordinates(),
postalCode: Map.get(address, "postcode"),
streetAddress: street_address(address)
}
end
@spec street_address(map()) :: String.t()
defp street_address(body) do
if Map.has_key?(body, "house_number") do
Map.get(body, "house_number") <> " " <> Map.get(body, "road")
else
Map.get(body, "road")
end
end
end

View File

@@ -0,0 +1,87 @@
defmodule Mobilizon.Service.Geospatial.Photon do
@moduledoc """
[Photon](https://photon.komoot.de) backend.
"""
alias Mobilizon.Service.Geospatial.Provider
require Logger
alias Mobilizon.Addresses.Address
@behaviour Provider
@endpoint Application.get_env(:mobilizon, __MODULE__) |> get_in([:endpoint])
@impl Provider
@doc """
Photon implementation for `c:Mobilizon.Service.Geospatial.Provider.geocode/3`.
Note: It seems results are quite wrong.
"""
@spec geocode(number(), number(), keyword()) :: list(Address.t())
def geocode(lon, lat, options \\ []) do
url = build_url(:geocode, %{lon: lon, lat: lat}, options)
Logger.debug("Asking photon for reverse geocoding with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url),
{:ok, %{"features" => features}} <- Poison.decode(body) do
processData(features)
end
end
@impl Provider
@doc """
Photon 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 photon for addresses with #{url}")
with {:ok, %HTTPoison.Response{status_code: 200, body: body}} <-
HTTPoison.get(url),
{:ok, %{"features" => features}} <- Poison.decode(body) do
processData(features)
end
end
@spec build_url(atom(), map(), list()) :: String.t()
defp build_url(method, args, options) do
limit = Keyword.get(options, :limit, 10)
lang = Keyword.get(options, :lang, "en")
coords = Keyword.get(options, :coords, nil)
endpoint = Keyword.get(options, :endpoint, @endpoint)
case method do
:search ->
url = "#{endpoint}/api/?q=#{URI.encode(args.q)}&lang=#{lang}&limit=#{limit}"
if is_nil(coords), do: url, else: url <> "&lat=#{coords.lat}&lon=#{coords.lon}"
:geocode ->
"#{endpoint}/reverse?lon=#{args.lon}&lat=#{args.lat}&lang=#{lang}&limit=#{limit}"
end
end
defp processData(features) do
features
|> Enum.map(fn %{"geometry" => geometry, "properties" => properties} ->
%Address{
addressCountry: Map.get(properties, "country"),
addressLocality: Map.get(properties, "city"),
addressRegion: Map.get(properties, "state"),
description: Map.get(properties, "name") || streetAddress(properties),
floor: Map.get(properties, "floor"),
geom: Map.get(geometry, "coordinates") |> Provider.coordinates(),
postalCode: Map.get(properties, "postcode"),
streetAddress: properties |> streetAddress()
}
end)
end
defp streetAddress(properties) do
if Map.has_key?(properties, "housenumber") do
Map.get(properties, "housenumber") <> " " <> Map.get(properties, "street")
else
Map.get(properties, "street")
end
end
end

View File

@@ -0,0 +1,72 @@
defmodule Mobilizon.Service.Geospatial.Provider do
@moduledoc """
Provider Behaviour for Geospatial stuff.
## Supported backends
* `Mobilizon.Service.Geospatial.Nominatim` [🔗](https://wiki.openstreetmap.org/wiki/Nominatim)
* `Mobilizon.Service.Geospatial.Photon` [🔗](https://photon.komoot.de)
* `Mobilizon.Service.Geospatial.Addok` [🔗](https://github.com/addok/addok)
* `Mobilizon.Service.Geospatial.MapQuest` [🔗](https://developer.mapquest.com/documentation/open/)
* `Mobilizon.Service.Geospatial.GoogleMaps` [🔗](https://developers.google.com/maps/documentation/geocoding/intro)
## Shared options
* `:user_agent` User-Agent string to send to the backend. Defaults to `"Mobilizon"`
* `:lang` Lang in which to prefer results. Used as a request parameter or through an `Accept-Language` HTTP header.
Defaults to `"en"`.
* `:limit` Maximum limit for the number of results returned by the backend. Defaults to `10`
* `:api_key` Allows to override the API key (if the backend requires one) set inside the configuration.
* `:endpoint` Allows to override the endpoint set inside the configuration
"""
alias Mobilizon.Addresses.Address
@doc """
Get an address from longitude and latitude coordinates.
## Options
Most backends implement all of [the shared options](#module-shared-options).
## Examples
iex> geocode(48.11, -1.77)
%Address{}
"""
@callback geocode(longitude :: number(), latitude :: number(), options :: keyword()) ::
list(Address.t())
@doc """
Search for an address
## Options
In addition to [the shared options](#module-shared-options), `c:search/2` also accepts the following options:
* `coords` Map of coordinates (ex: `%{lon: 48.11, lat: -1.77}`) allowing to give a geographic priority to the search.
Defaults to `nil`
## Examples
iex> search("10 rue Jangot")
%Address{}
"""
@callback search(address :: String.t(), options :: keyword()) :: list(Address.t())
@doc """
Returns a `Geo.Point` for given coordinates
"""
@spec coordinates(list(number()), number()) :: Geo.Point.t()
def coordinates(coords, srid \\ 4326)
def coordinates([x, y], srid) when is_number(x) and is_number(y),
do: %Geo.Point{coordinates: {x, y}, srid: srid}
def coordinates([x, y], srid) when is_bitstring(x) and is_bitstring(y),
do: %Geo.Point{coordinates: {String.to_float(x), String.to_float(y)}, srid: srid}
@spec coordinates(any()) :: nil
def coordinates(_, _), do: nil
end