From 96802dbcafc1df25a242fc100d7f06a580960d86 Mon Sep 17 00:00:00 2001
From: Alex Reisner <alex@alexreisner.com>
Date: Tue, 6 Nov 2012 12:51:51 -0500
Subject: [PATCH] Upgrade Yahoo from old/current API to Yahoo BOSS.

---
 README.md                                     |  18 +--
 lib/geocoder/configuration.rb                 |   2 +-
 lib/geocoder/lookups/yahoo.rb                 |  57 +++-------
 lib/oauth_util.rb                             | 106 ++++++++++++++++++
 .../fixtures/yahoo_madison_square_garden.json |  96 ++++++++--------
 test/fixtures/yahoo_no_results.json           |  14 +--
 .../yahoo_v1_madison_square_garden.json       |  46 --------
 test/fixtures/yahoo_v1_no_results.json        |  10 --
 test/lookup_test.rb                           |   6 -
 test/services_test.rb                         |  39 +++----
 test/test_helper.rb                           |   6 +-
 11 files changed, 205 insertions(+), 195 deletions(-)
 create mode 100644 lib/oauth_util.rb
 delete mode 100644 test/fixtures/yahoo_v1_madison_square_garden.json
 delete mode 100644 test/fixtures/yahoo_v1_no_results.json

diff --git a/README.md b/README.md
index 7e7b60c6..c1113188 100644
--- a/README.md
+++ b/README.md
@@ -263,7 +263,7 @@ By default Geocoder uses Google's geocoding API to fetch coordinates and street
     Geocoder.configure do |config|
 
       # geocoding service (see below for supported options):
-      config.lookup = :yahoo
+      config.lookup = :yandex
 
       # to use an API key:
       config.api_key = "..."
@@ -304,17 +304,19 @@ The following is a comparison of the supported geocoding APIs. The "Limitations"
 * **Limitations**: "You must not use or display the Content without a corresponding Google map, unless you are explicitly permitted to do so in the Maps APIs Documentation, or through written permission from Google." "You must not pre-fetch, cache, or store any Content, except that you may store: (i) limited amounts of Content for the purpose of improving the performance of your Maps API Implementation..."
 * **Notes**: To use Google Premier set `Geocoder::Configuration.lookup = :google_premier` and `Geocoder::Configuration.api_key = [key, client, channel]`.
 
-#### Yahoo (`:yahoo`)
+#### Yahoo BOSS (`:yahoo`)
 
-* **API key**: optional in development (required for production apps)
-* **Key signup**: https://developer.apps.yahoo.com/wsregapp
-* **Quota**: 50,000 requests/day, more available by special arrangement
+Yahoo BOSS is **not a free service**. As of November 17, 2012 Yahoo no longer offers a free geocoding API.
+
+* **API key**: requires OAuth consumer key and secret (set `Geocoder::Configuration.api_key = [key, secret]`)
+* **Key signup**: http://developer.yahoo.com/boss/geo/
+* **Quota**: unlimited, but subject to usage fees
 * **Region**: world
 * **SSL support**: no
 * **Languages**: ?
-* **Documentation**: http://developer.yahoo.com/geo/placefinder/guide/responses.html
-* **Terms of Service**: http://info.yahoo.com/legal/us/yahoo/maps/mapsapi/mapsapi-2141.html
-* **Limitations**: "YOU SHALL NOT... (viii) store or allow end users to store map imagery, map data or geocoded location information from the Yahoo! Maps APIs for any future use; (ix) use the stand-alone geocoder for any use other than displaying Yahoo! Maps or displaying points on Yahoo! Maps;"
+* **Documentation**: http://developer.yahoo.com/boss/geo/docs/index.html
+* **Terms of Service**: http://info.yahoo.com/legal/us/yahoo/boss/tou/?pir=ucJPcJ1ibUn.h.d.lVmlcbcEkoHjwJ_PvxG9SLK9VIbIQAw1XFrnDqY-
+* **Limitations**: No mass downloads, no commercial map production based on the data, no storage of data except for caching.
 
 #### Bing (`:bing`)
 
