gcoder 0.8.0

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,3 @@
1
+ coverage
2
+ rdoc
3
+ pkg
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source :rubygems
2
+
3
+ gem 'hashie'
data/LICENSE ADDED
@@ -0,0 +1,18 @@
1
+ Copyright (c) 2010 Carsten Nielsen
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
4
+ this software and associated documentation files (the "Software"), to deal in
5
+ the Software without restriction, including without limitation the rights to
6
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
7
+ of the Software, and to permit persons to whom the Software is furnished to do
8
+ so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
15
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
16
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
17
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
18
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,144 @@
1
+ # GCoder
2
+
3
+ GCoder geocodes stuff using the Google Geocoding API (V3) and caches the
4
+ results somewhere, if you want. _(Need something more bulldozer-like? Check out
5
+ [Geokit](http://github.com/andre/geokit-gem).)_
6
+
7
+ # Bon Usage
8
+
9
+ require 'gcoder'
10
+
11
+ G = GCoder.connect(:storage => :heap)
12
+
13
+ G['dundas and sorauren', :region => :ca] # ... it geocodes!
14
+ G[[41.87, -74.16]] # ... and reverse-geocodes!
15
+
16
+ The returned value is the 'results' portion of the Google Geocoding API
17
+ [response](http://code.google.com/apis/maps/documentation/geocoding/#JSON).
18
+
19
+ ## Configuration Options
20
+
21
+ These can be applied globally by setting `GCoder.config` or on a per-connection
22
+ basis by passing them to `GCoder.connect`.
23
+
24
+ ### `:append`
25
+
26
+ Specifies a string to append to the end of all queries.
27
+
28
+ ### `:region`
29
+
30
+ Tells the Geocoder to favour results in a specific region. More info
31
+ [here](http://code.google.com/apis/maps/documentation/geocoding/#RegionCodes).
32
+
33
+ ### `:language`
34
+
35
+ By default this is whatever Google thinks it is, you can set it to something
36
+ if you'd like. More info
37
+ [here](http://code.google.com/apis/maps/documentation/geocoding/#GeocodingRequests).
38
+
39
+ ### `:bounds`
40
+
41
+ Specifies a bounding box in which to favour results from. Described as an array
42
+ of two latitude / longitude pairs. The first describes the Northeast corner of
43
+ the box and the second describes the Southwest corner of the box. Here is an
44
+ example input:
45
+
46
+ [[50.09, -94.88], [41.87, -74.16]]
47
+
48
+ More info [here](http://code.google.com/apis/maps/documentation/geocoding/#Viewports).
49
+
50
+ ### `:storage`
51
+
52
+ Defines the storage adapter to use for caching results from the geocoder to
53
+ limit unnecessary throughput. See "Storage Adapters" below for more information.
54
+
55
+ ### `:storage_config`
56
+
57
+ Passed on to the selected adapter as configuration options. See
58
+ "Storage Adapters" below for more information.
59
+
60
+ ## Storage Adapters
61
+
62
+ GCoder comes with two adapters: `:heap`, and `:redis`. You can check out
63
+ `lib/gcoder/storage.rb` for examples of how these are implemented. You can
64
+ select either of these, or pass `nil` (or `false`) as the `:storage` option to
65
+ disable caching of results.
66
+
67
+ * `:storage => nil` - Disable caching (default.)
68
+ * `:storage => :heap` - Saves cached values in an in-memory Hash.
69
+ * `:storage => :redis` - Saves cached values within Redis.
70
+
71
+ ### Adapter Configuration
72
+
73
+ Some adapters have configuration settings that can be passed. The contents of
74
+ `:storage_config` are passed to the adapter when it is instantiated.
75
+
76
+ **HeapAdapter (:heap)**
77
+
78
+ The Heap adapter has no options.
79
+
80
+ **RedisAdapter (:redis)**
81
+
82
+ The Redis adapter has the following options, none are required.
83
+
84
+ * `:connection` - Passed to `Redis.connect`.
85
+ * `:keyspace` - Prefixed to all keys generated by GCoder.
86
+ * `:key_ttl` - A time-to-live in seconds before cached results expire.
87
+
88
+ ### Roll Your Own Adapter
89
+
90
+ Making your own adapter is pretty easy. Your adapter needs to respond to four
91
+ instance methods: `connect`, `set`, `get`, and `clear`. Let's make an adapter
92
+ for Sequel.
93
+
94
+ class SequelAdapter < GCoder::Storage::Adapter
95
+ def connect
96
+ @db = Sequel.connect(config[:connection])
97
+ @table_name = (config[:table_name] || :gcoder_results)
98
+ end
99
+
100
+ # The methods `nkey` and `nval` are used to "normalize" keys and values,
101
+ # respectively. You are encouraged to use them.
102
+ def set(key, value)
103
+ if get(key)
104
+ table.filter(:id => nkey(key)).update(:value => nval(value))
105
+ else
106
+ table.insert(:id => nkey(key), :value => nval(value))
107
+ end
108
+ end
109
+
110
+ def get(key)
111
+ if (row = table.filter(:id => nkey(key)).first)
112
+ row[:value]
113
+ else
114
+ nil
115
+ end
116
+ end
117
+
118
+ def clear
119
+ table.delete_all
120
+ end
121
+
122
+ private
123
+
124
+ def table
125
+ @db[@table_name]
126
+ end
127
+ end
128
+
129
+ GCoder::Storage.register(:sequel, SequelAdapter)
130
+
131
+ Now we can use our adapter as a caching layer by specifying it like this:
132
+
133
+ G = GCoder.connect \
134
+ :storage => :sequel,
135
+ :storage_config => {
136
+ :connection => 'sqlite://geo.db',
137
+ :table_name => :locations
138
+ }
139
+
140
+ ## Notes
141
+
142
+ Tested with Ruby 1.9.2 (MRI) and nothing else, fork it. See
143
+ [LICENSE](http://github.com/heycarsten/gcoder/blob/master/LICENSE) for details
144
+ about that jazz.
data/Rakefile ADDED
@@ -0,0 +1,48 @@
1
+ require 'rubygems/specification' unless defined?(Gem::Specification)
2
+ require 'rake/gempackagetask'
3
+ require 'rake/testtask'
4
+
5
+ def gemspec
6
+ @gemspec ||= begin
7
+ Gem::Specification.load(File.expand_path('gcoder.gemspec'))
8
+ end
9
+ end
10
+
11
+ task :default => :spec
12
+
13
+ desc 'Start an irb console'
14
+ task :console do
15
+ system 'irb -I lib -r gcoder'
16
+ end
17
+
18
+ desc 'Validates the gemspec'
19
+ task :gemspec do
20
+ gemspec.validate
21
+ end
22
+
23
+ desc 'Displays the current version'
24
+ task :version do
25
+ puts "Current version: #{gemspec.version}"
26
+ end
27
+
28
+ desc 'Installs the gem locally'
29
+ task :install => :package do
30
+ sh "gem install pkg/#{gemspec.name}-#{gemspec.version}"
31
+ end
32
+
33
+ desc 'Release the gem'
34
+ task :release => :package do
35
+ sh "gem push pkg/#{gemspec.name}-#{gemspec.version}.gem"
36
+ end
37
+
38
+ Rake::GemPackageTask.new(gemspec) do |pkg|
39
+ pkg.gem_spec = gemspec
40
+ end
41
+ task :gem => :gemspec
42
+ task :package => :gemspec
43
+
44
+ Rake::TestTask.new(:spec) do |t|
45
+ t.libs += %w[gcoder spec]
46
+ t.test_files = FileList['spec/**/*.rb']
47
+ t.verbose = true
48
+ end
data/gcoder.gemspec ADDED
@@ -0,0 +1,22 @@
1
+ # coding: utf-8
2
+ require File.expand_path("../lib/gcoder/version", __FILE__)
3
+
4
+ Gem::Specification.new do |s|
5
+ s.name = 'gcoder'
6
+ s.version = GCoder::VERSION
7
+ s.platform = Gem::Platform::RUBY
8
+ s.authors = ['Carsten Nielsen']
9
+ s.email = ['heycarsten@gmail.com']
10
+ s.homepage = 'http://github.com/heycarsten/gcoder'
11
+ s.summary = %q{A nice library for geocoding stuff with Google Geocoder API}
12
+ s.description = %q{Uses Google Geocoder API to geocode stuff and optionally caches the results somewhere}
13
+
14
+ s.required_rubygems_version = '>= 1.3.6'
15
+ s.rubyforge_project = 'gcoder'
16
+
17
+ s.add_dependency 'hashie'
18
+
19
+ s.files = `git ls-files`.split(?\n)
20
+ s.test_files = `git ls-files -- {test,spec}/*`.split(?\n)
21
+ s.require_paths = ['lib']
22
+ end
@@ -0,0 +1,135 @@
1
+ module GCoder
2
+ module Geocoder
3
+
4
+ HOST = 'maps.googleapis.com'
5
+ PATH = '/maps/api/geocode/json'
6
+
7
+ class Request
8
+ def self.u(string)
9
+ string.to_s.gsub(/([^ a-zA-Z0-9_.-]+)/n) {
10
+ '%' + $1.unpack('H2' * $1.size).join('%').upcase
11
+ }.tr(' ', '+')
12
+ end
13
+
14
+ def self.to_query(params)
15
+ params.map { |key, val| "#{u key}=#{u val}" }.join('&')
16
+ end
17
+
18
+ def self.stubs
19
+ @stubs ||= {}
20
+ end
21
+
22
+ def self.stub(uri, body)
23
+ stubs[uri] = body
24
+ end
25
+
26
+ def initialize(query, opts = {})
27
+ @config = GCoder.config.merge(opts)
28
+ detect_and_set_query(query)
29
+ end
30
+
31
+ def params
32
+ p = { :sensor => 'false' }
33
+ p[:address] = address if @address
34
+ p[:latlng] = latlng if @latlng
35
+ p[:language] = @config[:language] if @config[:language]
36
+ p[:region] = @config[:region] if @config[:region]
37
+ p[:bounds] = bounds if @config[:bounds]
38
+ p
39
+ end
40
+
41
+ def path
42
+ "#{PATH}?#{self.class.to_query(params)}"
43
+ end
44
+
45
+ def uri
46
+ "http://#{HOST}#{path}"
47
+ end
48
+
49
+ def get
50
+ Timeout.timeout(@config[:timeout]) do
51
+ Response.new(uri, http_get)
52
+ end
53
+ rescue Timeout::Error
54
+ raise TimeoutError, "Query timeout after #{@config[:timeout]} second(s)"
55
+ end
56
+
57
+ private
58
+
59
+ def detect_and_set_query(query)
60
+ if query.is_a?(Array)
61
+ case
62
+ when query.size != 2
63
+ raise BadQueryError, "Unable to geocode lat/lng pair that is not " \
64
+ "two elements long: #{query.inspect}"
65
+ when query.any? { |q| '' == q.to_s.strip }
66
+ raise BadQueryError, "Unable to geocode lat/lng pair with blank " \
67
+ "elements: #{query.inspect}"
68
+ else
69
+ @latlng = query
70
+ end
71
+ else
72
+ if '' == query.to_s.strip
73
+ raise BadQueryError, "Unable to geocode a blank query: " \
74
+ "#{query.inspect}"
75
+ else
76
+ @address = query
77
+ end
78
+ end
79
+ end
80
+
81
+ def http_get
82
+ self.class.stubs[uri] || Net::HTTP.get(HOST, path)
83
+ end
84
+
85
+ def latlng
86
+ @latlng.join(',')
87
+ end
88
+
89
+ def bounds
90
+ @config[:bounds].map { |point| point.join(',') }.join('|')
91
+ end
92
+
93
+ def address
94
+ @config[:append] ? "#{@address} #{@config[:append]}" : @address
95
+ end
96
+ end
97
+
98
+
99
+ class Response
100
+ attr_reader :body, :uri
101
+
102
+ def initialize(uri, body)
103
+ @uri = uri
104
+ @body = body
105
+ @response = Hashie::Mash.new(JSON.parse(@body))
106
+ validate_status!
107
+ end
108
+
109
+ def as_mash
110
+ @response
111
+ end
112
+
113
+ private
114
+
115
+ def validate_status!
116
+ case @response.status
117
+ when 'OK'
118
+ # All is well!
119
+ when 'ZERO_RESULTS'
120
+ raise NoResultsError, "Geocoding API returned no results: (#{@uri})"
121
+ when 'OVER_QUERY_LIMIT'
122
+ raise OverLimitError, 'Rate limit for Geocoding API exceeded!'
123
+ when 'REQUEST_DENIED'
124
+ raise GeocoderError, "Request denied by the Geocoding API: (#{@uri})"
125
+ when 'INVALID_REQUEST'
126
+ raise GeocoderError, "An invalid request was made: (#{@uri})"
127
+ else
128
+ raise GeocoderError, 'No status in Geocoding API response: ' \
129
+ "(#{@uri})\n\n#{@body}"
130
+ end
131
+ end
132
+ end
133
+
134
+ end
135
+ end
@@ -0,0 +1,42 @@
1
+ module GCoder
2
+ class Resolver
3
+
4
+ def initialize(opts = {})
5
+ @config = GCoder.config.merge(opts)
6
+ if (adapter_name = @config[:storage])
7
+ @conn = Storage[adapter_name].new(@config[:storage_config])
8
+ else
9
+ @conn = nil
10
+ end
11
+ end
12
+
13
+ def [](*args)
14
+ geocode *args
15
+ end
16
+
17
+ def geocode(query, opts = {})
18
+ fetch([query, opts].join) do
19
+ Geocoder::Request.new(query, opts).get.as_mash
20
+ end.results
21
+ end
22
+
23
+ def fetch(key)
24
+ raise ArgumentError, 'block required' unless block_given?
25
+ Hashie::Mash.new((val = get(key)) ? JSON.parse(val) : set(key, yield))
26
+ end
27
+
28
+ private
29
+
30
+ def get(query)
31
+ return nil unless @conn
32
+ @conn.get(query)
33
+ end
34
+
35
+ def set(key, value)
36
+ return value unless @conn
37
+ @conn.set(key, value.to_json)
38
+ value
39
+ end
40
+
41
+ end
42
+ end
@@ -0,0 +1,107 @@
1
+ module GCoder
2
+ module Storage
3
+
4
+ def self.adapters
5
+ @adapters ||= {}
6
+ end
7
+
8
+ def self.[](name)
9
+ adapters[name.to_sym]
10
+ end
11
+
12
+ def self.register(name, mod)
13
+ adapters[name.to_sym] = mod
14
+ end
15
+
16
+ class Adapter
17
+ def initialize(opts = {})
18
+ @config = (opts || {})
19
+ connect
20
+ end
21
+
22
+ def config
23
+ @config
24
+ end
25
+
26
+ def connect
27
+ raise NotImplementedError, 'This adapter needs to implement #connect'
28
+ end
29
+
30
+ def clear
31
+ raise NotImplementedError, 'This adapter needs to implement #clear'
32
+ end
33
+
34
+ def get(key)
35
+ raise NotImplementedError, 'This adapter needs to implement #get'
36
+ end
37
+
38
+ def set(key, val)
39
+ raise NotImplementedError, 'This adapter needs to implement #set'
40
+ end
41
+
42
+ protected
43
+
44
+ def nval(value)
45
+ value.to_s
46
+ end
47
+
48
+ def nkey(key)
49
+ Digest::SHA1.hexdigest(key.to_s.downcase)
50
+ end
51
+ end
52
+
53
+
54
+ class HeapAdapter < Adapter
55
+ def connect
56
+ @heap = {}
57
+ end
58
+
59
+ def clear
60
+ @heap = {}
61
+ end
62
+
63
+ def get(key)
64
+ @heap[nkey(key)]
65
+ end
66
+
67
+ def set(key, value)
68
+ @heap[nkey(key)] = nval(value)
69
+ end
70
+ end
71
+
72
+
73
+ class RedisAdapter < Adapter
74
+ def connect
75
+ require 'redis'
76
+ @rdb = Redis.connect(*[config[:connection]].compact)
77
+ @keyspace = "#{config[:keyspace] || 'gcoder'}:"
78
+ end
79
+
80
+ def clear
81
+ @rdb.keys(@keyspace + '*').each { |key| @rdb.del(key) }
82
+ end
83
+
84
+ def get(key)
85
+ @rdb.get(keyns(key))
86
+ end
87
+
88
+ def set(key, value)
89
+ if (ttl = config[:key_ttl])
90
+ @rdb.setex(keyns(key), ttl, nval(value))
91
+ else
92
+ @rdb.set(keyns(key), nval(value))
93
+ end
94
+ end
95
+
96
+ private
97
+
98
+ def keyns(key)
99
+ "#{@keyspace}#{nkey(key)}"
100
+ end
101
+ end
102
+
103
+ register :heap, HeapAdapter
104
+ register :redis, RedisAdapter
105
+
106
+ end
107
+ end
@@ -0,0 +1,3 @@
1
+ module GCoder
2
+ VERSION = '0.8.0'
3
+ end
data/lib/gcoder.rb ADDED
@@ -0,0 +1,38 @@
1
+ require 'json'
2
+ require 'hashie'
3
+ require 'net/http'
4
+ require 'timeout'
5
+ require 'digest/sha1'
6
+
7
+ $:.unshift(File.dirname(__FILE__))
8
+
9
+ require 'gcoder/version'
10
+ require 'gcoder/geocoder'
11
+ require 'gcoder/storage'
12
+ require 'gcoder/resolver'
13
+
14
+ module GCoder
15
+ class NoResultsError < StandardError; end
16
+ class OverLimitError < StandardError; end
17
+ class GeocoderError < StandardError; end
18
+ class BadQueryError < StandardError; end
19
+ class NotImplementedError < StandardError; end
20
+ class TimeoutError < StandardError; end
21
+
22
+ DEFAULT_CONFIG = {
23
+ :timeout => 5,
24
+ :append => nil,
25
+ :region => nil,
26
+ :bounds => nil,
27
+ :storage => nil,
28
+ :storage_config => nil
29
+ }.freeze
30
+
31
+ def self.config
32
+ @config ||= DEFAULT_CONFIG.dup
33
+ end
34
+
35
+ def self.connect(options = {})
36
+ Resolver.new(options)
37
+ end
38
+ end
@@ -0,0 +1,53 @@
1
+ require 'spec_helper'
2
+
3
+ describe GCoder::Geocoder::Request do
4
+ it 'should raise an error when passed nil' do
5
+ -> {
6
+ GCoder::Geocoder::Request.new(nil)
7
+ }.must_raise GCoder::BadQueryError
8
+ end
9
+
10
+ it 'should raise an error when passed a blank string' do
11
+ -> {
12
+ GCoder::Geocoder::Request.new(' ')
13
+ }.must_raise GCoder::BadQueryError
14
+ end
15
+
16
+ it 'should raise an error when passed incorrect lat/lng pair' do
17
+ GCoder::Geocoder::Request.tap do |req|
18
+ -> { req.new([]) }.must_raise GCoder::BadQueryError
19
+ -> { req.new([43.64]) }.must_raise GCoder::BadQueryError
20
+ -> { req.new([43.64, nil]) }.must_raise GCoder::BadQueryError
21
+ -> { req.new(['', 43.64]) }.must_raise GCoder::BadQueryError
22
+ end
23
+ end
24
+
25
+ it 'should URI encode a string' do
26
+ GCoder::Geocoder::Request.u('hello world').must_equal 'hello+world'
27
+ end
28
+
29
+ it 'should create a query string' do
30
+ q = GCoder::Geocoder::Request.to_query(:q => 'hello world', :a => 'test')
31
+ q.must_equal 'q=hello+world&a=test'
32
+ end
33
+
34
+ it '(when passed a bounds option) should generate correct query params' do
35
+ GCoder::Geocoder::Request.new('q', :bounds => [[1,2], [3,4]]).tap do |req|
36
+ req.params[:bounds].must_equal '1,2|3,4'
37
+ end
38
+ end
39
+
40
+ it '(when passed a lat/lng pair) should generate correct query params' do
41
+ GCoder::Geocoder::Request.new([43.64, -79.39]).tap do |req|
42
+ req.params[:latlng].must_equal '43.64,-79.39'
43
+ req.params[:address].must_be_nil
44
+ end
45
+ end
46
+
47
+ it '(when passed a geocodable string) should generate correct query params' do
48
+ GCoder::Geocoder::Request.new('queen and spadina').tap do |req|
49
+ req.params[:latlng].must_be_nil
50
+ req.params[:address].must_equal 'queen and spadina'
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,47 @@
1
+ require 'spec_helper'
2
+
3
+ describe 'GCoder::Resolver (with caching)' do
4
+ before do
5
+ @g = GCoder.connect(:storage => :heap, :region => :us)
6
+ end
7
+
8
+ it 'should resolve geocodable queries' do
9
+ r = @g.geocode('queen and spadina', :region => :ca)
10
+ r.must_be_instance_of Array
11
+ end
12
+
13
+ it 'should resolve cached queries' do
14
+ r1 = @g.geocode('queen and spadina', :region => :ca)
15
+ r2 = @g.geocode('queen and spadina', :region => :ca)
16
+ [r1, r2].each { |r| r.must_be_instance_of Array }
17
+ end
18
+
19
+ it 'should resolve reverse-geocodeable queries' do
20
+ r = @g.geocode([43.6487606, -79.3962415], :region => nil)
21
+ r.must_be_instance_of Array
22
+ end
23
+
24
+ it 'should raise an error for queries with no results' do
25
+ -> { @g['noresults', :region => nil] }.must_raise GCoder::NoResultsError
26
+ end
27
+
28
+ it 'should raise an error for denied queries' do
29
+ -> { @g['denied', :region => nil] }.must_raise GCoder::GeocoderError
30
+ end
31
+
32
+ it 'should raise an error when the query limit is exceeded' do
33
+ -> { @g['overlimit', :region => nil] }.must_raise GCoder::OverLimitError
34
+ end
35
+
36
+ it 'should raise an error when the request is invalid' do
37
+ -> { @g['denied', :region => nil] }.must_raise GCoder::GeocoderError
38
+ end
39
+ end
40
+
41
+ describe 'GCoder::Resolver (without caching)' do
42
+ it 'should resolve queries' do
43
+ g = GCoder.connect(:storage => nil)
44
+ r = g['queen and spadina', :region => :ca]
45
+ r.must_be_instance_of Array
46
+ end
47
+ end