Yes, geocode, but save your caches

Wow! This takes me back! Please check the date this post was authored, as it may no longer be relevant in a modern context.

Geocoding is like a spicy pepper, it provides an impressive kick, but should normally be sprinkled in moderation. Now that rails has GeoKit, it’s even super-easy to do.

Once you fight your way through the API-only documentation.

But lucky you, we’re going to walk through a basic GeoKit set up modeled after what we used over at FindYourDoc (we launch next week-ish):

  1. Install GeoKit
  2. Find out where a visitor is from and stuff it in a cookie
  3. Test our geocoding (‘cause duh, you’re testing, right?)
  4. Use javascript to put it somewhere

At the end of this you’ll be customizing pages for visitors based on their location, but without killing off your action_caches. Won’t that be a ball?

Installing GeoKit

This one’s easy, like any rails plugin if you run:

ruby script/plugin install -x svn://rubyforge.org/var/svn/geokit/trunk

You’ll install it as a SVN external (and then you can use piston to manage it!), or you can run:

ruby script/plugin install svn://rubyforge.org/var/svn/geokit/trunk

and just pull it down locally. Next we’ll need some configuration lovin’. You’ll want to go get a Google Maps API key for http://localhost:3000/ for while you develop, and chances are you’ll want another key for production. GeoKit’s installer appends a bunch of lines to your config/environment.rb, find the one that looks like this:

GeoKit::Geocoders::GOOGLE='REPLACE_WITH_YOUR_GOOGLE_KEY'

and drop the key Google gave you in there. If you got a key for your production install, you could always:

if RAILS_ENV == 'production'
  GeoKit::Geocoders::GOOGLE='MyProductionKey'
else
  # This will work for most people doing normal dev on localhost
  GeoKit::Geocoders::GOOGLE='MyDevKey'
end

You’ll also want to tell GeoKit to only use Google:

GeoKit::Geocoders::PROVIDER_ORDER=[:google]

So there you are, now to do some geocoding.

Geocode a visitor’s ip, stash it in a cookie

“A cookie?!” I hear you cry, “Why lord why?!” GeoKit comes with a nifty little helper that will stuff a geocoding object into your session automatically, but FindYourDoc needs a bit more. We wanted to know we could scale safely, so we’ve made aggressive use of caching, specifically with the action_cache plugin. Using sessions means we render our geocoded values in our view, so we can’t cache that page.

Ahh, but if we put them in a cookie, we can use a before_filter for that, and still cache our actual action. Then we can grab the cookies in Javascript and render them on our page load. Cache is good to go, and we have different geocoded values appear for each visitor.

Our application controller ends up looking something like this:

class ApplicationController < ActionController::Base
  before_filter :put_geocoding_into_cookies

private
  def put_geocoding_into_cookies
    if set_geokit_cookies?
      set_geokit_cookies( request.remote_ip )
    end
  end

  def set_geokit_cookies?
    unless cookies['geocoded']
      unless cookies['geocoded'] == 'NO'
        return true
    end end
    return false
  end

  def set_geokit_cookies( ip )
    @location = GeoKit::Geocoders::IpGeocoder.geocode( ip )
    if @location.success &&
      @location.respond_to?( :country_code ) &&
      @location.country_code == 'US'
      cookies['zipcode']  = @location.zipcode if @location.respond_to? :zipcode
      cookies['state']    = @location.state if @location.respond_to? :state
      cookies['city']     = @location.city if @location.respond_to? :city
      cookies['geocoded'] = 'YES';
    else
      cookies['geocoded'] = 'NO';
    end
  end

end

Note how it’s split into private methods we could write tests against. If only testing private instance methods on the application controller was that easy (if you have ideas on this, let me know). To run over the highlights of that code:

before_filter :put_geocoding_into_cookies

Tells Rails to run our method put_geocoding_into_cookies when any page has attempted access. set_geokit_cookies? is looking to find out if we have tried to geocode before, and also if we’ve already set a flag of ‘NO’ to say we’ve tried and failed already.

@location = GeoKit::Geocoders::IpGeocoder.geocode( ip )

That’s our magic geocoding line. Note that the ip was passed in from request.remote_ip in put_geocoding_into_cookies. The if statement tests for a valid result, and a result in the US. If we pass the statement, we set our cookies for state, zipcode, and city. Note that we use strings for our keys:

cookies['state']    = @location.state if @location.respond_to? :state

Which is a quirk in Rails, you’d expect a HashWithIndifferentAccess there. The respond_to? test is a work-around for GeoKit’s decision to not create attributes that is has no value for.

And that’s that. Your addresses should be geocoded in tossed into cookies. Try using Firebug and you’ll see them in your server headers. Of course, you’re probably geocoding 127.0.0.1, which won’t really give you anything useful. So how do we know any of this if working?

Testing IP Geocoding

We test it. There are two little hurdles to doing that, the first is pretty handily taken care of by the assert_cookie plugin. Install that, and you’ll be able to:

assert_cookie :geocoded, :value => 'NO'

and we’ll need that. With assert_cookie we can test what cookies get set for what IPs, but we still have another hurdle:

set_geokit_cookies( request.remote_ip )

We need to fake an IP. Enter the AnywhereController:

class AnywhereController < ActionController::TestRequest
  def remote_ip
    @my_remote_ip || @remote_ip
  end

  def go_to_sunnyvale
    @my_remote_ip = '64.233.187.99'
  end

  def go_to_nowhere
    @my_remote_ip = '0.0.0.0'
  end

end