diff --git a/lib/geocoder/configuration.rb b/lib/geocoder/configuration.rb
index c2df6d3e..dd426e0b 100644
--- a/lib/geocoder/configuration.rb
+++ b/lib/geocoder/configuration.rb
@@ -23,7 +23,7 @@ module Geocoder
   #
   #   Geocoder.configure do |config|
   #     config.timeout      = 5
-  #     config.lookup       = :yahoo
+  #     config.lookup       = :yandex
   #     config.api_key      = "2a9fsa983jaslfj982fjasd"
   #     config.units        = :km
   #   end
diff --git a/lib/geocoder/lookups/yahoo.rb b/lib/geocoder/lookups/yahoo.rb
index f27beca0..adbe2e98 100644
--- a/lib/geocoder/lookups/yahoo.rb
+++ b/lib/geocoder/lookups/yahoo.rb
@@ -1,5 +1,6 @@
 require 'geocoder/lookups/base'
 require "geocoder/results/yahoo"
+require 'oauth_util'
 
 module Geocoder::Lookup
   class Yahoo < Base
@@ -12,47 +13,16 @@ module Geocoder::Lookup
 
     def results(query)
       return [] unless doc = fetch_data(query)
-      doc = doc['ResultSet']
-      if api_version(doc).to_i == 1 and r = version_1_results(doc)
-        return r
-      elsif api_version(doc).to_i == 2 and r = version_2_results(doc)
-        return r
-      else
-        warn "Yahoo Geocoding API error: #{doc['Error']} (#{doc['ErrorMessage']})."
-        return []
-      end
-    end
-
-    def api_version(doc)
-      if doc.include?('version')
-        return doc['version'].to_f
-      elsif doc.include?('@version')
-        return doc['@version'].to_f
-      end
-    end
-
-    def version_1_results(doc)
-      if doc['Error'] == 0
-        if doc['Found'] > 0
-          return doc['Results']
-        else
-          return []
-        end
-      end
-    end
-
-    ##
-    # Return array of results, or nil if an error.
-    #
-    def version_2_results(doc)
-      # seems to have Error == 7 when no results, though this is not documented
-      if [0, 7].include?(doc['Error'].to_i)
-        if doc['Found'].to_i > 0
-          r = doc['Result']
-          return r.is_a?(Array) ? r : [r]
+      doc = doc['bossresponse']
+      if doc['responsecode'].to_i == 200
+        if doc['placefinder']['count'].to_i > 0
+          return doc['placefinder']['results']
         else
           return []
         end
+      else
+        warn "Yahoo Geocoding API error: #{doc['responsecode']}."
+        return []
       end
     end
 
@@ -60,14 +30,17 @@ module Geocoder::Lookup
       super.merge(
         :location => query.sanitized_text,
         :flags => "JXTSR",
-        :gflags => "AC#{'R' if query.reverse_geocode?}",
-        :locale => "#{Geocoder::Configuration.language}_US",
-        :appid => Geocoder::Configuration.api_key
+        :gflags => "AC#{'R' if query.reverse_geocode?}"
       )
     end
 
     def query_url(query)
-      "http://where.yahooapis.com/geocode?" + url_query_string(query)
+      base_url = "http://yboss.yahooapis.com/geo/placefinder?"
+      parsed_url = URI.parse(base_url + url_query_string(query))
+      o = OauthUtil.new
+      o.consumer_key = Geocoder::Configuration.api_key[0]
+      o.consumer_secret = Geocoder::Configuration.api_key[1]
+      base_url + o.sign(parsed_url).query_string
     end
   end
 end
