lib/google_maps_geocoder/google_maps_geocoder.rb
# Copyright the GoogleMapsGeocoder contributors.
# SPDX-License-Identifier: MIT
# frozen_string_literal: true
require 'json'
require 'logger'
require 'net/http'
require 'rack'
# A simple PORO wrapper for geocoding with Google Maps.
#
# @example
# chez_barack = GoogleMapsGeocoder.new '1600 Pennsylvania DC'
# chez_barack.formatted_address
# => "1600 Pennsylvania Avenue Northwest, President's Park,
# Washington, DC 20500, USA"
# rubocop:disable Metrics/ClassLength
class GoogleMapsGeocoder
GOOGLE_ADDRESS_SEGMENTS = %i[
city country_long_name country_short_name county lat lng postal_code
state_long_name state_short_name
].freeze
private_constant :GOOGLE_ADDRESS_SEGMENTS
GOOGLE_MAPS_API = 'https://maps.googleapis.com/maps/api/geocode/json'
private_constant :GOOGLE_MAPS_API
ALL_ADDRESS_SEGMENTS = (
GOOGLE_ADDRESS_SEGMENTS + %i[formatted_address formatted_street_address]
).freeze
private_constant :ALL_ADDRESS_SEGMENTS
# Returns the complete formatted address with standardized abbreviations.
#
# @return [String] the complete formatted address
# @example
# chez_barack.formatted_address
# => "1600 Pennsylvania Avenue Northwest, President's Park,
# Washington, DC 20500, USA"
attr_reader :formatted_address
# Returns the formatted street address with standardized abbreviations.
#
# @return [String] the formatted street address
# @example
# chez_barack.formatted_street_address
# => "1600 Pennsylvania Avenue Northwest"
attr_reader :formatted_street_address
# Self-explanatory
attr_reader(*GOOGLE_ADDRESS_SEGMENTS)
# Returns the formatted address as a comma-delimited string.
alias address formatted_address
# Returns the address' country as a full string.
alias country country_long_name
# Returns the address' country as an abbreviated string.
alias country_code country_short_name
# Returns the address' latitude as a float.
alias latitude lat
# Returns the address' longitude as a float.
alias longitude lng
# Returns the address' state as a full string.
alias state state_long_name
# Returns the address' state as an abbreviated string.
alias state_code state_short_name
# Geocodes the specified address and wraps the results in a GoogleMapsGeocoder
# object.
#
# @param address [String] a geocodable address
# @return [GoogleMapsGeocoder] the Google Maps result for the specified
# address
# @example
# chez_barack = GoogleMapsGeocoder.new '1600 Pennsylvania DC'
def initialize(address)
@json = address.is_a?(String) ? google_maps_response(address) : address
status = @json && @json['status']
raise RuntimeError if status == 'OVER_QUERY_LIMIT'
raise GeocodingError, @json if !@json || @json.empty? || status != 'OK'
set_attributes_from_json
Logger.new($stderr).info('GoogleMapsGeocoder') do
"Geocoded \"#{address}\" => \"#{formatted_address}\""
end
end
# Returns the address' coordinates as an array of floats.
def coordinates
[lat, lng]
end
# Returns true if the address Google returns is an exact match.
#
# @return [boolean] whether the Google Maps result is an exact match
# @example
# chez_barack.exact_match?
# => true
def exact_match?
!partial_match?
end
# Returns true if the address Google returns isn't an exact match.
#
# @return [boolean] whether the Google Maps result is a partial match
# @example
# GoogleMapsGeocoder.new('1600 Pennsylvania DC').partial_match?
# => true
def partial_match?
@json['results'][0]['partial_match'] == true
end
# A geocoding error returned by Google Maps.
class GeocodingError < StandardError
# Returns the complete JSON response from Google Maps as a Hash.
#
# @return [Hash] Google Maps' JSON response
# @example
# {
# "results" => [],
# "status" => "ZERO_RESULTS"
# }
attr_reader :json
# Initialize a GeocodingError wrapping the JSON returned by Google Maps.
#
# @param json [Hash] Google Maps' JSON response
# @return [GeocodingError] the geocoding error
def initialize(json = {})
@json = json
if (message = @json['error_message'])
Logger.new($stderr).error(message)
end
super @json
end
end
private
def google_maps_request(address)
"#{GOOGLE_MAPS_API}?address=#{Rack::Utils.escape address}"\
"&key=#{ENV['GOOGLE_MAPS_API_KEY']}"
end
def google_maps_response(address)
uri = URI.parse google_maps_request(address)
response = http(uri).request(Net::HTTP::Get.new(uri.request_uri))
JSON.parse response.body
end
def http(uri)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
http
end
def parse_address_component_type(type, name = 'long_name')
address_component = @json['results'][0]['address_components'].detect do |ac|
ac['types']&.include?(type)
end
address_component && address_component[name]
end
def parse_city
parse_address_component_type('sublocality') ||
parse_address_component_type('locality')
end
def parse_country_long_name
parse_address_component_type('country')
end
def parse_country_short_name
parse_address_component_type('country', 'short_name')
end
def parse_county
parse_address_component_type('administrative_area_level_2')
end
def parse_formatted_address
@json['results'][0]['formatted_address']
end
def parse_formatted_street_address
"#{parse_address_component_type('street_number')} "\
"#{parse_address_component_type('route')}"
end
def parse_lat
@json['results'][0]['geometry']['location']['lat']
end
def parse_lng
@json['results'][0]['geometry']['location']['lng']
end
def parse_postal_code
parse_address_component_type('postal_code')
end
def parse_state_long_name
parse_address_component_type('administrative_area_level_1')
end
def parse_state_short_name
parse_address_component_type('administrative_area_level_1', 'short_name')
end
def set_attributes_from_json
ALL_ADDRESS_SEGMENTS.each do |segment|
instance_variable_set :"@#{segment}", send("parse_#{segment}")
end
end
end
# rubocop:enable Metrics/ClassLength