Merge branch 'geospatial' into 'master'

Add GeoSpatial backends for geocoding

See merge request framasoft/mobilizon!95
This commit is contained in:
Thomas Citharel 2019-03-15 16:44:12 +01:00
commit c20eaa379c
36 changed files with 1490 additions and 8 deletions

View file

@ -64,3 +64,20 @@ config :arc,
storage: Arc.Storage.Local storage: Arc.Storage.Local
config :phoenix, :format_encoders, json: Jason config :phoenix, :format_encoders, json: Jason
config :mobilizon, Mobilizon.Service.Geospatial.Nominatim,
endpoint:
System.get_env("GEOSPATIAL_NOMINATIM_ENDPOINT") || "https://nominatim.openstreetmap.org",
api_key: System.get_env("GEOSPATIAL_NOMINATIM_API_KEY") || nil
config :mobilizon, Mobilizon.Service.Geospatial.Addok,
endpoint: System.get_env("GEOSPATIAL_ADDOK_ENDPOINT") || "https://api-adresse.data.gouv.fr"
config :mobilizon, Mobilizon.Service.Geospatial.Photon,
endpoint: System.get_env("GEOSPATIAL_PHOTON_ENDPOINT") || "https://photon.komoot.de"
config :mobilizon, Mobilizon.Service.Geospatial.GoogleMaps,
api_key: System.get_env("GEOSPATIAL_GOOGLE_MAPS_API_KEY") || nil
config :mobilizon, Mobilizon.Service.Geospatial.MapQuest,
api_key: System.get_env("GEOSPATIAL_MAP_QUEST_API_KEY") || nil

View file

@ -58,6 +58,8 @@ config :mobilizon, Mobilizon.Mailer,
# Do not print debug messages in production # Do not print debug messages in production
config :logger, level: System.get_env("MOBILIZON_LOGLEVEL") |> String.to_atom() || :info config :logger, level: System.get_env("MOBILIZON_LOGLEVEL") |> String.to_atom() || :info
config :mobilizon, Mobilizon.Service.Geospatial, service: Mobilizon.Service.Geospatial.Nominatim
# ## SSL Support # ## SSL Support
# #
# To get SSL working, you will need to add the `https` key # To get SSL working, you will need to add the `https` key

View file

@ -32,3 +32,5 @@ config :mobilizon, Mobilizon.Mailer, adapter: Bamboo.TestAdapter
config :exvcr, config :exvcr,
vcr_cassette_library_dir: "test/fixtures/vcr_cassettes" vcr_cassette_library_dir: "test/fixtures/vcr_cassettes"
config :mobilizon, Mobilizon.Service.Geospatial, service: Mobilizon.Service.Geospatial.Mock

View file

@ -108,6 +108,7 @@ defmodule Mobilizon.Addresses do
@doc """ @doc """
Processes raw geo data informations and return a `Geo` geometry which can be one of `Geo.Point`. Processes raw geo data informations and return a `Geo` geometry which can be one of `Geo.Point`.
""" """
# TODO: Unused, remove me
def process_geom(%{"type" => type_input, "data" => data}) do def process_geom(%{"type" => type_input, "data" => data}) do
type = type =
if !is_atom(type_input) && type_input != nil do if !is_atom(type_input) && type_input != nil do
@ -145,4 +146,62 @@ defmodule Mobilizon.Addresses do
defp process_point(_, _) do defp process_point(_, _) do
{:error, "Latitude and longitude must be numbers"} {:error, "Latitude and longitude must be numbers"}
end end
@doc """
Search addresses in our database
We only look at the description for now, and eventually order by object distance
"""
@spec search_addresses(String.t(), list()) :: list(Address.t())
def search_addresses(search, options) do
limit = Keyword.get(options, :limit, 5)
query = from(a in Address, where: ilike(a.description, ^"%#{search}%"), limit: ^limit)
query =
if coords = Keyword.get(options, :coords, false),
do:
from(a in query,
order_by: [fragment("? <-> ?", a.geom, ^"POINT(#{coords.lon} #{coords.lat})'")]
),
else: query
query =
if country = Keyword.get(options, :country, nil),
do: from(a in query, where: ilike(a.addressCountry, ^"%#{country}%")),
else: query
Repo.all(query)
end
@doc """
Reverse geocode from coordinates in our database
We only take addresses 50km around and sort them by distance
"""
@spec reverse_geocode(number(), number(), list()) :: list(Address.t())
def reverse_geocode(lon, lat, options) do
limit = Keyword.get(options, :limit, 5)
radius = Keyword.get(options, :radius, 50_000)
country = Keyword.get(options, :country, nil)
srid = Keyword.get(options, :srid, 4326)
import Geo.PostGIS
with {:ok, point} <- Geo.WKT.decode("SRID=#{srid};POINT(#{lon} #{lat})") do
query =
from(a in Address,
order_by: [fragment("? <-> ?", a.geom, ^point)],
limit: ^limit,
where: st_dwithin_in_meters(^point, a.geom, ^radius)
)
query =
if country,
do: from(a in query, where: ilike(a.addressCountry, ^"%#{country}%")),
else: query
Repo.all(query)
end
end
end end

View file

@ -12,11 +12,17 @@ defmodule MobilizonWeb.Context do
end end
def call(conn, _) do def call(conn, _) do
with %User{} = user <- Guardian.Plug.current_resource(conn) do context = %{ip: to_string(:inet_parse.ntoa(conn.remote_ip))}
put_private(conn, :absinthe, %{context: %{current_user: user}})
else context =
case Guardian.Plug.current_resource(conn) do
%User{} = user ->
Map.put(context, :current_user, user)
nil -> nil ->
conn context
end end
put_private(conn, :absinthe, %{context: context})
end end
end end

View file

