From 852439577fd32f27d2ae40e1f11d8bdaa1bdcb59 Mon Sep 17 00:00:00 2001 From: Antti Hukkanen Date: Thu, 23 Jul 2020 00:38:29 +0300 Subject: [PATCH 1/3] Add lookup and result classes for Photon --- lib/geocoder/lookup.rb | 3 +- lib/geocoder/lookups/photon.rb | 90 ++++++++++++++++++++++++ lib/geocoder/results/photon.rb | 124 +++++++++++++++++++++++++++++++++ 3 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 lib/geocoder/lookups/photon.rb create mode 100644 lib/geocoder/results/photon.rb diff --git a/lib/geocoder/lookup.rb b/lib/geocoder/lookup.rb index 51b69cc28..a04aa98c2 100644 --- a/lib/geocoder/lookup.rb +++ b/lib/geocoder/lookup.rb @@ -53,7 +53,8 @@ def street_services :test, :latlon, :amap, - :osmnames + :osmnames, + :photon ] end diff --git a/lib/geocoder/lookups/photon.rb b/lib/geocoder/lookups/photon.rb new file mode 100644 index 000000000..b5524b1ac --- /dev/null +++ b/lib/geocoder/lookups/photon.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require 'geocoder/lookups/base' +require 'geocoder/results/photon' + +module Geocoder + module Lookup + # https://github.com/komoot/photon + class Photon < Base + def name + 'Photon' + end + + private + + def base_query_url(query) + host = configuration[:host] || 'photon.komoot.de' + method = query.reverse_geocode? ? 'reverse' : 'api' + "#{protocol}://#{host}/#{method}?" + end + + def results(query) + return [] unless (doc = fetch_data(query)) + return [] unless doc['type'] == 'FeatureCollection' + return [] unless doc['features'] || doc['features'].present? + + doc['features'] + end + + def query_url_params(query) + lang = query.language || configuration.language + params = { lang: lang, limit: query.options[:limit] } + + if query.reverse_geocode? + params.merge!(query_url_params_reverse(query)) + else + params.merge!(query_url_params_coordinates(query)) + end + + params.merge!(super) + end + + def query_url_params_coordinates(query) + params = { q: query.sanitized_text } + + if (bias = query.options[:bias]) + params.merge!(lat: bias[:latitude], lon: bias[:longitude], location_bias_scale: bias[:scale]) + end + + if (filter = query_url_params_coordinates_filter(query)) + params.merge!(filter) + end + + params + end + + def query_url_params_coordinates_filter(query) + filter = query.options[:filter] + return unless filter + + bbox = filter[:bbox] + { + bbox: bbox.is_a?(Array) ? bbox.join(',') : bbox, + osm_tag: filter[:osm_tag] + } + end + + def query_url_params_reverse(query) + params = { lat: query.coordinates[0], lon: query.coordinates[1], radius: query.options[:radius] } + + if (dsort = query.options[:distance_sort]) + params[:distance_sort] = dsort ? 'true' : 'false' + end + + if (filter = query_url_params_reverse_filter(query)) + params.merge!(filter) + end + + params + end + + def query_url_params_reverse_filter(query) + filter = query.options[:filter] + return unless filter + + { query_string_filter: filter[:string] } + end + end + end +end diff --git a/lib/geocoder/results/photon.rb b/lib/geocoder/results/photon.rb new file mode 100644 index 000000000..c9ea1fc3d --- /dev/null +++ b/lib/geocoder/results/photon.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require 'geocoder/results/base' + +module Geocoder + module Result + # https://github.com/komoot/photon + class Photon < Base + def name + properties['name'] + end + + def address(_format = :full) + parts = [] + parts << name if name + parts << street_address if street_address + parts << city + parts << state if state + parts << postal_code + parts << country + + parts.join(', ') + end + + def street_address + return unless street + return street unless house_number + + "#{house_number} #{street}" + end + + def house_number + properties['housenumber'] + end + + def street + properties['street'] + end + + def postal_code + properties['postcode'] + end + + def city + properties['city'] + end + + def state + properties['state'] + end + + def state_code + '' + end + + def country + properties['country'] + end + + def country_code + '' + end + + def coordinates + return unless geometry + return unless geometry[:coordinates] + + geometry[:coordinates].reverse + end + + def geometry + return unless data['geometry'] + + symbol_hash data['geometry'] + end + + def bounds + properties['extent'] + end + + # Type of the result (OSM object type), one of: + # + # :node + # :way + # :relation + # + def type + { + 'N' => :node, + 'W' => :way, + 'R' => :relation + }[properties['osm_type']] + end + + def osm_id + properties['osm_id'] + end + + # See: https://wiki.openstreetmap.org/wiki/Tags + def osm_tag + return unless properties['osm_key'] + return properties['osm_key'] unless properties['osm_value'] + + "#{properties['osm_key']}=#{properties['osm_value']}" + end + + private + + def properties + @properties ||= data['properties'] || {} + end + + def symbol_hash(orig_hash) + {}.tap do |result| + orig_hash.each_key do |key| + next unless orig_hash[key] + + result[key.to_sym] = orig_hash[key] + end + end + end + end + end +end From ed78157518680e97c5079ae68d115591e435be35 Mon Sep 17 00:00:00 2001 From: Antti Hukkanen Date: Thu, 23 Jul 2020 00:39:07 +0300 Subject: [PATCH 2/3] Add tests for Photon --- test/fixtures/photon_invalid_request | 3 + test/fixtures/photon_madison_square_garden | 34 +++++ test/fixtures/photon_no_results | 4 + test/fixtures/photon_reverse | 27 ++++ test/test_helper.rb | 15 +++ test/unit/lookups/photon_test.rb | 143 +++++++++++++++++++++ 6 files changed, 226 insertions(+) create mode 100644 test/fixtures/photon_invalid_request create mode 100644 test/fixtures/photon_madison_square_garden create mode 100644 test/fixtures/photon_no_results create mode 100644 test/fixtures/photon_reverse create mode 100644 test/unit/lookups/photon_test.rb diff --git a/test/fixtures/photon_invalid_request b/test/fixtures/photon_invalid_request new file mode 100644 index 000000000..b81055f0e --- /dev/null +++ b/test/fixtures/photon_invalid_request @@ -0,0 +1,3 @@ +{ + "message":"some error happened and the response code is always 400" +} diff --git a/test/fixtures/photon_madison_square_garden b/test/fixtures/photon_madison_square_garden new file mode 100644 index 000000000..d099a4179 --- /dev/null +++ b/test/fixtures/photon_madison_square_garden @@ -0,0 +1,34 @@ +{ + "features":[ + { + "geometry":{ + "coordinates":[ + -73.99355027800776, + 40.7505247 + ], + "type":"Point" + }, + "type":"Feature", + "properties":{ + "osm_id":138141251, + "osm_type":"W", + "extent":[ + -73.9944446, + 40.751161, + -73.9925924, + 40.7498531 + ], + "country":"United States of America", + "osm_key":"leisure", + "housenumber":"4", + "city":"New York", + "street":"Pennsylvania Plaza", + "osm_value":"stadium", + "postcode":"10001", + "name":"Madison Square Garden", + "state":"New York" + } + } + ], + "type":"FeatureCollection" +} diff --git a/test/fixtures/photon_no_results b/test/fixtures/photon_no_results new file mode 100644 index 000000000..44ff5308c --- /dev/null +++ b/test/fixtures/photon_no_results @@ -0,0 +1,4 @@ +{ + "type":"FeatureCollection", + "features":[] +} diff --git a/test/fixtures/photon_reverse b/test/fixtures/photon_reverse new file mode 100644 index 000000000..1ca04d6c8 --- /dev/null +++ b/test/fixtures/photon_reverse @@ -0,0 +1,27 @@ +{ + "features":[ + { + "geometry":{ + "coordinates":[ + -73.9935078, + 40.750499 + ], + "type":"Point" + }, + "type":"Feature", + "properties":{ + "osm_id":6985936386, + "osm_type":"N", + "country":"United States of America", + "osm_key":"tourism", + "housenumber":"4", + "city":"New York", + "street":"Pennsylvania Plaza", + "osm_value":"attraction", + "postcode":"10121", + "state":"New York" + } + } + ], + "type":"FeatureCollection" +} diff --git a/test/test_helper.rb b/test/test_helper.rb index 43a91b9ca..95f2da76d 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -474,6 +474,21 @@ def fixture_prefix end end + require 'geocoder/lookups/photon' + class Photon + private + def read_fixture(file) + filepath = File.join("test", "fixtures", file) + s = File.read(filepath).strip.gsub(/\n\s*/, "") + + options = { body: s, code: 200 } + if file == "photon_invalid_request" + options[:code] = 400 + end + + MockHttpResponse.new(options) + end + end end end diff --git a/test/unit/lookups/photon_test.rb b/test/unit/lookups/photon_test.rb new file mode 100644 index 000000000..e1c96bbc0 --- /dev/null +++ b/test/unit/lookups/photon_test.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require 'test_helper' + +# Test for Photon +class PhotonTest < GeocoderTestCase + def setup + Geocoder.configure(lookup: :photon) + end + + def test_photon_forward_geocoding_result_properties + result = Geocoder.search('Madison Square Garden, New York, NY').first + + geometry = { type: 'Point', coordinates: [-73.99355027800776, 40.7505247] } + bounds = [-73.9944446, 40.751161, -73.9925924, 40.7498531] + + assert_equal(40.7505247, result.latitude) + assert_equal(-73.99355027800776, result.longitude) + assert_equal '4 Pennsylvania Plaza', result.street_address + assert_equal 'Madison Square Garden, 4 Pennsylvania Plaza, New York, New York, 10001, United States of America', + result.address + assert_equal '4', result.house_number + assert_equal 'Pennsylvania Plaza', result.street + assert_equal geometry, result.geometry + assert_equal bounds, result.bounds + assert_equal :way, result.type + assert_equal 138_141_251, result.osm_id + assert_equal 'leisure=stadium', result.osm_tag + end + + def test_photon_reverse_geocoding_result_properties + result = Geocoder.search([45.423733, -75.676333]).first + + geometry = { type: 'Point', coordinates: [-73.9935078, 40.750499] } + + assert_equal(40.750499, result.latitude) + assert_equal(-73.9935078, result.longitude) + assert_equal '4 Pennsylvania Plaza', result.street_address + assert_equal '4 Pennsylvania Plaza, New York, New York, 10121, United States of America', + result.address + assert_equal '4', result.house_number + assert_equal 'Pennsylvania Plaza', result.street + assert_equal geometry, result.geometry + assert_nil result.bounds + assert_equal :node, result.type + assert_equal 6_985_936_386, result.osm_id + assert_equal 'tourism=attraction', result.osm_tag + end + + def test_photon_query_url_contains_language + lookup = Geocoder::Lookup::Photon.new + url = lookup.query_url( + Geocoder::Query.new( + 'Test Query', + language: 'de' + ) + ) + assert_match(/lang=de/, url) + end + + def test_photon_query_url_contains_limit + lookup = Geocoder::Lookup::Photon.new + url = lookup.query_url( + Geocoder::Query.new( + 'Test Query', + limit: 5 + ) + ) + assert_match(/limit=5/, url) + end + + def test_photon_query_url_contains_query + lookup = Geocoder::Lookup::Photon.new + url = lookup.query_url( + Geocoder::Query.new( + 'Test Query' + ) + ) + assert_match(/q=Test\+Query/, url) + end + + def test_photon_query_url_contains_params + lookup = Geocoder::Lookup::Photon.new + url = lookup.query_url( + Geocoder::Query.new( + 'Test Query', + bias: { + latitude: 45.423733, + longitude: -75.676333, + scale: 4 + }, + filter: { + bbox: [-73.9944446, 40.751161, -73.9925924, 40.7498531], + osm_tag: 'leisure:stadium' + } + ) + ) + assert_match(/q=Test\+Query/, url) + assert_match(/lat=45\.423733/, url) + assert_match(/lon=-75\.676333/, url) + assert_match(/bbox=-73\.9944446%2C40\.751161%2C-73\.9925924%2C40\.7498531/, url) + assert_match(/osm_tag=leisure%3Astadium/, url) + end + + def test_photon_reverse_query_url_contains_lat_lon + lookup = Geocoder::Lookup::Photon.new + url = lookup.query_url( + Geocoder::Query.new( + [45.423733, -75.676333] + ) + ) + assert_no_match(/q=.*/, url) + assert_match(/lat=45\.423733/, url) + assert_match(/lon=-75\.676333/, url) + end + + def test_photon_reverse_query_url_contains_params + lookup = Geocoder::Lookup::Photon.new + url = lookup.query_url( + Geocoder::Query.new( + [45.423733, -75.676333], + radius: 5, + distance_sort: true, + filter: { + string: 'query string filter' + } + ) + ) + assert_no_match(/q=.*/, url) + assert_match(/lat=45\.423733/, url) + assert_match(/lon=-75\.676333/, url) + assert_match(/radius=5/, url) + assert_match(/distance_sort=true/, url) + assert_match(/query_string_filter=query\+string\+filter/, url) + end + + def test_photon_invalid_request + Geocoder.configure(always_raise: [Geocoder::InvalidRequest]) + assert_raises Geocoder::InvalidRequest do + Geocoder.search('invalid request') + end + end +end From 9fdb9eb35fdc33e03ef446c5c53b11a43b92b003 Mon Sep 17 00:00:00 2001 From: Antti Hukkanen Date: Thu, 23 Jul 2020 00:39:35 +0300 Subject: [PATCH 3/3] Add API guide documentation for Photon --- README_API_GUIDE.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/README_API_GUIDE.md b/README_API_GUIDE.md index c0cf1e739..760e69be4 100644 --- a/README_API_GUIDE.md +++ b/README_API_GUIDE.md @@ -224,6 +224,42 @@ Open source geocoding engine which can be self-hosted. MapTiler.com hosts an ins * **Terms of Service**: https://www.maptiler.com/terms/ * **Notes**: To use self-hosted service, set the `:host` option in `Geocoder.configure`. +### Photon (`:photon`) + +Open source geocoding engine which can be self-hosted. Komoot hosts a public installation for fair use without the need for an API key (usage might be subject of change). + +* **API key**: none +* **Quota**: You can use the API for your project, but please be fair - extensive usage will be throttled. +* **Region**: world +* **SSL support**: yes +* **Languages**: en, de, fr, it +* **Extra query options** (see [Photon documentation](https://github.com/komoot/photon) for more information): + * `:limit` - restrict the maximum amount of returned results, e.g. `limit: 5` + * `:filter` - extra filters for the search + * `:bbox` (forward) - restricts the bounding box for the forward search, + e.g. `filter: { bbox: [9.5, 51.5, 11.5, 53.5] }` + (minLon, minLat, maxLon, maxLat). + * `:osm_tag` (forward) - filters forward search results by + [tags and values](https://taginfo.openstreetmap.org/projects/nominatim#tags), + e.g. `filter: { osm_tag: 'tourism:museum' }`, + see API documentation for more information. + * `:string` (reverse) - filters the reverse search results by a query + string filter, e.g. `filter: { string: 'query string filter' }`, + * `:bias` (forward) - a location bias based on which results are + prioritized, provide an option hash with the keys `:latitude`, + `:longitude`, and `:scale` (optional, default scale: 1.6), e.g. + `bias: { latitude: 12, longitude: 12, scale: 4 }` + * `:radius` (reverse) - a kilometer radius for the reverse geocoding search, + must be a positive number between 0-5000 (default radius: 1), + e.g. `radius: 10` + * `:distance_sort` (reverse) - defines if results are sorted by distance for + reverse search queries or not, only available if the distance sorting is + enabled for the instace, e.g. `distance_sort: true` +* **Documentation**: https://github.com/komoot/photon +* **Terms of Service**: https://photon.komoot.de/ +* **Limitations**: The public API provider (Komoot) does not guarantee for the availability and usage might be subject of change in the future. You can host your own Photon server without such limitations. [Data licensed under Open Database License (ODbL) (you must provide attribution).](https://www.openstreetmap.org/copyright) +* **Notes**: To use Photon set `Geocoder.configure(lookup: :photon, use_https: true)`. If you are [running your own instance of Photon](https://github.com/komoot/photon) you can configure the host like this: `Geocoder.configure(lookup: :photon, use_https: true, photon: {host: "photon.example.org"})`. + Regional Street Address Lookups -------------------------------