diff --git a/lib/oauth_util.rb b/lib/oauth_util.rb
new file mode 100644
index 00000000..a98cb686
--- /dev/null
+++ b/lib/oauth_util.rb
@@ -0,0 +1,106 @@
+# A utility for signing an url using OAuth in a way that's convenient for debugging
+# Note: the standard Ruby OAuth lib is here http://github.com/mojodna/oauth
+# Source: http://gist.github.com/383159
+# License: http://gist.github.com/375593
+# Usage: see example.rb below
+
+require 'uri'
+require 'cgi'
+require 'openssl'
+require 'base64'
+
+class OauthUtil
+
+  attr_accessor :consumer_key, :consumer_secret, :token, :token_secret, :req_method, 
+                :sig_method, :oauth_version, :callback_url, :params, :req_url, :base_str
+
+  def initialize
+    @consumer_key = ''
+    @consumer_secret = ''
+    @token = ''
+    @token_secret = ''
+    @req_method = 'GET'
+    @sig_method = 'HMAC-SHA1'
+    @oauth_version = '1.0'
+    @callback_url = ''
+  end
+
+  # openssl::random_bytes returns non-word chars, which need to be removed. using alt method to get length
+  # ref http://snippets.dzone.com/posts/show/491
+  def nonce
+    Array.new( 5 ) { rand(256) }.pack('C*').unpack('H*').first
+  end
+
+  def percent_encode( string )
+
+    # ref http://snippets.dzone.com/posts/show/1260
+    return URI.escape( string, Regexp.new("[^#{URI::PATTERN::UNRESERVED}]") ).gsub('*', '%2A')
+  end
+
+  # @ref http://oauth.net/core/1.0/#rfc.section.9.2
+  def signature
+    key = percent_encode( @consumer_secret ) + '&' + percent_encode( @token_secret )
+
+    # ref: http://blog.nathanielbibler.com/post/63031273/openssl-hmac-vs-ruby-hmac-benchmarks
+    digest = OpenSSL::Digest::Digest.new( 'sha1' )
+    hmac = OpenSSL::HMAC.digest( digest, key, @base_str )
+
+    # ref http://groups.google.com/group/oauth-ruby/browse_thread/thread/9110ed8c8f3cae81
+    Base64.encode64( hmac ).chomp.gsub( /\n/, '' )
+  end
+
+  # sort (very important as it affects the signature), concat, and percent encode
+  # @ref http://oauth.net/core/1.0/#rfc.section.9.1.1
+  # @ref http://oauth.net/core/1.0/#9.2.1
+  # @ref http://oauth.net/core/1.0/#rfc.section.A.5.1
+  def query_string
+    pairs = []
+    @params.sort.each { | key, val | 
+      pairs.push( "#{ percent_encode( key ) }=#{ percent_encode( val.to_s ) }" )
+    }
+    pairs.join '&'
+  end
+
+  # organize params & create signature
+  def sign( parsed_url )
+
+    @params = {
+      'oauth_consumer_key' => @consumer_key,
+      'oauth_nonce' => nonce,
+      'oauth_signature_method' => @sig_method,
+      'oauth_timestamp' => Time.now.to_i.to_s,
+      'oauth_version' => @oauth_version
+    }
+
+    # if url has query, merge key/values into params obj overwriting defaults
+    if parsed_url.query
+      #@params.merge! CGI.parse( parsed_url.query )
+      CGI.parse( parsed_url.query ).each do |k,v|
+        if v.is_a?(Array) && v.count == 1
+          @params[k] = v.first
+        else
+          @params[k] = v
+        end
+      end
+    end
+
+    # @ref http://oauth.net/core/1.0/#rfc.section.9.1.2
+    @req_url = parsed_url.scheme + '://' + parsed_url.host + parsed_url.path
+
+    # create base str. make it an object attr for ez debugging
+    # ref http://oauth.net/core/1.0/#anchor14
+    @base_str = [ 
+      @req_method, 
+      percent_encode( req_url ), 
+
+      # normalization is just x-www-form-urlencoded
+      percent_encode( query_string ) 
+
+    ].join( '&' )
+
+    # add signature
+    @params[ 'oauth_signature' ] = signature
+
+    return self
+  end
+end
diff --git a/test/fixtures/yahoo_madison_square_garden.json b/test/fixtures/yahoo_madison_square_garden.json
index f649afe2..24161f60 100644
--- a/test/fixtures/yahoo_madison_square_garden.json
+++ b/test/fixtures/yahoo_madison_square_garden.json
@@ -1,52 +1,52 @@
 {
-  "@lang": "en-US",
-  "ResultSet": {
-    "@version": "2.0",
-    "@lang": "en-US",
-    "Error": "0",
-    "ErrorMessage": "No error",
-    "Locale": "en-US",
-    "Found": "1",
-    "Quality": "90",
-    "Result": {
-      "quality": "90",
-      "latitude": "40.750381",
-      "longitude": "-73.993988",
-      "offsetlat": "40.750381",
-      "offsetlon": "-73.993988",
-      "radius": "100",
-      "boundingbox": {
-        "north": "40.750832",
-        "south": "40.749931",
-        "east": "-73.993393",
-        "west": "-73.994591"
-      },
-      "name": "Madison Square Garden",
-      "line1": "Madison Square Garden",
-      "line2": "New York, NY 10001",
-      "line3": "",
-      "line4": "United States",
-      "cross": "",
-      "house": "",
-      "street": "",
-      "xstreet": "",
-      "unittype": "",
-      "unit": "",
-      "postal": "10001",
-      "neighborhood": "Garment District|Midtown|Midtown West|Manhattan",
-      "city": "New York",
-      "county": "New York County",
-      "state": "New York",
-      "country": "United States",
-      "countrycode": "US",
-      "statecode": "NY",
-      "countycode": "",
-      "timezone": "America/New_York",
-      "areacode": "212",
-      "uzip": "10001",
-      "hash": "",
-      "woeid": "23617041",
-      "woetype": "20"
+  "bossresponse": {
+    "responsecode": "200",
+    "placefinder": {
+      "start": "0",
+      "count": "1",
+      "request": "flags=JXTSR&location=Madison%20Square%20Garden%2C%20NY%2C%20NY&%unsafe%appid=%5B%22dj0yJmk9ZmZ5NXFrNGhNcEthJmQ9WVdrOVFUSlhPV2x1TjJVbWNHbzlORE0wT0RFME9UWXkmcz1jb25zdW1lcnNlY3JldCZ4PTAy%22%2C%20%22b57b1b98eb21f171231f5b441cba505261d6c9bb%22%5D&gflags=AC&locale=en_US",
+      "results": [
+        {
+          "quality": "90",
+          "latitude": "40.750381",
+          "longitude": "-73.993988",
+          "offsetlat": "40.750381",
+          "offsetlon": "-73.993988",
+          "radius": "400",
+          "boundingbox": {
+            "north": "40.750832",
+            "south": "40.749931",
+            "east": "-73.993393",
+            "west": "-73.994591"
+          },
+          "name": "Madison Square Garden",
+          "line1": "Madison Square Garden",
+          "line2": "New York, NY 10001",
+          "line3": "",
+          "line4": "United States",
+          "cross": "",
+          "house": "",
+          "street": "",
+          "xstreet": "",
+          "unittype": "",
+          "unit": "",
+          "postal": "10001",
+          "neighborhood": "Garment District|Midtown|Midtown West|Manhattan",
+          "city": "New York",
+          "county": "New York County",
+          "state": "New York",
+          "country": "United States",
+          "countrycode": "US",
+          "statecode": "NY",
+          "countycode": "",
+          "timezone": "America/New_York",
+          "areacode": "212",
+          "uzip": "10001",
+          "hash": "",
+          "woeid": "23617041",
+          "woetype": "20"
+        }
+      ]
     }
   }
 }
diff --git a/test/fixtures/yahoo_no_results.json b/test/fixtures/yahoo_no_results.json
index e97865df..0c65fddc 100644
--- a/test/fixtures/yahoo_no_results.json
+++ b/test/fixtures/yahoo_no_results.json
@@ -1,10 +1,10 @@
 {
-  "ResultSet":{
-    "version":"1.0",
-    "Error":0,
-    "ErrorMessage":"No error",
-    "Locale":"us_US",
-    "Quality":10,
-    "Found":0
+  "bossresponse": {
+    "responsecode": "200",
+    "placefinder": {
+      "start": "0",
+      "count": "0",
+      "request": "flags=JXTSR&location=asdfasdf28394782sdfj2983&%unsafe%appid=%5B%22dj0yJmk9ZmZ5NXFrNGhNcEthJmQ9WVdrOVFUSlhPV2x1TjJVbWNHbzlORE0wT0RFME9UWXkmcz1jb25zdW1lcnNlY3JldCZ4PTAy%22%2C%20%22b57b1b98eb21f171231f5b441cba505261d6c9bb%22%5D&gflags=AC&locale=en_US"
+    }
   }
 }
diff --git a/test/fixtures/yahoo_v1_madison_square_garden.json b/test/fixtures/yahoo_v1_madison_square_garden.json
deleted file mode 100644
index 6e54b8e7..00000000
--- a/test/fixtures/yahoo_v1_madison_square_garden.json
+++ /dev/null
@@ -1,46 +0,0 @@
-{
-  "ResultSet":{
-    "version":"1.0",
-    "Error":0,
-    "ErrorMessage":"No error",
-    "Locale":"us_US",
-    "Quality":90,
-    "Found":1,
-    "Results":[{
-      "quality":90,
-      "latitude":"40.750381",
-      "longitude":"-73.993988",
-      "offsetlat":"40.750381",
-      "offsetlon":"-73.993988",
-      "radius":100,
-      "name":"Madison Square Garden",
-      "line1":"Madison Square Garden",
-      "line2":"New York, NY  10001",
-      "line3":"",
-      "line4":"United States",
-      "house":"",
-      "street":"",
-      "xstreet":"",
-      "unittype":"",
-      "unit":"",
-      "postal":"10001",
-      "neighborhood":"",
-      "city":"New York",
-      "county":"New York County",
-      "state":"New York",
-      "country":"United States",
-      "countrycode":"US",
-      "statecode":"NY",
-      "countycode":"",
-      "uzip":"10001",
-      "hash":"",
-      "woeid":23617041,
-      "woetype":20,
-      "cross":"",
-      "timezone":"America/New_York",
-      "neighborhood":"Garment District|Midtown|Midtown West|Manhattan",
-      "areacode":"212",
-      "boundingbox":{"north":"40.750832","south":"40.749931","east":"-73.993393","west":"-73.994591"}
-    }]
-  }
-}
diff --git a/test/fixtures/yahoo_v1_no_results.json b/test/fixtures/yahoo_v1_no_results.json
deleted file mode 100644
index e97865df..00000000
--- a/test/fixtures/yahoo_v1_no_results.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
-  "ResultSet":{
-    "version":"1.0",
-    "Error":0,
-    "ErrorMessage":"No error",
-    "Locale":"us_US",
-    "Quality":10,
-    "Found":0
-  }
-}
diff --git a/test/lookup_test.rb b/test/lookup_test.rb
index 5b4550c6..a0c8cd22 100644
--- a/test/lookup_test.rb
+++ b/test/lookup_test.rb
@@ -29,12 +29,6 @@ class LookupTest < Test::Unit::TestCase
     assert_match "key=MY_KEY", g.send(:query_url, Geocoder::Query.new("Madison Square Garden, New York, NY  10001, United States"))
   end
 
-  def test_yahoo_app_id
-    Geocoder::Configuration.api_key = "MY_KEY"
-    g = Geocoder::Lookup::Yahoo.new
-    assert_match "appid=MY_KEY", g.send(:query_url, Geocoder::Query.new("Madison Square Garden, New York, NY  10001, United States"))
-  end
-
   def test_geocoder_ca_showpostal
     Geocoder::Configuration.api_key = "MY_KEY"
     g = Geocoder::Lookup::GeocoderCa.new
diff --git a/test/services_test.rb b/test/services_test.rb
index c689f369..9d204aac 100644
--- a/test/services_test.rb
+++ b/test/services_test.rb
@@ -8,10 +8,20 @@ class ServicesTest < Test::Unit::TestCase
     Geocoder::Lookup.all_services_except_test.each do |l|
       next if l == :google_premier # TODO: need to set keys to test
       next if l == :freegeoip # does not use query string
+      # need to set API key for Yahoo for OAuth encoding
+      if l == :yahoo
+        Geocoder::Configuration.api_key = [
+          'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
+          'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
+        ]
+      else
+        Geocoder::Configuration.api_key = nil
+      end
       url = Geocoder::Lookup.get(l).send(:query_url, Geocoder::Query.new(
         "test", :params => {:one_in_the_hand => "two in the bush"}
       ))
-      assert_match /one_in_the_hand=two\+in\+the\+bush/, url,
+      # should be "+"s for all lookups except Yahoo
+      assert_match /one_in_the_hand=two(%20|\+)in(%20|\+)the(%20|\+)bush/, url,
         "Lookup #{l} does not appear to support arbitrary params in URL"
     end
   end
@@ -67,37 +77,20 @@ class ServicesTest < Test::Unit::TestCase
 
   # --- Yahoo ---
 
-  def test_yahoo_v1_no_results
-    Geocoder::Configuration.lookup = :yahoo
-    assert_equal [], Geocoder.search("no results v1")
-  end
-
-  def test_yahoo_v1_result_components
-    Geocoder::Configuration.lookup = :yahoo
-    result = Geocoder.search("madison square garden v1").first
-    assert_equal "10001", result.postal_code
-  end
-
-  def test_yahoo_v1_address_formatting
-    Geocoder::Configuration.lookup = :yahoo
-    result = Geocoder.search("madison square garden v1").first
-    assert_equal "Madison Square Garden, New York, NY  10001, United States", result.address
-  end
-
-  def test_yahoo_v2_no_results
+  def test_yahoo_no_results
     Geocoder::Configuration.lookup = :yahoo
     assert_equal [], Geocoder.search("no results")
   end
 
-  def test_yahoo_v2_result_components
+  def test_yahoo_result_components
     Geocoder::Configuration.lookup = :yahoo
-    result = Geocoder.search("madison square garden v2").first
+    result = Geocoder.search("madison square garden").first
     assert_equal "10001", result.postal_code
   end
 
-  def test_yahoo_v2_address_formatting
+  def test_yahoo_address_formatting
     Geocoder::Configuration.lookup = :yahoo
-    result = Geocoder.search("madison square garden v2").first
+    result = Geocoder.search("madison square garden").first
     assert_equal "Madison Square Garden, New York, NY 10001, United States", result.address
   end
 
diff --git a/test/test_helper.rb b/test/test_helper.rb
index 1f592ecf..7361b300 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -103,10 +103,8 @@ module Geocoder
         raise TimeoutError if query.text == "timeout"
         raise SocketError if query.text == "socket_error"
         file = case query.text
-          when "no results v1"; :v1_no_results
-          when "madison square garden v1"; :v1_madison_square_garden
-          when "no results";    :no_results
-          else                  :madison_square_garden
+          when "no results"; :no_results
+          else               :madison_square_garden
         end
         read_fixture "yahoo_#{file}.json"
       end
-- 
GitLab