@ -0,0 +1,33 @@
defmodule MobilizonWeb.Resolvers.Address do
@moduledoc """
Handles the comment-related GraphQL calls
"""
require Logger
alias Mobilizon.Addresses
alias Mobilizon.Service.Geospatial
def search(_parent, %{query: query}, %{context: %{ip: ip}}) do
country = Geolix.lookup(ip) |> Map.get(:country, nil)
local_addresses = Task.async(fn -> Addresses.search_addresses(query, country: country) end)
remote_addresses = Task.async(fn -> Geospatial.service().search(query) end)
addresses = Task.await(local_addresses) ++ Task.await(remote_addresses)
{:ok, addresses}
end
def reverse_geocode(_parent, %{longitude: longitude, latitude: latitude}, %{context: %{ip: ip}}) do
country = Geolix.lookup(ip) |> Map.get(:country, nil)
local_addresses =
Task.async(fn -> Addresses.reverse_geocode(longitude, latitude, country: country) end)
remote_addresses = Task.async(fn -> Geospatial.service().geocode(longitude, latitude) end)
addresses = Task.await(local_addresses) ++ Task.await(remote_addresses)
{:ok, addresses}
end
end

View file

@ -132,6 +132,7 @@ defmodule MobilizonWeb.Schema do
import_fields(:event_queries) import_fields(:event_queries)
import_fields(:participant_queries) import_fields(:participant_queries)
import_fields(:tag_queries) import_fields(:tag_queries)
import_fields(:address_queries)
end end
@desc """ @desc """

View file

@ -3,6 +3,7 @@ defmodule MobilizonWeb.Schema.AddressType do
Schema representation for Address Schema representation for Address
""" """
use Absinthe.Schema.Notation use Absinthe.Schema.Notation
alias MobilizonWeb.Resolvers
object :physical_address do object :physical_address do
field(:type, :address_type) field(:type, :address_type)
@ -36,4 +37,21 @@ defmodule MobilizonWeb.Schema.AddressType do
value(:phone, description: "The address is a phone number for a conference") value(:phone, description: "The address is a phone number for a conference")
value(:other, description: "The address is something else") value(:other, description: "The address is something else")
end end
object :address_queries do
@desc "Search for an address"
field :search_address, type: list_of(:physical_address) do
arg(:query, non_null(:string))
resolve(&Resolvers.Address.search/3)
end
@desc "Reverse geocode coordinates"
field :reverse_geocode, type: list_of(:physical_address) do
arg(:longitude, non_null(:float))
arg(:latitude, non_null(:float))
resolve(&Resolvers.Address.reverse_geocode/3)
end
end
end end

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

View file

@ -65,7 +65,6 @@ defmodule Mobilizon.Mixfile do
{:geo, "~> 3.0"}, {:geo, "~> 3.0"},
{:geo_postgis, "~> 3.1"}, {:geo_postgis, "~> 3.1"},
{:timex, "~> 3.0"}, {:timex, "~> 3.0"},
# Waiting for new release
{:icalendar, "~> 0.7"}, {:icalendar, "~> 0.7"},
{:exgravatar, "~> 2.0.1"}, {:exgravatar, "~> 2.0.1"},
{:httpoison, "~> 1.0"}, {:httpoison, "~> 1.0"},
@ -89,6 +88,7 @@ defmodule Mobilizon.Mixfile do
{:atomex, "0.3.0"}, {:atomex, "0.3.0"},
{:cachex, "~> 3.1"}, {:cachex, "~> 3.1"},
{:earmark, "~> 1.3.1"}, {:earmark, "~> 1.3.1"},
{:geohax, "~> 0.3.0"},
# Dev and test dependencies # Dev and test dependencies
{:phoenix_live_reload, "~> 1.2", only: :dev}, {:phoenix_live_reload, "~> 1.2", only: :dev},
{:ex_machina, "~> 2.2", only: [:dev, :test]}, {:ex_machina, "~> 2.2", only: [:dev, :test]},

View file

