geokit/geokit

View on GitHub
lib/geokit/geocoders.rb

Summary

Maintainability
A
15 mins
Test Coverage
require 'geokit/net_adapter/net_http'
require 'geokit/net_adapter/typhoeus'
require 'ipaddr'
require 'json'
require 'logger'
require 'net/http'
require 'openssl'
require 'timeout'
require 'yaml'

module Geokit
  require File.join(File.dirname(__FILE__), 'inflectors')

  # Contains a range of geocoders:
  #
  # ### "regular" address geocoders
  # * Yahoo Geocoder - requires an API key.
  # * Geocoder.ca - for Canada; may require authentication as well.
  # * Geonames - a free geocoder
  #
  # ### address geocoders that also provide reverse geocoding
  # * Google Geocoder - requires an API key.
  #
  # ### IP address geocoders
  # * IP Geocoder - geocodes an IP address using hostip.info's web service.
  # * Geoplugin.net -- another IP address geocoder
  # * IP-API.com -- another IP address geocoder
  #
  # ### The Multigeocoder
  # * Multi Geocoder - provides failover for the physical location geocoders.
  #
  # Some of these geocoders require configuration. You don't have to provide it here. See the README.
  module Geocoders
    @@proxy = nil
    @@useragent = nil
    @@request_timeout = nil
    @@provider_order = [:google, :us]
    @@ip_provider_order = [:geo_plugin, :ip]
    @@logger = Logger.new(STDOUT)
    @@logger.level = Logger::INFO
    @@host = nil
    @@domain = nil
    @@net_adapter = Geokit::NetAdapter::NetHttp
    @@secure = true
    @@ssl_verify_mode = OpenSSL::SSL::VERIFY_PEER

    def self.__define_accessors
      class_variables.each do |v|
        sym = v.to_s.delete('@').to_sym
        next if self.respond_to? sym
        module_eval <<-EOS, __FILE__, __LINE__
          def self.#{sym}
            value = if defined?(#{sym.to_s.upcase})
              #{sym.to_s.upcase}
            else
              @@#{sym}
            end
            if value.is_a?(Hash)
              value = (self.domain.nil? ? nil : value[self.domain]) || value.values.first
            end
            value
          end

          def self.#{sym}=(obj)
            @@#{sym} = obj
          end
        EOS
      end
    end

    __define_accessors

    # Error which is thrown in the event a geocoding error occurs.
    class GeocodeError < StandardError; end
    class TooManyQueriesError < StandardError; end
    class AccessDeniedError < StandardError; end
    class NoSuchGeocoderError < StandardError; end

    # -------------------------------------------------------------------------------------------
    # Geocoder Base class -- every geocoder should inherit from this
    # -------------------------------------------------------------------------------------------

    # The Geocoder base class which defines the interface to be used by all
    # other geocoders.
    class Geocoder
      # Main method which calls the do_geocode template method which subclasses
      # are responsible for implementing.  Returns a populated GeoLoc or an
      # empty one with a failed success code.
      def self.geocode(address, *args)
        logger.debug "#{provider_name} geocoding. address: #{address}, args #{args}"
        do_geocode(address, *args) || GeoLoc.new
      rescue TooManyQueriesError, GeocodeError, AccessDeniedError, NoSuchGeocoderError
        raise
      rescue => e
        logger.error "Caught an error during #{provider_name} geocoding call: #{$!}"
        logger.error e.backtrace.join("\n")
        GeoLoc.new
      end
      # Main method which calls the do_reverse_geocode template method which subclasses
      # are responsible for implementing.  Returns a populated GeoLoc or an
      # empty one with a failed success code.
      def self.reverse_geocode(latlng, *args)
        logger.debug "#{provider_name} geocoding. latlng: #{latlng}, args #{args}"
        do_reverse_geocode(latlng, *args) || GeoLoc.new
      end

      protected

      def self.logger
        Geokit::Geocoders.logger
      end

      private

      def self.config(*attrs)
        attrs.each do |attr|
          class_eval <<-METHOD
            @@#{attr} = nil
            def self.#{attr}=(value)
              @@#{attr} = value
            end
            def self.#{attr}
              @@#{attr}
            end
          METHOD
        end
      end

      def self.inherited(base)
        base.config :secure
      end

      def self.new_loc
        loc = GeoLoc.new
        loc.provider = Geokit::Inflector.underscore(provider_name)
        loc
      end

      # Call the geocoder service using the timeout if configured.
      def self.call_geocoder_service(url)
        Timeout.timeout(Geokit::Geocoders.request_timeout) { return do_get(url) } if Geokit::Geocoders.request_timeout
        do_get(url)
      rescue Timeout::Error
        nil
      end

      # Not all geocoders can do reverse geocoding. So, unless the subclass explicitly overrides this method,
      # a call to reverse_geocode will return an empty GeoLoc. If you happen to be using MultiGeocoder,
      # this will cause it to failover to the next geocoder, which will hopefully be one which supports reverse geocoding.
      def self.do_reverse_geocode(_latlng)
        GeoLoc.new
      end

      def self.use_https?
        secure && Geokit::Geocoders.secure
      end

      def self.protocol
        use_https? ? 'https' : 'http'
      end

      # Wraps the geocoder call around a proxy if necessary.
      def self.do_get(url)
        net_adapter.do_get(url)
      end

      def self.net_adapter
        Geokit::Geocoders.net_adapter
      end

      def self.provider_name
        name.split('::').last.gsub(/Geocoder$/, '')
      end

      def self.parse(format, body, *args)
        logger.debug "#{provider_name} geocoding. Result: #{CGI.escape(body)}"

        if format == :xml
          begin
            require 'rexml/document'
          rescue LoadError
            logger.error "REXML load error, if using Ruby 3.0 add 'rexml' to your Gemfile"
            raise
          end
        end

        case format
        when :json then parse_json(JSON.load(body), *args)
        when :xml  then parse_xml(REXML::Document.new(body), *args)
        when :yaml then parse_yaml(YAML.load(body), *args)
        when :csv  then parse_csv(body.chomp.split(','), *args)
        end
      end

      def self.set_mappings(loc, xml, mappings)
        mappings.each_pair do |field, xml_field|
          loc.send("#{field}=", xml.elements[xml_field].try(:text))
        end
      end

      def self.process(format, url, *args)
        res = call_geocoder_service(url)
        return GeoLoc.new unless net_adapter.success?(res)
        parse format, res.body, *args
      end
    end

    # -------------------------------------------------------------------------------------------
    # "Regular" Address geocoders
    # -------------------------------------------------------------------------------------------
    require File.join(File.dirname(__FILE__), 'geocoders/base_ip')
    Dir[File.join(File.dirname(__FILE__), '/geocoders/*.rb')].each { |f| require f }

    require File.join(File.dirname(__FILE__), 'multi_geocoder')
  end
end