With ActionController::TestRequest subclassed, can can overwrite the remote_ip method to return any IP we want, and therefor test some IPs that will actually geocode. A full geocoding test might look like:

require File.dirname(__FILE__) + '/../test_helper'
require 'welcome_controller'

# Re-raise errors caught by the controller.
class WelcomeController; def rescue_action(e) raise e end; end

class AnywhereController < ActionController::TestRequest
  def remote_ip
    @my_remote_ip || @remote_ip
  end

  def go_to_sunnyvale
    # Google geocoding google? should work, right? ;-)
    @my_remote_ip = '64.233.187.99'
  end

  def go_to_nowhere
    @my_remote_ip = '0.0.0.0'
  end

end

class GeocodingTest < Test::Unit::TestCase

  def setup
    # Here we use our new AnywhereController
    @controller = WelcomeController.new
    @request    = AnywhereController.new
    @response   = ActionController::TestResponse.new
  end

  def test_should_set_cookies_for_sunnyvale
    # Try out google's IP
    @request.go_to_sunnyvale
    get :index
    assert_cookie :city, :value => 'Sunnyvale'
    assert_cookie :state, :value => 'CA'
    assert_cookie :geocoded, :value => 'YES'
  end

  def test_should_not_set_cookies
    # And nowhere-land
    @request.go_to_nowhere
    get :index
    assert_cookie :geocoded, :value => 'NO'
  end

  def test_should_not_keep_trying
    # Changing the IP halfway through should have no affect, since
    # we don't geocode if we've already tried, right?
    @request.go_to_nowhere
    assert_equal '0.0.0.0', @request.remote_ip
    get :index
    assert_cookie :geocoded, :value => 'NO'
    @request.go_to_sunnyvale
    assert_equal '64.233.187.99', @request.remote_ip
    @request.cookies["geocoded"] = CGI::Cookie.new("geocoded", "NO")
    get :index
    assert_cookie :geocoded, :value => 'NO'
  end

end

Whammo, as someone once said: “This code may not run, I have not tried it, I have only tested it”. Our production server should geocode perfectly well if this test suite passes.

Finally, To Javascript

All right, we’ve run the gauntlet. We have GeoKit installed, we’ve stuffed some data into our cookies, and we’ve tested the whole ordeal. All that without disturbing our super-fast action-cached page just past this. But our visitors still can’t see a thing.

Well, Javascript has access to cookies, so let’s use that to push the info into a form field. It’ll be cool, visitors will all get the same rendered page, but the headers will send different cookies. The Javascript, which will itself be cached, can alter the rendered and drawn page so each visitor views something custom.

Accessing cookies is a bit of a pain, but there are a few options, including a prototype module. We’re going to stick with a few lines lifted from quirksmode.org and their write up on cookies. I’ll ignore the particulars, here’s the function we’ll use:

function readCookie(name) {
  var nameEQ = name + "=";
  var ca = document.cookie.split(';');
  for(var i=0;i < ca.length;i++) {
    var c = ca[i];
    while (c.charAt(0)==' ') c = c.substring(1,c.length);
      if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length,c.length);
    }
  return null;
}

Toss that into your public/stylesheets/application.js and make sure you’ve included it in your application’s layout. Now we’ll use a few functions from prototype and readCookie to load our data into some fields:

//< ! [ C D A T A [
// Sorry, the above spaces are needed for a bug in html-scanner locally.
Event.observe( window, 'load', function() {
  var zipcode_el = $('search[zipcode]');
  if ( zipcode_el && zipcode_el.value == '' ) {
    var zipcode = readCookie('zipcode');
    if (zipcode) { zipcode_el.value = zipcode }
  }

  var city_el = $('search[city]');
  if ( city_el && city_el.value == '' ) {
    var city = readCookie('city');
    if (city) { city_el.value = city }
  }

  var state_el = $('search[state]');
  if ( state_el && state_el.options[state_el.selectedIndex].value == '' ) {
    var state = readCookie('state');
    if (state) {
      for (var i=0;i < state_el.length;i++){
        if ( state_el.options[i].value == state ) {
          state_el.selectedIndex = i;
        }
      }
    }
  }
});
//]]>

We can even toss that in our form’s partial, and as long as everything else attached to onload uses:

Event.observe( window, 'load', function() { } );

Then the onloads wont over-write each other.

  var zipcode_el = $('search[zipcode]');
  if ( zipcode_el && zipcode_el.value == '' ) {
    var zipcode = readCookie('zipcode');
    if (zipcode) { zipcode_el.value = zipcode }
  }

This block first finds a field with the id of search[zipcode], aborts if it already has a value in it (say, from your server’s render), then writes the cookie contents into the element if the cookie exists.

  var state_el = $('search[state]');
  if ( state_el && state_el.options[state_el.selectedIndex].value == '' ) {
    var state = readCookie('state');
    if (state) {
      for (var i=0;i < state_el.length;i++){
        if ( state_el.options[i].value == state ) {
          state_el.selectedIndex = i;
        }
      }
    }
  }

This block handles a select box in a similar manner, setting the state only if it hasn’t already been set.

Whew

Well, that’s a wrap. We’ve cached, we’ve cried, we’ve read cookies in Javascript and tested IPs our browsers didn’t even know existed. Best yet, you can customizes pages by location now without fragging your caches, so scale with joy!

This is my first write up on rails at this blog, thanks for stopping by. My background is a pretty varied one, hence the name madhatted, but they’ll be a big focus on Rails and Javascript here, so stick around. I’ll be adding links and such over the next few weeks, pardon my mess!