@ -48,6 +48,7 @@
"gen_smtp": {:hex, :gen_smtp, "0.12.0", "97d44903f5ca18ca85cb39aee7d9c77e98d79804bbdef56078adcf905cb2ef00", [:rebar3], [], "hexpm"}, "gen_smtp": {:hex, :gen_smtp, "0.12.0", "97d44903f5ca18ca85cb39aee7d9c77e98d79804bbdef56078adcf905cb2ef00", [:rebar3], [], "hexpm"},
"geo": {:hex, :geo, "3.1.0", "727e005262430d037e870ff364e65d80ca5ca21d5ac8eddd57a1ada72c3f83b0", [:mix], [], "hexpm"}, "geo": {:hex, :geo, "3.1.0", "727e005262430d037e870ff364e65d80ca5ca21d5ac8eddd57a1ada72c3f83b0", [:mix], [], "hexpm"},
"geo_postgis": {:hex, :geo_postgis, "3.1.0", "d06c8fa5fd140a52a5c9dab4ad6623a696dd7d99dd791bb361d3f94942442ff9", [:mix], [{:geo, "~> 3.1", [hex: :geo, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0 or ~> 4.0", [hex: :poison, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm"}, "geo_postgis": {:hex, :geo_postgis, "3.1.0", "d06c8fa5fd140a52a5c9dab4ad6623a696dd7d99dd791bb361d3f94942442ff9", [:mix], [{:geo, "~> 3.1", [hex: :geo, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0 or ~> 4.0", [hex: :poison, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.14", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm"},
"geohax": {:hex, :geohax, "0.3.0", "c2e7d8cc6cdf4158120b50fcbe03a296da561d2089eb7ad68d84b6f5d3df5607", [:mix], [], "hexpm"},
"geolix": {:hex, :geolix, "0.17.0", "8f3f4068be08599912de67ae24372a6c148794a0152f9f83ffd5a2ffcb21d29a", [:mix], [{:mmdb2_decoder, "~> 0.3.0", [hex: :mmdb2_decoder, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.0", [hex: :poolboy, repo: "hexpm", optional: false]}], "hexpm"}, "geolix": {:hex, :geolix, "0.17.0", "8f3f4068be08599912de67ae24372a6c148794a0152f9f83ffd5a2ffcb21d29a", [:mix], [{:mmdb2_decoder, "~> 0.3.0", [hex: :mmdb2_decoder, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.0", [hex: :poolboy, repo: "hexpm", optional: false]}], "hexpm"},
"gettext": {:hex, :gettext, "0.16.1", "e2130b25eebcbe02bb343b119a07ae2c7e28bd4b146c4a154da2ffb2b3507af2", [:mix], [], "hexpm"}, "gettext": {:hex, :gettext, "0.16.1", "e2130b25eebcbe02bb343b119a07ae2c7e28bd4b146c4a154da2ffb2b3507af2", [:mix], [], "hexpm"},
"guardian": {:hex, :guardian, "1.2.1", "bdc8dd3dbf0fb7216cb6f91c11831faa1a64d39cdaed9a611e37f2413e584983", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.3", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm"}, "guardian": {:hex, :guardian, "1.2.1", "bdc8dd3dbf0fb7216cb6f91c11831faa1a64d39cdaed9a611e37f2413e584983", [:mix], [{:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.3", [hex: :phoenix, repo: "hexpm", optional: true]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm"},

View file

@ -0,0 +1,28 @@
[
{
"request": {
"body": "",
"headers": [],
"method": "get",
"options": [],
"request_body": "",
"url": "https://api-adresse.data.gouv.fr/reverse/?lon=4.842569&lat=45.751718"
},
"response": {
"binary": false,
"body": "{\"limit\": 1, \"features\": [{\"geometry\": {\"coordinates\": [4.842569, 45.751718], \"type\": \"Point\"}, \"properties\": {\"y\": 6518613.6, \"city\": \"Lyon\", \"label\": \"10 Rue Jangot 69007 Lyon\", \"score\": 1.0, \"distance\": 0, \"type\": \"housenumber\", \"street\": \"Rue Jangot\", \"name\": \"10 Rue Jangot\", \"x\": 843191.7, \"id\": \"69387_3650_f5ec2a\", \"housenumber\": \"10\", \"citycode\": \"69387\", \"context\": \"69, Rh\\u00f4ne, Auvergne-Rh\\u00f4ne-Alpes (Rh\\u00f4ne-Alpes)\", \"postcode\": \"69007\", \"importance\": 0.3164}, \"type\": \"Feature\"}], \"attribution\": \"BAN\", \"version\": \"draft\", \"type\": \"FeatureCollection\", \"licence\": \"ODbL 1.0\"}",
"headers": {
"Server": "nginx/1.13.4",
"Date": "Wed, 13 Mar 2019 17:22:17 GMT",
"Content-Type": "application/json; charset=utf-8",
"Content-Length": "598",
"Connection": "keep-alive",
"X-Cache-Status": "MISS",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "X-Requested-With"
},
"status_code": 200,
"type": "ok"
}
}
]

View file

@ -0,0 +1,29 @@
[
{
"request": {
"body": "",
"headers": [],
"method": "get",
"options": [],
"request_body": "",
"url": "https://api-adresse.data.gouv.fr/search/?q=10%20rue%20Jangot&limit=10"
},
"response": {
"binary": false,
"body": "{\"limit\": 10, \"features\": [{\"geometry\": {\"coordinates\": [4.842569, 45.751718], \"type\": \"Point\"}, \"properties\": {\"y\": 6518573.3, \"city\": \"Lyon\", \"label\": \"10 Rue Jangot 69007 Lyon\", \"score\": 0.8469454545454544, \"type\": \"housenumber\", \"street\": \"Rue Jangot\", \"name\": \"10 Rue Jangot\", \"x\": 843232.2, \"id\": \"ADRNIVX_0000000260022046\", \"housenumber\": \"10\", \"citycode\": \"69387\", \"context\": \"69, Rh\\u00f4ne, Auvergne-Rh\\u00f4ne-Alpes (Rh\\u00f4ne-Alpes)\", \"postcode\": \"69007\", \"importance\": 0.3164}, \"type\": \"Feature\"}, {\"geometry\": {\"coordinates\": [2.440118, 50.371066], \"type\": \"Point\"}, \"properties\": {\"y\": 7030518.3, \"city\": \"Bailleul-aux-Cornailles\", \"label\": \"Rue Jangon 62127 Bailleul-aux-Cornailles\", \"score\": 0.5039055944055943, \"name\": \"Rue Jangon\", \"x\": 660114.7, \"id\": \"62070_0100_9b8d3c\", \"type\": \"street\", \"citycode\": \"62070\", \"context\": \"62, Pas-de-Calais, Hauts-de-France (Nord-Pas-de-Calais)\", \"postcode\": \"62127\", \"importance\": 0.0045}, \"type\": \"Feature\"}], \"attribution\": \"BAN\", \"version\": \"draft\", \"type\": \"FeatureCollection\", \"licence\": \"ODbL 1.0\", \"query\": \"10 rue Jangot\"}",
"headers": {
"Server": "nginx/1.13.4",
"Date": "Wed, 13 Mar 2019 17:01:21 GMT",
"Content-Type": "application/json; charset=utf-8",
"Content-Length": "1087",
"Connection": "keep-alive",
"Vary": "Accept-Encoding",
"X-Cache-Status": "MISS",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "X-Requested-With"
},
"status_code": 200,
"type": "ok"
}
}
]

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,34 @@
[
{
"request": {
"body": "",
"headers": [],
"method": "get",
"options": [],
"request_body": "",
"url": "https://maps.googleapis.com/maps/api/geocode/json?address=10%20rue%20Jangot&limit=10&key=toto&language=en"
},
"response": {
"binary": false,
"body": "{\n \"results\" : [\n {\n \"address_components\" : [\n {\n \"long_name\" : \"10\",\n \"short_name\" : \"10\",\n \"types\" : [ \"street_number\" ]\n },\n {\n \"long_name\" : \"Rue Jangot\",\n \"short_name\" : \"Rue Jangot\",\n \"types\" : [ \"route\" ]\n },\n {\n \"long_name\" : \"Lyon\",\n \"short_name\" : \"Lyon\",\n \"types\" : [ \"locality\", \"political\" ]\n },\n {\n \"long_name\" : \"Rhône\",\n \"short_name\" : \"Rhône\",\n \"types\" : [ \"administrative_area_level_2\", \"political\" ]\n },\n {\n \"long_name\" : \"Auvergne-Rhône-Alpes\",\n \"short_name\" : \"Auvergne-Rhône-Alpes\",\n \"types\" : [ \"administrative_area_level_1\", \"political\" ]\n },\n {\n \"long_name\" : \"France\",\n \"short_name\" : \"FR\",\n \"types\" : [ \"country\", \"political\" ]\n },\n {\n \"long_name\" : \"69007\",\n \"short_name\" : \"69007\",\n \"types\" : [ \"postal_code\" ]\n }\n ],\n \"formatted_address\" : \"10 Rue Jangot, 69007 Lyon, France\",\n \"geometry\" : {\n \"location\" : {\n \"lat\" : 45.75164940000001,\n \"lng\" : 4.8424032\n },\n \"location_type\" : \"ROOFTOP\",\n \"viewport\" : {\n \"northeast\" : {\n \"lat\" : 45.75299838029151,\n \"lng\" : 4.843752180291502\n },\n \"southwest\" : {\n \"lat\" : 45.75030041970851,\n \"lng\" : 4.841054219708497\n }\n }\n },\n \"place_id\" : \"ChIJtW0QikTq9EcRLI4Vy6bRx0U\",\n \"plus_code\" : {\n \"compound_code\" : \"QR2R+MX Lyon, France\",\n \"global_code\" : \"8FQ6QR2R+MX\"\n },\n \"types\" : [ \"street_address\" ]\n }\n ],\n \"status\" : \"OK\"\n}\n",
"headers": {
"Content-Type": "application/json; charset=UTF-8",
"Date": "Wed, 13 Mar 2019 17:50:19 GMT",
"Expires": "Thu, 14 Mar 2019 17:50:19 GMT",
"Access-Control-Allow-Origin": "*",
"Server": "mafe",
"X-XSS-Protection": "1; mode=block",
"X-Frame-Options": "SAMEORIGIN",
"Server-Timing": "gfet4t7; dur=52",
"Cache-Control": "public, max-age=86400",
"Age": "17",
"Alt-Svc": "quic=\":443\"; ma=2592000; v=\"46,44,43,39\"",
"Accept-Ranges": "none",
"Vary": "Accept-Encoding",
"Transfer-Encoding": "chunked"
},
"status_code": 200,
"type": "ok"
}
}
]

View file

@ -0,0 +1,36 @@
[
{
"request": {
"body": "",
"headers": [],
"method": "get",
"options": [],
"request_body": "",
"url": "https://open.mapquestapi.com/geocoding/v1/reverse?key=secret_key&location=45.751718,4.842569&maxResults=10"
},
"response": {
"binary": false,
"body": "{\"info\":{\"statuscode\":0,\"copyright\":{\"text\":\"\\u00A9 2019 MapQuest, Inc.\",\"imageUrl\":\"http://api.mqcdn.com/res/mqlogo.gif\",\"imageAltText\":\"\\u00A9 2019 MapQuest, Inc.\"},\"messages\":[]},\"options\":{\"maxResults\":1,\"thumbMaps\":true,\"ignoreLatLngInput\":false},\"results\":[{\"providedLocation\":{\"latLng\":{\"lat\":45.751718,\"lng\":4.842569}},\"locations\":[{\"street\":\"10 Rue Jangot\",\"adminArea6\":\"\",\"adminArea6Type\":\"Neighborhood\",\"adminArea5\":\"Lyon\",\"adminArea5Type\":\"City\",\"adminArea4\":\"\",\"adminArea4Type\":\"County\",\"adminArea3\":\"Auvergne-Rh\\u00F4ne-Alpes\",\"adminArea3Type\":\"State\",\"adminArea1\":\"FR\",\"adminArea1Type\":\"Country\",\"postalCode\":\"69007\",\"geocodeQualityCode\":\"P1AAA\",\"geocodeQuality\":\"POINT\",\"dragPoint\":false,\"sideOfStreet\":\"N\",\"linkId\":\"0\",\"unknownInput\":\"\",\"type\":\"s\",\"latLng\":{\"lat\":45.751714,\"lng\":4.842566},\"displayLatLng\":{\"lat\":45.751714,\"lng\":4.842566},\"mapUrl\":\"http://open.mapquestapi.com/staticmap/v5/map?key=secret_key&type=map&size=225,160&locations=45.7517141,4.8425657|marker-sm-50318A-1&scalebar=true&zoom=15&rand=-570915433\"}]}]}",
"headers": {
"Access-Control-Allow-Methods": "OPTIONS,GET,POST",
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-cache, must-revalidate",
"Content-Type": "application/json;charset=UTF-8",
"Date": "Thu, 14 Mar 2019 09:27:01 GMT",
"Expires": "Mon, 20 Dec 1998 01:00:00 GMT",
"GeocodeTransactionCount": "0",
"Last-Modified": "Thu, 14 Mar 2019 09:27:01 GMT",
"Pragma": "no-cache",
"ReverseGeocodeTransactionCount": "1",
"Server": "Apache-Coyote/1.1",
"Set-Cookie": "JSESSIONID=something; Path=/; HttpOnly",
"status": "success",
"transactionWeight": "1.0",
"Content-Length": "1063",
"Connection": "keep-alive"
},
"status_code": 200,
"type": "ok"
}
}
]

View file

@ -0,0 +1,36 @@
[
{
"request": {
"body": "",
"headers": [],
"method": "get",
"options": [],
"request_body": "",
"url": "https://open.mapquestapi.com/geocoding/v1/address?key=secret_key&location=10%20rue%20Jangot&maxResults=10"
},
"response": {
"binary": false,
"body": "{\"info\":{\"statuscode\":0,\"copyright\":{\"text\":\"\\u00A9 2019 MapQuest, Inc.\",\"imageUrl\":\"http://api.mqcdn.com/res/mqlogo.gif\",\"imageAltText\":\"\\u00A9 2019 MapQuest, Inc.\"},\"messages\":[]},\"options\":{\"maxResults\":10,\"thumbMaps\":true,\"ignoreLatLngInput\":false},\"results\":[{\"providedLocation\":{\"location\":\"10 rue Jangot\"},\"locations\":[{\"street\":\"10 Rue Jangot\",\"adminArea6\":\"7e\",\"adminArea6Type\":\"Neighborhood\",\"adminArea5\":\"Lyon\",\"adminArea5Type\":\"City\",\"adminArea4\":\"Lyon\",\"adminArea4Type\":\"County\",\"adminArea3\":\"Auvergne-Rh\\u00F4ne-Alpes\",\"adminArea3Type\":\"State\",\"adminArea1\":\"FR\",\"adminArea1Type\":\"Country\",\"postalCode\":\"69007\",\"geocodeQualityCode\":\"P1AXX\",\"geocodeQuality\":\"POINT\",\"dragPoint\":false,\"sideOfStreet\":\"N\",\"linkId\":\"0\",\"unknownInput\":\"\",\"type\":\"s\",\"latLng\":{\"lat\":45.751714,\"lng\":4.842566},\"displayLatLng\":{\"lat\":45.751714,\"lng\":4.842566},\"mapUrl\":\"http://open.mapquestapi.com/staticmap/v5/map?key=secret_key&type=map&size=225,160&locations=45.7517141,4.8425657|marker-sm-50318A-1&scalebar=true&zoom=15&rand=1358091752\"}]}]}",
"headers": {
"Access-Control-Allow-Methods": "OPTIONS,GET,POST",
"Access-Control-Allow-Origin": "*",
"Cache-Control": "no-cache, must-revalidate",
"Content-Type": "application/json;charset=UTF-8",
"Date": "Thu, 14 Mar 2019 09:27:01 GMT",
"Expires": "Mon, 20 Dec 1998 01:00:00 GMT",
"GeocodeTransactionCount": "1",
"Last-Modified": "Thu, 14 Mar 2019 09:27:01 GMT",
"Pragma": "no-cache",
"ReverseGeocodeTransactionCount": "0",
"Server": "Apache-Coyote/1.1",
"Set-Cookie": "JSESSIONID=something; Path=/; HttpOnly",
"status": "success",
"transactionWeight": "1.0",
"Content-Length": "1055",
"Connection": "keep-alive"
},
"status_code": 200,
"type": "ok"
}
}
]

View file

@ -0,0 +1,30 @@
[
{
"request": {
"body": "",
"headers": [],
"method": "get",
"options": [],
"request_body": "",
"url": "https://nominatim.openstreetmap.org/reverse?format=jsonv2&lat=45.751718&lon=4.842569&addressdetails=1"
},
"response": {
"binary": false,
"body": "{\"place_id\":41453794,\"licence\":\"Data © OpenStreetMap contributors, ODbL 1.0. https://osm.org/copyright\",\"osm_type\":\"node\",\"osm_id\":3078260611,\"lat\":\"45.7517141\",\"lon\":\"4.8425657\",\"place_rank\":30,\"category\":\"place\",\"type\":\"house\",\"importance\":\"0\",\"addresstype\":\"place\",\"name\":null,\"display_name\":\"10, Rue Jangot, La Guillotière, Lyon 7e Arrondissement, Lyon, Métropole de Lyon, Circonscription départementale du Rhône, Auvergne-Rhône-Alpes, France métropolitaine, 69007, France\",\"address\":{\"house_number\":\"10\",\"road\":\"Rue Jangot\",\"suburb\":\"La Guillotière\",\"city_district\":\"Lyon 7e Arrondissement\",\"city\":\"Lyon\",\"county\":\"Lyon\",\"state_district\":\"Circonscription départementale du Rhône\",\"state\":\"Auvergne-Rhône-Alpes\",\"country\":\"France\",\"postcode\":\"69007\",\"country_code\":\"fr\"},\"boundingbox\":[\"45.7516141\",\"45.7518141\",\"4.8424657\",\"4.8426657\"]}",
"headers": {
"Date": "Thu, 14 Mar 2019 10:26:11 GMT",
"Server": "Apache/2.4.29 (Ubuntu)",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "OPTIONS,GET",
"Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload",
"Expect-CT": "max-age=0, report-uri=\"https://openstreetmap.report-uri.com/r/d/ct/reportOnly\"",
"Upgrade": "h2",
"Connection": "Upgrade, close",
"Transfer-Encoding": "chunked",
"Content-Type": "application/json; charset=UTF-8"
},
"status_code": 200,
"type": "ok"
}
}
]

View file

@ -0,0 +1,30 @@
[
{
"request": {
"body": "",
"headers": [],
"method": "get",
"options": [],
"request_body": "",
"url": "https://nominatim.openstreetmap.org/search?format=jsonv2&q=10%20rue%20Jangot&limit=10&accept-language=en&addressdetails=1"
},
"response": {
"binary": false,
"body": "[{\"place_id\":41453794,\"licence\":\"Data © OpenStreetMap contributors, ODbL 1.0. https://osm.org/copyright\",\"osm_type\":\"node\",\"osm_id\":3078260611,\"boundingbox\":[\"45.7516641\",\"45.7517641\",\"4.8425157\",\"4.8426157\"],\"lat\":\"45.7517141\",\"lon\":\"4.8425657\",\"display_name\":\"10, Rue Jangot, La Guillotière, Lyon 7e Arrondissement, Lyon, Métropole de Lyon, Departemental constituency of Rhône, Auvergne-Rhône-Alpes, Metropolitan France, 69007, France\",\"place_rank\":30,\"category\":\"place\",\"type\":\"house\",\"importance\":0.31100000000000005,\"address\":{\"house_number\":\"10\",\"road\":\"Rue Jangot\",\"suburb\":\"La Guillotière\",\"city_district\":\"Lyon 7e Arrondissement\",\"city\":\"Lyon\",\"county\":\"Lyon\",\"state_district\":\"Departemental constituency of Rhône\",\"state\":\"Auvergne-Rhône-Alpes\",\"country\":\"France\",\"postcode\":\"69007\",\"country_code\":\"fr\"}}]",
"headers": {
"Date": "Thu, 14 Mar 2019 10:24:24 GMT",
"Server": "Apache/2.4.29 (Ubuntu)",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "OPTIONS,GET",
"Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload",
"Expect-CT": "max-age=0, report-uri=\"https://openstreetmap.report-uri.com/r/d/ct/reportOnly\"",
"Upgrade": "h2",
"Connection": "Upgrade, close",
"Transfer-Encoding": "chunked",
"Content-Type": "application/json; charset=UTF-8"
},
"status_code": 200,
"type": "ok"
}
}
]

View file

@ -0,0 +1,26 @@
[
{
"request": {
"body": "",
"headers": [],
"method": "get",
"options": [],
"request_body": "",
"url": "https://photon.komoot.de/reverse?lon=4.842569&lat=45.751718"
},
"response": {
"binary": false,
"body": "{\"features\":[{\"geometry\":{\"coordinates\":[4.8416864,45.7605435],\"type\":\"Point\"},\"type\":\"Feature\",\"properties\":{\"osm_id\":4662865602,\"osm_type\":\"N\",\"country\":\"France\",\"osm_key\":\"leisure\",\"city\":\"Lyon\",\"street\":\"Rue Pravaz\",\"osm_value\":\"fitness_centre\",\"postcode\":\"69003\",\"name\":\"L'appart Fitness\",\"state\":\"Auvergne-Rhône-Alpes\"}}],\"type\":\"FeatureCollection\"}",
"headers": {
"Server": "nginx/1.9.3 (Ubuntu)",
"Date": "Thu, 14 Mar 2019 10:46:45 GMT",
"Content-Type": "application/json;charset=utf-8",
"Transfer-Encoding": "chunked",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*"
},
"status_code": 200,
"type": "ok"
}
}
]

View file

@ -0,0 +1,26 @@
[
{
"request": {
"body": "",
"headers": [],
"method": "get",
"options": [],
"request_body": "",
"url": "https://photon.komoot.de/api/?q=10%20rue%20Jangot&lang=en&limit=10"
},
"response": {
"binary": false,
"body": "{\"features\":[{\"geometry\":{\"coordinates\":[4.8425657,45.7517141],\"type\":\"Point\"},\"type\":\"Feature\",\"properties\":{\"osm_id\":3078260611,\"osm_type\":\"N\",\"country\":\"France\",\"osm_key\":\"place\",\"housenumber\":\"10\",\"city\":\"Lyon\",\"street\":\"Rue Jangot\",\"osm_value\":\"house\",\"postcode\":\"69007\",\"state\":\"Auvergne-Rhône-Alpes\"}},{\"geometry\":{\"coordinates\":[4.8424254,45.7517056],\"type\":\"Point\"},\"type\":\"Feature\",\"properties\":{\"osm_id\":3078260612,\"osm_type\":\"N\",\"country\":\"France\",\"osm_key\":\"place\",\"housenumber\":\"10bis\",\"city\":\"Lyon\",\"street\":\"Rue Jangot\",\"osm_value\":\"house\",\"postcode\":\"69007\",\"state\":\"Auvergne-Rhône-Alpes\"}}],\"type\":\"FeatureCollection\"}",
"headers": {
"Server": "nginx/1.9.3 (Ubuntu)",
"Date": "Thu, 14 Mar 2019 10:46:43 GMT",
"Content-Type": "application/json;charset=utf-8",
"Transfer-Encoding": "chunked",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*"
},
"status_code": 200,
"type": "ok"
}
}
]

View file

@ -0,0 +1,59 @@
defmodule Mobilizon.Service.Geospatial.AddokTest do
use Mobilizon.DataCase, async: false
alias Mobilizon.Service.Geospatial.Addok
alias Mobilizon.Addresses.Address
import Mock
use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney
@endpoint Application.get_env(:mobilizon, Mobilizon.Service.Geospatial.Addok)
|> get_in([:endpoint])
@fake_endpoint "https://domain.tld"
describe "search address" do
test "produces a valid search address" do
with_mock HTTPoison, get: fn _url -> "{}" end do
Addok.search("10 Rue Jangot")
assert_called(HTTPoison.get("#{@endpoint}/search/?q=10%20Rue%20Jangot&limit=10"))
end
end
test "produces a valid search address with options" do
with_mock HTTPoison, get: fn _url -> "{}" end do
Addok.search("10 Rue Jangot",
endpoint: @fake_endpoint,
limit: 5,
coords: %{lat: 49, lon: 12}
)
assert_called(
HTTPoison.get("#{@fake_endpoint}/search/?q=10%20Rue%20Jangot&limit=5&lat=49&lon=12")
)
end
end
test "returns a valid address from search" do
use_cassette "geospatial/addok/search" do
assert %Address{
addressLocality: "Lyon",
description: "10 Rue Jangot",
postalCode: "69007",
streetAddress: "10 Rue Jangot",
geom: %Geo.Point{coordinates: {4.842569, 45.751718}, properties: %{}, srid: 4326}
} == Addok.search("10 rue Jangot") |> hd
end
end
test "returns a valid address from reverse geocode" do
use_cassette "geospatial/addok/geocode" do
assert %Address{
addressLocality: "Lyon",
description: "10 Rue Jangot",
postalCode: "69007",
streetAddress: "10 Rue Jangot",
geom: %Geo.Point{coordinates: {4.842569, 45.751718}, properties: %{}, srid: 4326}
} == Addok.geocode(4.842569, 45.751718) |> hd
end
end
end
end

View file

@ -0,0 +1,8 @@
defmodule Mobilizon.Service.GeospatialTest do
use Mobilizon.DataCase
alias Mobilizon.Service.Geospatial
describe "get service" do
assert Geospatial.service() === Elixir.Mobilizon.Service.Geospatial.Mock
end
end

View file

@ -0,0 +1,80 @@
defmodule Mobilizon.Service.Geospatial.GoogleMapsTest do
use Mobilizon.DataCase, async: false
alias Mobilizon.Service.Geospatial.GoogleMaps
alias Mobilizon.Addresses.Address
import Mock
use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney
describe "search address" do
test "without API Key triggers an error" do
assert_raise ArgumentError, "API Key required to use Google Maps", fn ->
GoogleMaps.search("10 Rue Jangot")
end
end
test "produces a valid search address with options" do
with_mock HTTPoison,
get: fn _url ->
{:ok,
%HTTPoison.Response{status_code: 200, body: "{\"status\": \"OK\", \"results\": []}"}}
end do
GoogleMaps.search("10 Rue Jangot",
limit: 5,
lang: "fr",
api_key: "toto"
)
assert_called(
HTTPoison.get(
"https://maps.googleapis.com/maps/api/geocode/json?limit=5&key=toto&language=fr&address=10%20Rue%20Jangot"
)
)
end
end
test "triggers an error with an invalid API Key" do
assert_raise ArgumentError, "The provided API key is invalid.", fn ->
GoogleMaps.search("10 rue Jangot", api_key: "secret_key")
end
end
test "returns a valid address from search" do
use_cassette "geospatial/google_maps/search" do
assert %Address{
addressLocality: "Lyon",
description: "10 Rue Jangot, 69007 Lyon, France",
addressRegion: "Auvergne-Rhône-Alpes",
addressCountry: "France",
postalCode: "69007",
streetAddress: "10 Rue Jangot",
geom: %Geo.Point{
coordinates: {4.8424032, 45.75164940000001},
properties: %{},
srid: 4326
}
} == GoogleMaps.search("10 rue Jangot", api_key: "toto") |> hd
end
end
test "returns a valid address from reverse geocode" do
use_cassette "geospatial/google_maps/geocode" do
assert %Address{
addressLocality: "Lyon",
description: "10 Rue Jangot, 69007 Lyon, France",
addressRegion: "Auvergne-Rhône-Alpes",
addressCountry: "France",
postalCode: "69007",
streetAddress: "10 Rue Jangot",
geom: %Geo.Point{
coordinates: {4.8424967, 45.751725},
properties: %{},
srid: 4326
}
} ==
GoogleMaps.geocode(4.842569, 45.751718, api_key: "toto")
|> hd
end
end
end
end

View file

@ -0,0 +1,85 @@
defmodule Mobilizon.Service.Geospatial.MapQuestTest do
use Mobilizon.DataCase, async: false
alias Mobilizon.Service.Geospatial.MapQuest
alias Mobilizon.Addresses.Address
import Mock
use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney
describe "search address" do
test "without API Key triggers an error" do
assert_raise ArgumentError, "API Key required to use MapQuest", fn ->
MapQuest.search("10 Rue Jangot")
end
end
test "produces a valid search address with options" do
with_mock HTTPoison,
get: fn _url ->
{:ok,
%HTTPoison.Response{
status_code: 200,
body: "{\"info\": {\"statuscode\": 0}, \"results\": []}"
}}
end do
MapQuest.search("10 Rue Jangot",
limit: 5,
lang: "fr",
api_key: "toto"
)
assert_called(
HTTPoison.get(
"https://open.mapquestapi.com/geocoding/v1/address?key=toto&location=10%20Rue%20Jangot&maxResults=5"
)
)
end
end
test "triggers an error with an invalid API Key" do
assert_raise ArgumentError, "The AppKey submitted with this request is invalid.", fn ->
MapQuest.search("10 rue Jangot", api_key: "secret_key")
end
end
test "returns a valid address from search" do
use_cassette "geospatial/map_quest/search" do
assert %Address{
addressLocality: "Lyon",
description: "10 Rue Jangot",
addressRegion: "Auvergne-Rhône-Alpes",
addressCountry: "FR",
postalCode: "69007",
streetAddress: "10 Rue Jangot",
geom: %Geo.Point{
coordinates: {4.842566, 45.751714},
properties: %{},
srid: 4326
}
} ==
MapQuest.search("10 rue Jangot", api_key: "secret_key")
|> hd
end
end
test "returns a valid address from reverse geocode" do
use_cassette "geospatial/map_quest/geocode" do
assert %Address{
addressLocality: "Lyon",
description: "10 Rue Jangot",
addressRegion: "Auvergne-Rhône-Alpes",
addressCountry: "FR",
postalCode: "69007",
streetAddress: "10 Rue Jangot",
geom: %Geo.Point{
coordinates: {4.842569, 45.751718},
properties: %{},
srid: 4326
}
} ==
MapQuest.geocode(4.842569, 45.751718, api_key: "secret_key")
|> hd
end
end
end
end

View file

@ -0,0 +1,68 @@
defmodule Mobilizon.Service.Geospatial.NominatimTest do
use Mobilizon.DataCase, async: false
alias Mobilizon.Service.Geospatial.Nominatim
alias Mobilizon.Addresses.Address
import Mock
use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney
describe "search address" do
test "produces a valid search address with options" do
with_mock HTTPoison,
get: fn _url ->
{:ok, %HTTPoison.Response{status_code: 200, body: "[]"}}
end do
Nominatim.search("10 Rue Jangot",
limit: 5,
lang: "fr"
)
assert_called(
HTTPoison.get(
"https://nominatim.openstreetmap.org/search?format=jsonv2&q=10%20Rue%20Jangot&limit=5&accept-language=fr&addressdetails=1"
)
)
end
end
test "returns a valid address from search" do
use_cassette "geospatial/nominatim/search" do
assert %Address{
addressLocality: "Lyon",
description:
"10, Rue Jangot, La Guillotière, Lyon 7e Arrondissement, Lyon, Métropole de Lyon, Departemental constituency of Rhône, Auvergne-Rhône-Alpes, Metropolitan France, 69007, France",
addressRegion: "Auvergne-Rhône-Alpes",
addressCountry: "France",
postalCode: "69007",
streetAddress: "10 Rue Jangot",
geom: %Geo.Point{
coordinates: {4.8425657, 45.7517141},
properties: %{},
srid: 4326
}
} == Nominatim.search("10 rue Jangot") |> hd
end
end
test "returns a valid address from reverse geocode" do
use_cassette "geospatial/nominatim/geocode" do
assert %Address{
addressLocality: "Lyon",
description:
"10, Rue Jangot, La Guillotière, Lyon 7e Arrondissement, Lyon, Métropole de Lyon, Circonscription départementale du Rhône, Auvergne-Rhône-Alpes, France métropolitaine, 69007, France",
addressRegion: "Auvergne-Rhône-Alpes",
addressCountry: "France",
postalCode: "69007",
streetAddress: "10 Rue Jangot",
geom: %Geo.Point{
coordinates: {4.8425657, 45.7517141},
properties: %{},
srid: 4326
}
} ==
Nominatim.geocode(4.842569, 45.751718)
|> hd
end
end
end
end

View file

@ -0,0 +1,65 @@
defmodule Mobilizon.Service.Geospatial.PhotonTest do
use Mobilizon.DataCase, async: false
alias Mobilizon.Service.Geospatial.Photon
alias Mobilizon.Addresses.Address
import Mock
use ExVCR.Mock, adapter: ExVCR.Adapter.Hackney
describe "search address" do
test "produces a valid search address with options" do
with_mock HTTPoison,
get: fn _url ->
{:ok, %HTTPoison.Response{status_code: 200, body: "{\"features\": []"}}
end do
Photon.search("10 Rue Jangot",
limit: 5,
lang: "fr"
)
assert_called(
HTTPoison.get("https://photon.komoot.de/api/?q=10%20Rue%20Jangot&lang=fr&limit=5")
)
end
end
test "returns a valid address from search" do
use_cassette "geospatial/photon/search" do
assert %Address{
addressLocality: "Lyon",
description: "10 Rue Jangot",
addressRegion: "Auvergne-Rhône-Alpes",
addressCountry: "France",
postalCode: "69007",
streetAddress: "10 Rue Jangot",
geom: %Geo.Point{
coordinates: {4.8425657, 45.7517141},
properties: %{},
srid: 4326
}
} == Photon.search("10 rue Jangot") |> hd
end
end
# Photon returns something quite wrong, so no need to test this right now.
# test "returns a valid address from reverse geocode" do
# use_cassette "geospatial/photon/geocode" do
# assert %Address{
# addressLocality: "Lyon",
# description: "",
# addressRegion: "Auvergne-Rhône-Alpes",
# addressCountry: "France",
# postalCode: "69007",
# streetAddress: "10 Rue Jangot",
# geom: %Geo.Point{
# coordinates: {4.8425657, 45.7517141},
# properties: %{},
# srid: 4326
# }
# } ==
# Photon.geocode(4.8425657, 45.7517141)
# |> hd
# end
# end
end
end

View file

@ -0,0 +1,65 @@
defmodule MobilizonWeb.Resolvers.AddressResolverTest do
use MobilizonWeb.ConnCase
alias MobilizonWeb.AbsintheHelpers
import Mobilizon.Factory
describe "Address Resolver" do
test "search/3 search for addresses", %{conn: conn} do
address = insert(:address, description: "10 rue Jangot, Lyon")
query = """
{
searchAddress(query: "10 Rue Jangot") {
description,
geom
}
}
"""
res =
conn
|> get("/api", AbsintheHelpers.query_skeleton(query, "address"))
json_response(res, 200)["data"]["searchAddress"]
|> Enum.each(fn addr -> assert Map.get(addr, "description") == address.description end)
end
test "geocode/3 reverse geocodes coordinates", %{conn: conn} do
address =
insert(:address,
description: "10 rue Jangot, Lyon"
)
query = """
{
reverseGeocode(longitude: -23.01, latitude: 30.01) {
description,
geom
}
}
"""
res =
conn
|> get("/api", AbsintheHelpers.query_skeleton(query, "address"))
assert json_response(res, 200)["data"]["reverseGeocode"] == []
query = """
{
reverseGeocode(longitude: 45.75, latitude: 4.85) {
description,
geom
}
}
"""
res =
conn
|> get("/api", AbsintheHelpers.query_skeleton(query, "address"))
assert json_response(res, 200)["data"]["reverseGeocode"] |> hd |> Map.get("description") ==
address.description
end
end
end

View file

@ -70,7 +70,7 @@ defmodule Mobilizon.Factory do
def address_factory do def address_factory do
%Mobilizon.Addresses.Address{ %Mobilizon.Addresses.Address{
description: sequence("MyAddress"), description: sequence("MyAddress"),
geom: %Geo.Point{coordinates: {30, -90}, srid: 4326}, geom: %Geo.Point{coordinates: {45.75, 4.85}, srid: 4326},
floor: "Myfloor", floor: "Myfloor",
addressCountry: "My Country", addressCountry: "My Country",
addressLocality: "My Locality", addressLocality: "My Locality",

View file

@ -0,0 +1,15 @@
defmodule Mobilizon.Service.Geospatial.Mock do
@moduledoc """
Mock for Geospatial Provider implementations
"""
alias Mobilizon.Service.Geospatial.Provider
alias Mobilizon.Addresses.Address
@behaviour Provider
@impl Provider
def geocode(_lon, _lat, _options \\ []), do: []
@impl Provider
def search(_q, _options \\ []), do: [%Address{description: "10 rue Jangot, Lyon"}]
end