diff --git a/README_API_GUIDE.md b/README_API_GUIDE.md index 58a103052..b9241c11e 100644 --- a/README_API_GUIDE.md +++ b/README_API_GUIDE.md @@ -61,6 +61,18 @@ Global Street Address Lookups ``` * Via environment variables and other external methods. See **Setting AWS Credentials** in the [AWS SDK for Ruby Developer Guide](https://docs.aws.amazon.com/sdk-for-ruby/v3/developer-guide/setup-config.html). +### Azure (`:azure`) + +* **Key signup**: https://azure.microsoft.com/en-us/products/azure-maps +* **Quota**: 5,000 request/month with free API key, more with paid keys (see https://azure.microsoft.com/en-us/pricing/details/azure-maps) +* **Region**: world +* **SSL support**: yes +* **Languages**: see https://learn.microsoft.com/en-us/azure/azure-maps/supported-languages +* **Documentation**: https://learn.microsoft.com/en-us/azure/azure-maps +* **Terms of Service**: https://azure.microsoft.com/en-us/support/legal +* **Limitations**: Azure Maps doesn't have any maximum daily limits on the number of requests that can be made, however there are limits to the maximum number of queries per second (QPS) (see https://learn.microsoft.com/en-us/azure/azure-maps/azure-maps-qps-rate-limits) +* **Notes**: To use Azure, set `Geocoder.configure(lookup: :azure, api_key: "your_api_key", azure: { limit: your_limit })` limit is optional - limit the maximum number of results returned, default 10. + ### Bing (`:bing`) * **API key**: required (set `Geocoder.configure(lookup: :bing, api_key: key)`) diff --git a/lib/geocoder/lookup.rb b/lib/geocoder/lookup.rb index 87523cae8..bef2f3494 100644 --- a/lib/geocoder/lookup.rb +++ b/lib/geocoder/lookup.rb @@ -32,6 +32,7 @@ def all_services_with_http_requests def street_services @street_services ||= [ :location_iq, + :azure, :esri, :google, :google_premier, diff --git a/lib/geocoder/lookups/azure.rb b/lib/geocoder/lookups/azure.rb new file mode 100644 index 000000000..2021b6dac --- /dev/null +++ b/lib/geocoder/lookups/azure.rb @@ -0,0 +1,56 @@ +require 'geocoder/lookups/base' +require 'geocoder/results/azure' + +module Geocoder::Lookup + class Azure < Base + def name + 'Azure' + end + + def required_api_key_parts + ['api_key'] + end + + def supported_protocols + [:https] + end + + private + + def base_query_url(query) + host = 'atlas.microsoft.com/search/address' + + if query.reverse_geocode? + "#{protocol}://#{host}/reverse/json?" + else + "#{protocol}://#{host}/json?" + end + end + + def query_url_params(query) + params = { + 'api-version' => 1.0, + 'language' => query.options[:language] || 'en', + 'limit' => configuration[:limit] || 10, + 'query' => query.sanitized_text, + 'subscription-key' => configuration.api_key + } + + params.merge(super) + end + + def results(query) + return [] unless (doc = fetch_data(query)) + + return doc if doc['error'] + + if doc['results']&.any? + doc['results'] + elsif doc['addresses']&.any? + doc['addresses'] + else + [] + end + end + end +end \ No newline at end of file diff --git a/lib/geocoder/results/azure.rb b/lib/geocoder/results/azure.rb new file mode 100644 index 000000000..5ea48c298 --- /dev/null +++ b/lib/geocoder/results/azure.rb @@ -0,0 +1,65 @@ +require 'geocoder/results/base' + +module Geocoder::Result + class Azure < Base + def address + @data['address']['freeformAddress'] + end + + def building_number + @data['address']['buildingNumber'] + end + + def city + @data['address']['municipality'] + end + + def coordinates + if @data['position'].is_a?(String) # reverse geocoding result + @data['position'].split(',').map(&:to_f) + elsif @data['position'].is_a?(Hash) # forward geocoding result + [@data['position']['lat'], @data['position']['lon']] + end + end + + def country + @data['address']['country'] + end + + def country_code + @data['address']['countryCode'] + end + + def district + @data['address']['municipalitySubdivision'] + end + + def postal_code + @data['address']['postalCode'] + end + + def province + @data['address']['countrySubdivision'] + end + + def state + @data['address']['countrySubdivision'] + end + + def state_code + @data['address']['countrySubdivisionCode'] + end + + def street_name + @data['address']['streetName'] + end + + def street_number + @data['address']['streetNumber'] + end + + def viewport + @data['viewport'] || {} + end + end +end \ No newline at end of file diff --git a/test/fixtures/azure_invalid_key b/test/fixtures/azure_invalid_key new file mode 100644 index 000000000..799d12b7e --- /dev/null +++ b/test/fixtures/azure_invalid_key @@ -0,0 +1,8 @@ +{ + "error": { + "code": "InvalidKey", + "message": "The provided key was incorrect or the account resource does not exist.", + "target": "WWW-Authenticate", + "details": [] + } +} \ No newline at end of file diff --git a/test/fixtures/azure_jakarta b/test/fixtures/azure_jakarta new file mode 100644 index 000000000..a6b96e2ca --- /dev/null +++ b/test/fixtures/azure_jakarta @@ -0,0 +1,57 @@ +{ + "summary": { + "query": "Jakarta", + "queryType": "NON_NEAR", + "queryTime": 66, + "numResults": 1, + "offset": 0, + "totalResults": 10, + "fuzzyLevel": 1 + }, + "results": [ + { + "type": "Geography", + "id": "6AhgONQ49YLQy9Ku_ocHcA", + "score": 1, + "entityType": "Municipality", + "matchConfidence": { + "score": 1 + }, + "address": { + "municipality": "Jakarta", + "countrySubdivision": "Jakarta", + "countrySubdivisionName": "Jakarta", + "countrySubdivisionCode": "JK", + "countryCode": "ID", + "country": "Indonesia", + "countryCodeISO3": "IDN", + "freeformAddress": "Jakarta, Jakarta" + }, + "position": { + "lat": -6.17476, + "lon": 106.82707 + }, + "viewport": { + "topLeftPoint": { + "lat": -5.95462, "lon": 106.68588 + }, + "btmRightPoint": { + "lat": -6.37083, "lon": 106.9729 + } + }, + "boundingBox": { + "topLeftPoint": { + "lat": -5.95462, "lon": 106.68588 + }, + "btmRightPoint": { + "lat": -6.37083, "lon": 106.9729 + } + }, + "dataSources": { + "geometry": { + "id": "00004944-3100-3c00-0000-000023c347ee" + } + } + } + ] +} \ No newline at end of file diff --git a/test/fixtures/azure_madison_square_garden b/test/fixtures/azure_madison_square_garden new file mode 100644 index 000000000..39e44cf97 --- /dev/null +++ b/test/fixtures/azure_madison_square_garden @@ -0,0 +1,50 @@ +{ + "summary": { + "query": "madison square garden", + "queryType": "NON_NEAR", + "queryTime": 89, + "numResults": 1, + "offset": 0, + "totalResults": 6, + "fuzzyLevel": 1 + }, + "results": [ + { + "type": "Street", + "id": "QyS-HWGa0fkjABCOcS09Rw", + "score": 0.8939382576343416, + "matchConfidence": { + "score": 0.8939382576343416 + }, + "address": { + "streetName": "Garten Place", + "municipality": "Madison Heights", + "countrySecondarySubdivision": "Amherst", + "countrySubdivision": "VA", + "countrySubdivisionName": "Virginia", + "countrySubdivisionCode": "VA", + "postalCode": "24572", + "extendedPostalCode": "24572-4418, 24572-4419", + "countryCode": "US", + "country": "United States", + "countryCodeISO3": "USA", + "freeformAddress": "Garten Place, Madison Heights, VA 24572", + "localName": "Madison Heights" + }, + "position": { + "lat": 37.484629, + "lon": -79.109597 + }, + "viewport": { + "topLeftPoint": { + "lat": 37.485, + "lon": -79.11055 + }, + "btmRightPoint": { + "lat": 37.48439, + "lon": -79.10872 + } + } + } + ] +} diff --git a/test/fixtures/azure_no_results b/test/fixtures/azure_no_results new file mode 100644 index 000000000..0553ba9a7 --- /dev/null +++ b/test/fixtures/azure_no_results @@ -0,0 +1,12 @@ +{ + "summary": { + "query": "Jakarta", + "queryType": "NON_NEAR", + "queryTime": 66, + "numResults": 0, + "offset": 0, + "totalResults": 0, + "fuzzyLevel": 1 + }, + "results": [] +} \ No newline at end of file diff --git a/test/fixtures/azure_reverse b/test/fixtures/azure_reverse new file mode 100644 index 000000000..b2b47014d --- /dev/null +++ b/test/fixtures/azure_reverse @@ -0,0 +1,36 @@ +{ + "summary": { + "queryTime": 12, + "numResults": 1 + }, + "addresses": [ + { + "address": { + "buildingNumber": "10", + "streetNumber": "10", + "routeNumbers": [], + "street": "Jalan Mohammad Husni Thamrin", + "streetName": "Jalan Mohammad Husni Thamrin", + "streetNameAndNumber": "Jalan Mohammad Husni Thamrin 10", + "countryCode": "ID", + "countrySubdivision": "DKI Jakarta", + "municipality": "Jakarta", + "postalCode": "10230", + "municipalitySecondarySubdivision": "Menteng", + "country": "Indonesia", + "countryCodeISO3": "IDN", + "freeformAddress": "Jalan Mohammad Husni Thamrin 10, Kecamatan Jakarta, DKI Jakarta 10230", + "boundingBox": { + "northEast": "-6.198818,106.823264", + "southWest": "-6.199555,106.823246", + "entity": "position" + }, + "countrySubdivisionName": "DKI Jakarta", + "countrySubdivisionCode": "JK", + "localName": "Jakarta" + }, + "position": "-6.199203,106.823082", + "id": "hlyQM4iHOYn16mWumCtnJA" + } + ] +} diff --git a/test/unit/lookups/azure_test.rb b/test/unit/lookups/azure_test.rb new file mode 100644 index 000000000..bac0a363a --- /dev/null +++ b/test/unit/lookups/azure_test.rb @@ -0,0 +1,126 @@ +require 'test_helper' + +class AzureTest < GeocoderTestCase + + def setup + super + Geocoder.configure(lookup: :azure, azure: { limit: 1 }) + set_api_key!(:azure) + end + + def test_azure_results_jakarta_properties + result = Geocoder.search('Jakarta').first + + assert_equal 'Jakarta', result&.city + assert_equal 'Indonesia', result&.country + assert_equal 'Jakarta, Jakarta', result&.address + end + + def test_azure_results_jakarta_coordinates + result = Geocoder.search('Jakarta').first + + assert_equal -6.17476, result&.coordinates[0] + assert_equal 106.82707, result&.coordinates[1] + end + + def test_azure_results_jakarta_viewport + result = Geocoder.search('Jakarta').first + + assert_equal( + { + 'topLeftPoint' => { + 'lat' => -5.95462, + 'lon' => 106.68588 + }, + 'btmRightPoint'=> { + 'lat' => -6.37083, + 'lon' => 106.9729 + } + }, result&.viewport + ) + end + + def test_azure_reverse_results_properties + result = Geocoder.search([-6.198967624433219, 106.82358133258361]).first + + assert_equal 'Jakarta', result&.city + assert_equal 'Indonesia', result&.country + assert_equal 'Jalan Mohammad Husni Thamrin 10, Kecamatan Jakarta, DKI Jakarta 10230', result&.address + end + + def test_azure_no_result + result = Geocoder.search('no results') + + assert_equal 0, result&.length + end + + def test_azure_results_no_street_number + result = Geocoder.search('Jakarta').first + + assert_equal nil, result&.street_number + end + + def test_query_url + query = Geocoder::Query.new('Jakarta') + + assert_equal 'https://atlas.microsoft.com/search/address/json?api-version=1.0&language=en&limit=1&query=Jakarta&subscription-key=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', query.url + end + + def test_reverse_query_url + query = Geocoder::Query.new([-6.198967624433219, 106.82358133258361]) + + assert_equal "https://atlas.microsoft.com/search/address/reverse/json?api-version=1.0&language=en&limit=1&query=-6.198967624433219%2C106.82358133258361&subscription-key=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", query.url + end + + def test_azure_query_url_contains_api_key + lookup = Geocoder::Lookup::Azure.new + url = lookup.query_url( + Geocoder::Query.new( + 'Test Query' + ) + ) + + assert_match(/subscription-key=a+/, url) + end + + def test_azure_query_url_contains_language + lookup = Geocoder::Lookup::Azure.new + url = lookup.query_url( + Geocoder::Query.new( + 'Test Query', + language: 'en' + ) + ) + + assert_match(/language=en/, url) + end + + def test_azure_query_url_contains_text + lookup = Geocoder::Lookup::Azure.new + url = lookup.query_url( + Geocoder::Query.new( + 'PT Kulkul Teknologi Internasional' + ) + ) + + assert_match(/PT\+Kulkul\+Teknologi\+Internasional/i, url) + end + + def test_azure_reverse_query_url_contains_lat_lon + lookup = Geocoder::Lookup::Azure.new + url = lookup.query_url( + Geocoder::Query.new( + [-6.198967624433219, 106.82358133258361] + ) + ) + + assert_match(/query=-6\.198967624433219%2C106\.82358133258361/, url) + end + + def test_azure_invalid_key + result = Geocoder.search('invalid key').first + + assert_equal 'InvalidKey', result&.data&.last['code'] + assert_equal 'The provided key was incorrect or the account resource does not exist.', result&.data&.last['message'] + end +end \ No newline at end of file