FoveaCentral/google_maps_geocoder

View on GitHub
lib/google_maps_geocoder/google_maps_geocoder.rb

Summary

Maintainability
A
25 mins
Test Coverage
# 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