diff --git a/config/config.exs b/config/config.exs index 75ba5c4c0..cb708b25e 100644 --- a/config/config.exs +++ b/config/config.exs @@ -64,3 +64,20 @@ config :arc, storage: Arc.Storage.Local 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 diff --git a/config/prod.exs b/config/prod.exs index 3cea69df6..969e95539 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -58,6 +58,8 @@ config :mobilizon, Mobilizon.Mailer, # Do not print debug messages in production config :logger, level: System.get_env("MOBILIZON_LOGLEVEL") |> String.to_atom() || :info +config :mobilizon, Mobilizon.Service.Geospatial, service: Mobilizon.Service.Geospatial.Nominatim + # ## SSL Support # # To get SSL working, you will need to add the `https` key diff --git a/config/test.exs b/config/test.exs index c906f42e8..3339f23a3 100644 --- a/config/test.exs +++ b/config/test.exs @@ -32,3 +32,5 @@ config :mobilizon, Mobilizon.Mailer, adapter: Bamboo.TestAdapter config :exvcr, vcr_cassette_library_dir: "test/fixtures/vcr_cassettes" + +config :mobilizon, Mobilizon.Service.Geospatial, service: Mobilizon.Service.Geospatial.Mock diff --git a/lib/mobilizon/addresses/addresses.ex b/lib/mobilizon/addresses/addresses.ex index 891167a31..78607311f 100644 --- a/lib/mobilizon/addresses/addresses.ex +++ b/lib/mobilizon/addresses/addresses.ex @@ -108,6 +108,7 @@ defmodule Mobilizon.Addresses do @doc """ 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 type = if !is_atom(type_input) && type_input != nil do @@ -145,4 +146,62 @@ defmodule Mobilizon.Addresses do defp process_point(_, _) do {:error, "Latitude and longitude must be numbers"} 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 diff --git a/lib/mobilizon_web/context.ex b/lib/mobilizon_web/context.ex index c9abf01d0..be5834d04 100644 --- a/lib/mobilizon_web/context.ex +++ b/lib/mobilizon_web/context.ex @@ -12,11 +12,17 @@ defmodule MobilizonWeb.Context do end def call(conn, _) do - with %User{} = user <- Guardian.Plug.current_resource(conn) do - put_private(conn, :absinthe, %{context: %{current_user: user}}) - else - nil -> - conn - end + context = %{ip: to_string(:inet_parse.ntoa(conn.remote_ip))} + + context = + case Guardian.Plug.current_resource(conn) do + %User{} = user -> + Map.put(context, :current_user, user) + + nil -> + context + end + + put_private(conn, :absinthe, %{context: context}) end end diff --git a/lib/mobilizon_web/resolvers/address.ex b/lib/mobilizon_web/resolvers/address.ex new file mode 100644 index 000000000..df2493659 --- /dev/null +++ b/lib/mobilizon_web/resolvers/address.ex @@ -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 diff --git a/lib/mobilizon_web/schema.ex b/lib/mobilizon_web/schema.ex index 9d140130f..0373b80ce 100644 --- a/lib/mobilizon_web/schema.ex +++ b/lib/mobilizon_web/schema.ex @@ -132,6 +132,7 @@ defmodule MobilizonWeb.Schema do import_fields(:event_queries) import_fields(:participant_queries) import_fields(:tag_queries) + import_fields(:address_queries) end @desc """ diff --git a/lib/mobilizon_web/schema/address.ex b/lib/mobilizon_web/schema/address.ex index d3b560a16..577ead164 100644 --- a/lib/mobilizon_web/schema/address.ex +++ b/lib/mobilizon_web/schema/address.ex @@ -3,6 +3,7 @@ defmodule MobilizonWeb.Schema.AddressType do Schema representation for Address """ use Absinthe.Schema.Notation + alias MobilizonWeb.Resolvers object :physical_address do 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(:other, description: "The address is something else") 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 diff --git a/lib/service/geospatial/addok.ex b/lib/service/geospatial/addok.ex new file mode 100644 index 000000000..1d0f3f46d --- /dev/null +++ b/lib/service/geospatial/addok.ex @@ -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 diff --git a/lib/service/geospatial/geospatial.ex b/lib/service/geospatial/geospatial.ex new file mode 100644 index 000000000..0321997af --- /dev/null +++ b/lib/service/geospatial/geospatial.ex @@ -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 diff --git a/lib/service/geospatial/google_maps.ex b/lib/service/geospatial/google_maps.ex new file mode 100644 index 000000000..becb15773 --- /dev/null +++ b/lib/service/geospatial/google_maps.ex @@ -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 diff --git a/lib/service/geospatial/map_quest.ex b/lib/service/geospatial/map_quest.ex new file mode 100644 index 000000000..ab9dc6f18 --- /dev/null +++ b/lib/service/geospatial/map_quest.ex @@ -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 diff --git a/lib/service/geospatial/nominatim.ex b/lib/service/geospatial/nominatim.ex new file mode 100644 index 000000000..fcbbab12c --- /dev/null +++ b/lib/service/geospatial/nominatim.ex @@ -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 diff --git a/lib/service/geospatial/photon.ex b/lib/service/geospatial/photon.ex new file mode 100644 index 000000000..233694730 --- /dev/null +++ b/lib/service/geospatial/photon.ex @@ -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 diff --git a/lib/service/geospatial/provider.ex b/lib/service/geospatial/provider.ex new file mode 100644 index 000000000..e79ef2df8 --- /dev/null +++ b/lib/service/geospatial/provider.ex @@ -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 diff --git a/mix.exs b/mix.exs index f107b1dd7..596030d38 100644 --- a/mix.exs +++ b/mix.exs @@ -65,7 +65,6 @@ defmodule Mobilizon.Mixfile do {:geo, "~> 3.0"}, {:geo_postgis, "~> 3.1"}, {:timex, "~> 3.0"}, - # Waiting for new release {:icalendar, "~> 0.7"}, {:exgravatar, "~> 2.0.1"}, {:httpoison, "~> 1.0"}, @@ -89,6 +88,7 @@ defmodule Mobilizon.Mixfile do {:atomex, "0.3.0"}, {:cachex, "~> 3.1"}, {:earmark, "~> 1.3.1"}, + {:geohax, "~> 0.3.0"}, # Dev and test dependencies {:phoenix_live_reload, "~> 1.2", only: :dev}, {:ex_machina, "~> 2.2", only: [:dev, :test]}, diff --git a/mix.lock b/mix.lock index 630d70370..acd494782 100644 --- a/mix.lock +++ b/mix.lock @@ -48,6 +48,7 @@ "gen_smtp": {:hex, :gen_smtp, "0.12.0", "97d44903f5ca18ca85cb39aee7d9c77e98d79804bbdef56078adcf905cb2ef00", [:rebar3], [], "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"}, + "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"}, "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"}, diff --git a/test/fixtures/vcr_cassettes/geospatial/addok/geocode.json b/test/fixtures/vcr_cassettes/geospatial/addok/geocode.json new file mode 100644 index 000000000..8216724de --- /dev/null +++ b/test/fixtures/vcr_cassettes/geospatial/addok/geocode.json @@ -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" + } + } +] \ No newline at end of file diff --git a/test/fixtures/vcr_cassettes/geospatial/addok/search.json b/test/fixtures/vcr_cassettes/geospatial/addok/search.json new file mode 100644 index 000000000..bb395a96f --- /dev/null +++ b/test/fixtures/vcr_cassettes/geospatial/addok/search.json @@ -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" + } + } +] \ No newline at end of file diff --git a/test/fixtures/vcr_cassettes/geospatial/google_maps/geocode.json b/test/fixtures/vcr_cassettes/geospatial/google_maps/geocode.json new file mode 100644 index 000000000..edfaacc02 --- /dev/null +++ b/test/fixtures/vcr_cassettes/geospatial/google_maps/geocode.json @@ -0,0 +1,33 @@ +[ + { + "request": { + "body": "", + "headers": [], + "method": "get", + "options": [], + "request_body": "", + "url": "https://maps.googleapis.com/maps/api/geocode/json?latlng=45.751718,4.842569&key=toto&language=en" + }, + "response": { + "binary": false, + "body": "{\n \"plus_code\" : {\n \"compound_code\" : \"QR2V+M2 Lyon, France\",\n \"global_code\" : \"8FQ6QR2V+M2\"\n },\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.751725,\n \"lng\" : 4.8424967\n },\n \"location_type\" : \"ROOFTOP\",\n \"viewport\" : {\n \"northeast\" : {\n \"lat\" : 45.7530739802915,\n \"lng\" : 4.843845680291502\n },\n \"southwest\" : {\n \"lat\" : 45.7503760197085,\n \"lng\" : 4.841147719708498\n }\n }\n },\n \"place_id\" : \"ChIJrW0QikTq9EcRy4OdnHY6uS4\",\n \"plus_code\" : {\n \"compound_code\" : \"QR2R+MX Lyon, France\",\n \"global_code\" : \"8FQ6QR2R+MX\"\n },\n \"types\" : [ \"establishment\", \"point_of_interest\" ]\n },\n {\n \"address_components\" : [\n {\n \"long_name\" : \"10bis\",\n \"short_name\" : \"10bis\",\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\" : \"10bis Rue Jangot, 69007 Lyon, France\",\n \"geometry\" : {\n \"location\" : {\n \"lat\" : 45.751725,\n \"lng\" : 4.8424966\n },\n \"location_type\" : \"ROOFTOP\",\n \"viewport\" : {\n \"northeast\" : {\n \"lat\" : 45.7530739802915,\n \"lng\" : 4.843845580291503\n },\n \"southwest\" : {\n \"lat\" : 45.7503760197085,\n \"lng\" : 4.841147619708499\n }\n }\n },\n \"place_id\" : \"ChIJrW0QikTq9EcR96jk2OnO75w\",\n \"plus_code\" : {\n \"compound_code\" : \"QR2R+MX Lyon, France\",\n \"global_code\" : \"8FQ6QR2R+MX\"\n },\n \"types\" : [ \"street_address\" ]\n },\n {\n \"address_components\" : [\n {\n \"long_name\" : \"9\",\n \"short_name\" : \"9\",\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\" : \"9 Rue Jangot, 69007 Lyon, France\",\n \"geometry\" : {\n \"location\" : {\n \"lat\" : 45.7518165,\n \"lng\" : 4.8427168\n },\n \"location_type\" : \"RANGE_INTERPOLATED\",\n \"viewport\" : {\n \"northeast\" : {\n \"lat\" : 45.7531654802915,\n \"lng\" : 4.844065780291502\n },\n \"southwest\" : {\n \"lat\" : 45.7504675197085,\n \"lng\" : 4.841367819708497\n }\n }\n },\n \"place_id\" : \"EiA5IFJ1ZSBKYW5nb3QsIDY5MDA3IEx5b24sIEZyYW5jZSIaEhgKFAoSCR8N2ItE6vRHEW9tyPnhQsUIEAk\",\n \"types\" : [ \"street_address\" ]\n },\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 \"bounds\" : {\n \"northeast\" : {\n \"lat\" : 45.7518154,\n \"lng\" : 4.8427199\n },\n \"southwest\" : {\n \"lat\" : 45.75173520000001,\n \"lng\" : 4.8422361\n }\n },\n \"location\" : {\n \"lat\" : 45.7517676,\n \"lng\" : 4.8424811\n },\n \"location_type\" : \"GEOMETRIC_CENTER\",\n \"viewport\" : {\n \"northeast\" : {\n \"lat\" : 45.7531242802915,\n \"lng\" : 4.843826980291502\n },\n \"southwest\" : {\n \"lat\" : 45.75042631970851,\n \"lng\" : 4.841129019708498\n }\n }\n },\n \"place_id\" : \"ChIJadQ-ikTq9EcRo9mAXlItDZw\",\n \"types\" : [ \"route\" ]\n },\n {\n \"address_components\" : [\n {\n \"long_name\" : \"Saint Michel - Mairie\",\n \"short_name\" : \"St Michel - Mairie\",\n \"types\" : [ \"neighborhood\", \"political\" ]\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\" : \"St Michel - Mairie, 69007 Lyon, France\",\n \"geometry\" : {\n \"bounds\" : {\n \"northeast\" : {\n \"lat\" : 45.755675,\n \"lng\" : 4.846115999999999\n },\n \"southwest\" : {\n \"lat\" : 45.7458619,\n \"lng\" : 4.8380151\n }\n },\n \"location\" : {\n \"lat\" : 45.7481588,\n \"lng\" : 4.841602099999999\n },\n \"location_type\" : \"APPROXIMATE\",\n \"viewport\" : {\n \"northeast\" : {\n \"lat\" : 45.755675,\n \"lng\" : 4.846115999999999\n },\n \"southwest\" : {\n \"lat\" : 45.7458619,\n \"lng\" : 4.8380151\n }\n }\n },\n \"place_id\" : \"ChIJ0zUnFUTq9EcRfrgAlAvv5ps\",\n \"types\" : [ \"neighborhood\", \"political\" ]\n },\n {\n \"address_components\" : [\n {\n \"long_name\" : \"7th arrondissement of Lyon\",\n \"short_name\" : \"7th arrondissement of Lyon\",\n \"types\" : [ \"political\", \"sublocality\", \"sublocality_level_1\" ]\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\" : \"7th arrondissement of Lyon, 69007 Lyon, France\",\n \"geometry\" : {\n \"bounds\" : {\n \"northeast\" : {\n \"lat\" : 45.756743,\n \"lng\" : 4.8599059\n },\n \"southwest\" : {\n \"lat\" : 45.707486,\n \"lng\" : 4.8187161\n }\n },\n \"location\" : {\n \"lat\" : 45.7304251,\n \"lng\" : 4.8399378\n },\n \"location_type\" : \"APPROXIMATE\",\n \"viewport\" : {\n \"northeast\" : {\n \"lat\" : 45.756743,\n \"lng\" : 4.8599059\n },\n \"southwest\" : {\n \"lat\" : 45.707486,\n \"lng\" : 4.8187161\n }\n }\n },\n \"place_id\" : \"ChIJ-_L7biTq9EcRIBnC5CqrCAU\",\n \"types\" : [ \"political\", \"sublocality\", \"sublocality_level_1\" ]\n },\n {\n \"address_components\" : [\n {\n \"long_name\" : \"69007\",\n \"short_name\" : \"69007\",\n \"types\" : [ \"postal_code\" ]\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 \"formatted_address\" : \"69007 Lyon, France\",\n \"geometry\" : {\n \"bounds\" : {\n \"northeast\" : {\n \"lat\" : 45.7567599,\n \"lng\" : 4.8599819\n },\n \"southwest\" : {\n \"lat\" : 45.7073626,\n \"lng\" : 4.8187218\n }\n },\n \"location\" : {\n \"lat\" : 45.7304251,\n \"lng\" : 4.8399378\n },\n \"location_type\" : \"APPROXIMATE\",\n \"viewport\" : {\n \"northeast\" : {\n \"lat\" : 45.7567599,\n \"lng\" : 4.8599819\n },\n \"southwest\" : {\n \"lat\" : 45.7073626,\n \"lng\" : 4.8187218\n }\n }\n },\n \"place_id\" : \"ChIJ-_L7biTq9EcRcHzkQS6rCBw\",\n \"types\" : [ \"postal_code\" ]\n },\n {\n \"address_components\" : [\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 \"formatted_address\" : \"Lyon, France\",\n \"geometry\" : {\n \"bounds\" : {\n \"northeast\" : {\n \"lat\" : 45.808425,\n \"lng\" : 4.898393\n },\n \"southwest\" : {\n \"lat\" : 45.707486,\n \"lng\" : 4.7718489\n }\n },\n \"location\" : {\n \"lat\" : 45.764043,\n \"lng\" : 4.835659\n },\n \"location_type\" : \"APPROXIMATE\",\n \"viewport\" : {\n \"northeast\" : {\n \"lat\" : 45.808425,\n \"lng\" : 4.898393\n },\n \"southwest\" : {\n \"lat\" : 45.707486,\n \"lng\" : 4.7718489\n }\n }\n },\n \"place_id\" : \"ChIJl4foalHq9EcR8CG75CqrCAQ\",\n \"types\" : [ \"locality\", \"political\" ]\n },\n {\n \"address_components\" : [\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 \"formatted_address\" : \"Rh么ne, France\",\n \"geometry\" : {\n \"bounds\" : {\n \"northeast\" : {\n \"lat\" : 46.30650199999999,\n \"lng\" : 5.1601089\n },\n \"southwest\" : {\n \"lat\" : 45.45413,\n \"lng\" : 4.243647\n }\n },\n \"location\" : {\n \"lat\" : 45.7351456,\n \"lng\" : 4.6108043\n },\n \"location_type\" : \"APPROXIMATE\",\n \"viewport\" : {\n \"northeast\" : {\n \"lat\" : 46.30650199999999,\n \"lng\" : 5.1601089\n },\n \"southwest\" : {\n \"lat\" : 45.45413,\n \"lng\" : 4.243647\n }\n }\n },\n \"place_id\" : \"ChIJcf_obOGN9EcR0Cm55CqrCAM\",\n \"types\" : [ \"administrative_area_level_2\", \"political\" ]\n },\n {\n \"address_components\" : [\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 \"formatted_address\" : \"Auvergne-Rh么ne-Alpes, France\",\n \"geometry\" : {\n \"bounds\" : {\n \"northeast\" : {\n \"lat\" : 46.804293,\n \"lng\" : 7.1855661\n },\n \"southwest\" : {\n \"lat\" : 44.115493,\n \"lng\" : 2.062882\n }\n },\n \"location\" : {\n \"lat\" : 45.4471431,\n \"lng\" : 4.385250699999999\n },\n \"location_type\" : \"APPROXIMATE\",\n \"viewport\" : {\n \"northeast\" : {\n \"lat\" : 46.804293,\n \"lng\" : 7.1855661\n },\n \"southwest\" : {\n \"lat\" : 44.115493,\n \"lng\" : 2.062882\n }\n }\n },\n \"place_id\" : \"ChIJ_fju9ukE9UcROuOAIn5wRjk\",\n \"types\" : [ \"administrative_area_level_1\", \"political\" ]\n },\n {\n \"address_components\" : [\n {\n \"long_name\" : \"France\",\n \"short_name\" : \"FR\",\n \"types\" : [ \"country\", \"political\" ]\n }\n ],\n \"formatted_address\" : \"France\",\n \"geometry\" : {\n \"bounds\" : {\n \"northeast\" : {\n \"lat\" : 51.1241999,\n \"lng\" : 9.6624999\n },\n \"southwest\" : {\n \"lat\" : 41.31433,\n \"lng\" : -5.5591\n }\n },\n \"location\" : {\n \"lat\" : 46.227638,\n \"lng\" : 2.213749\n },\n \"location_type\" : \"APPROXIMATE\",\n \"viewport\" : {\n \"northeast\" : {\n \"lat\" : 51.1241999,\n \"lng\" : 9.6624999\n },\n \"southwest\" : {\n \"lat\" : 41.31433,\n \"lng\" : -5.5591\n }\n }\n },\n \"place_id\" : \"ChIJMVd4MymgVA0R99lHx5Y__Ws\",\n \"types\" : [ \"country\", \"political\" ]\n }\n ],\n \"status\" : \"OK\"\n}\n", + "headers": { + "Content-Type": "application/json; charset=UTF-8", + "Date": "Wed, 13 Mar 2019 17:53:53 GMT", + "Expires": "Thu, 14 Mar 2019 17:53:53 GMT", + "Cache-Control": "public, max-age=86400", + "Access-Control-Allow-Origin": "*", + "Server": "mafe", + "X-XSS-Protection": "1; mode=block", + "X-Frame-Options": "SAMEORIGIN", + "Server-Timing": "gfet4t7; dur=44", + "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" + } + } +] diff --git a/test/fixtures/vcr_cassettes/geospatial/google_maps/search.json b/test/fixtures/vcr_cassettes/geospatial/google_maps/search.json new file mode 100644 index 000000000..9963a55cf --- /dev/null +++ b/test/fixtures/vcr_cassettes/geospatial/google_maps/search.json @@ -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" + } + } +] diff --git a/test/fixtures/vcr_cassettes/geospatial/map_quest/geocode.json b/test/fixtures/vcr_cassettes/geospatial/map_quest/geocode.json new file mode 100644 index 000000000..a42c62f08 --- /dev/null +++ b/test/fixtures/vcr_cassettes/geospatial/map_quest/geocode.json @@ -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" + } + } +] diff --git a/test/fixtures/vcr_cassettes/geospatial/map_quest/search.json b/test/fixtures/vcr_cassettes/geospatial/map_quest/search.json new file mode 100644 index 000000000..4b23ed257 --- /dev/null +++ b/test/fixtures/vcr_cassettes/geospatial/map_quest/search.json @@ -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" + } + } +] diff --git a/test/fixtures/vcr_cassettes/geospatial/nominatim/geocode.json b/test/fixtures/vcr_cassettes/geospatial/nominatim/geocode.json new file mode 100644 index 000000000..d8513a89d --- /dev/null +++ b/test/fixtures/vcr_cassettes/geospatial/nominatim/geocode.json @@ -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" + } + } +] \ No newline at end of file diff --git a/test/fixtures/vcr_cassettes/geospatial/nominatim/search.json b/test/fixtures/vcr_cassettes/geospatial/nominatim/search.json new file mode 100644 index 000000000..c41b678cb --- /dev/null +++ b/test/fixtures/vcr_cassettes/geospatial/nominatim/search.json @@ -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" + } + } +] \ No newline at end of file diff --git a/test/fixtures/vcr_cassettes/geospatial/photon/geocode.json b/test/fixtures/vcr_cassettes/geospatial/photon/geocode.json new file mode 100644 index 000000000..8ff49672e --- /dev/null +++ b/test/fixtures/vcr_cassettes/geospatial/photon/geocode.json @@ -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" + } + } +] \ No newline at end of file diff --git a/test/fixtures/vcr_cassettes/geospatial/photon/search.json b/test/fixtures/vcr_cassettes/geospatial/photon/search.json new file mode 100644 index 000000000..45a644e63 --- /dev/null +++ b/test/fixtures/vcr_cassettes/geospatial/photon/search.json @@ -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" + } + } +] \ No newline at end of file diff --git a/test/mobilizon/service/geospatial/addok_test.exs b/test/mobilizon/service/geospatial/addok_test.exs new file mode 100644 index 000000000..cbf9de19f --- /dev/null +++ b/test/mobilizon/service/geospatial/addok_test.exs @@ -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 diff --git a/test/mobilizon/service/geospatial/geospatial_test.exs b/test/mobilizon/service/geospatial/geospatial_test.exs new file mode 100644 index 000000000..a43d02d7c --- /dev/null +++ b/test/mobilizon/service/geospatial/geospatial_test.exs @@ -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 diff --git a/test/mobilizon/service/geospatial/google_maps_test.exs b/test/mobilizon/service/geospatial/google_maps_test.exs new file mode 100644 index 000000000..10cd84c76 --- /dev/null +++ b/test/mobilizon/service/geospatial/google_maps_test.exs @@ -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 diff --git a/test/mobilizon/service/geospatial/map_quest_test.exs b/test/mobilizon/service/geospatial/map_quest_test.exs new file mode 100644 index 000000000..6dc03684e --- /dev/null +++ b/test/mobilizon/service/geospatial/map_quest_test.exs @@ -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 diff --git a/test/mobilizon/service/geospatial/nominatim_test.exs b/test/mobilizon/service/geospatial/nominatim_test.exs new file mode 100644 index 000000000..29964ad56 --- /dev/null +++ b/test/mobilizon/service/geospatial/nominatim_test.exs @@ -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 diff --git a/test/mobilizon/service/geospatial/photon_test.exs b/test/mobilizon/service/geospatial/photon_test.exs new file mode 100644 index 000000000..51db05fd4 --- /dev/null +++ b/test/mobilizon/service/geospatial/photon_test.exs @@ -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 diff --git a/test/mobilizon_web/resolvers/address_resolver_test.exs b/test/mobilizon_web/resolvers/address_resolver_test.exs new file mode 100644 index 000000000..dfaf79cca --- /dev/null +++ b/test/mobilizon_web/resolvers/address_resolver_test.exs @@ -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 diff --git a/test/support/factory.ex b/test/support/factory.ex index 3584b3d5b..ba31afd4b 100644 --- a/test/support/factory.ex +++ b/test/support/factory.ex @@ -70,7 +70,7 @@ defmodule Mobilizon.Factory do def address_factory do %Mobilizon.Addresses.Address{ description: sequence("MyAddress"), - geom: %Geo.Point{coordinates: {30, -90}, srid: 4326}, + geom: %Geo.Point{coordinates: {45.75, 4.85}, srid: 4326}, floor: "Myfloor", addressCountry: "My Country", addressLocality: "My Locality", diff --git a/test/support/mocks/geospatial_mock.ex b/test/support/mocks/geospatial_mock.ex new file mode 100644 index 000000000..f5a3de8b4 --- /dev/null +++ b/test/support/mocks/geospatial_mock.ex @@ -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