heycarsten-postalcoder 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/README.rdoc +7 -0
- data/Rakefile +58 -0
- data/VERSION.yml +4 -0
- data/lib/postalcoder/config.rb +25 -0
- data/lib/postalcoder/formats.rb +120 -0
- data/lib/postalcoder/geocoding_api.rb +121 -0
- data/lib/postalcoder/persistence.rb +62 -0
- data/lib/postalcoder/resolver.rb +16 -0
- data/lib/postalcoder.rb +43 -0
- data/test/config_test.rb +37 -0
- data/test/formats_test.rb +163 -0
- data/test/geocoding_api_test.rb +73 -0
- data/test/persistence_test.rb +78 -0
- data/test/postalcoder_test.rb +26 -0
- data/test/resolver_test.rb +42 -0
- data/test/test_helper.rb +58 -0
- metadata +74 -0
data/README.rdoc
ADDED
data/Rakefile
ADDED
@@ -0,0 +1,58 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rake'
|
3
|
+
|
4
|
+
|
5
|
+
begin
|
6
|
+
require 'jeweler'
|
7
|
+
Jeweler::Tasks.new do |g|
|
8
|
+
g.name = 'postalcoder'
|
9
|
+
g.summary = 'A library for geocoding postal codes via the Google Maps ' \
|
10
|
+
'Geocoding API with a persisted cache through Tokyo Tyrant'
|
11
|
+
g.email = 'heycarsten@gmail.com'
|
12
|
+
g.homepage = 'http://github.com/heycarsten/postalcoder'
|
13
|
+
g.authors = ['Carsten Nielsen']
|
14
|
+
end
|
15
|
+
rescue LoadError
|
16
|
+
puts 'Jeweler not available. Install it with: sudo gem install ' \
|
17
|
+
'technicalpickles-jeweler -s http://gems.github.com'
|
18
|
+
end
|
19
|
+
|
20
|
+
|
21
|
+
require 'rake/testtask'
|
22
|
+
Rake::TestTask.new(:test) do |test|
|
23
|
+
test.libs << 'lib' << 'test'
|
24
|
+
test.pattern = 'test/**/*_test.rb'
|
25
|
+
test.verbose = true
|
26
|
+
end
|
27
|
+
|
28
|
+
|
29
|
+
begin
|
30
|
+
require 'rcov/rcovtask'
|
31
|
+
Rcov::RcovTask.new do |test|
|
32
|
+
test.libs << 'test'
|
33
|
+
test.pattern = 'test/**/*_test.rb'
|
34
|
+
test.verbose = true
|
35
|
+
end
|
36
|
+
rescue LoadError
|
37
|
+
task :rcov do
|
38
|
+
abort 'RCov is not available. In order to run rcov, you must: sudo gem ' \
|
39
|
+
'install spicycode-rcov'
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
|
44
|
+
task :default => :test
|
45
|
+
|
46
|
+
# require 'rake/rdoctask'
|
47
|
+
# Rake::RDocTask.new do |rdoc|
|
48
|
+
# if File.exist?('VERSION.yml')
|
49
|
+
# config = YAML.load(File.read('VERSION.yml'))
|
50
|
+
# version = [config[:major], config[:minor], config[:patch]].join('.')
|
51
|
+
# else
|
52
|
+
# version = ''
|
53
|
+
# end
|
54
|
+
# rdoc.rdoc_dir = 'rdoc'
|
55
|
+
# rdoc.title = "PostalCoder #{version}"
|
56
|
+
# rdoc.rdoc_files.include('README*')
|
57
|
+
# rdoc.rdoc_files.include('lib/**/*.rb')
|
58
|
+
# end
|
data/VERSION.yml
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
module PostalCoder
|
2
|
+
module Config
|
3
|
+
|
4
|
+
# If :tt_port is 0 then :tt_host should point to a Unix socket.
|
5
|
+
@default_settings = {
|
6
|
+
:gmaps_api_key => nil,
|
7
|
+
:gmaps_api_timeout => 2,
|
8
|
+
:tt_host => nil,
|
9
|
+
:tt_port => 0,
|
10
|
+
:accepted_formats => [:ca_postal_code, :us_zip_code] }
|
11
|
+
|
12
|
+
def self.merge(overrides)
|
13
|
+
@default_settings.merge(overrides)
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.update(hsh)
|
17
|
+
@default_settings.update(hsh)
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.[](key)
|
21
|
+
@default_settings[key]
|
22
|
+
end
|
23
|
+
|
24
|
+
end
|
25
|
+
end
|
@@ -0,0 +1,120 @@
|
|
1
|
+
module PostalCoder
|
2
|
+
module Formats
|
3
|
+
|
4
|
+
SYMBOLS_TO_NAMES_MAP = {
|
5
|
+
:ca_postal_code => 'CAPostalCode',
|
6
|
+
:us_zip_code => 'USZipCode' }
|
7
|
+
|
8
|
+
def self.symbol_to_class(symbol)
|
9
|
+
unless symbol.is_a?(Symbol)
|
10
|
+
raise ArgumentError, "expected Symbol, not: #{symbol.class}"
|
11
|
+
end
|
12
|
+
class_name = SYMBOLS_TO_NAMES_MAP.fetch(symbol) do |sym|
|
13
|
+
raise Errors::UnknownFormatSymbolError, 'The format symbol ' \
|
14
|
+
"#{sym.inspect} is not one of the reconized symbols, which are: " \
|
15
|
+
"#{SYMBOLS_TO_NAMES_MAP.keys.inspect}"
|
16
|
+
end
|
17
|
+
eval class_name
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.symbols_to_classes(symbols)
|
21
|
+
unless symbols.is_a?(Array)
|
22
|
+
raise ArgumentError, "symbols must be Array, not: #{symbols.class}"
|
23
|
+
end
|
24
|
+
if symbols.empty?
|
25
|
+
raise ArgumentError, 'symbols must contain format symbols, it is empty.'
|
26
|
+
end
|
27
|
+
symbols.map { |f| symbol_to_class(f) }
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.instantiate(postal_code, format_symbol = nil)
|
31
|
+
unless postal_code.is_a?(String)
|
32
|
+
raise Errors::MalformedPostalCodeError, 'postal_code must be a ' \
|
33
|
+
"String, not: #{postal_code.class}"
|
34
|
+
end
|
35
|
+
if format_symbol
|
36
|
+
symbol_to_class(format_symbol).new(postal_code)
|
37
|
+
else
|
38
|
+
auto_instantiate(postal_code)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def self.auto_instantiate(postal_code, accepted_formats = Config[:accepted_formats])
|
43
|
+
results = symbols_to_classes(accepted_formats).map do |format|
|
44
|
+
begin
|
45
|
+
format.new(postal_code)
|
46
|
+
rescue Errors::MalformedPostalCodeError
|
47
|
+
nil
|
48
|
+
end
|
49
|
+
end.compact
|
50
|
+
return results[0] if results.any?
|
51
|
+
raise Errors::MalformedPostalCodeError, "The postal code: #{postal_code}" \
|
52
|
+
' did not properly map to any of the accepted formats.'
|
53
|
+
end
|
54
|
+
|
55
|
+
|
56
|
+
class AbstractFormat
|
57
|
+
|
58
|
+
attr_reader :value
|
59
|
+
|
60
|
+
def initialize(raw_value)
|
61
|
+
unless raw_value.is_a?(String)
|
62
|
+
raise ArgumentError, "value must be String, not: #{raw_value.class}"
|
63
|
+
end
|
64
|
+
@value = cleanup(raw_value)
|
65
|
+
validate_form!
|
66
|
+
end
|
67
|
+
|
68
|
+
def to_s
|
69
|
+
value
|
70
|
+
end
|
71
|
+
|
72
|
+
def cleanup(raw_value)
|
73
|
+
raw_value.upcase.gsub(/\s|\-/, '')
|
74
|
+
end
|
75
|
+
|
76
|
+
def has_valid_form?
|
77
|
+
raise NotImplementedError, "#{self.class}#has_valid_form? must be " \
|
78
|
+
'implemented.'
|
79
|
+
end
|
80
|
+
|
81
|
+
protected
|
82
|
+
|
83
|
+
def validate_form!
|
84
|
+
return true if has_valid_form?
|
85
|
+
raise Errors::MalformedPostalCodeError, "#{@value.inspect} is not a " \
|
86
|
+
"properly formed #{self.class}"
|
87
|
+
end
|
88
|
+
|
89
|
+
end
|
90
|
+
|
91
|
+
|
92
|
+
class CAPostalCode < AbstractFormat
|
93
|
+
|
94
|
+
def has_valid_form?
|
95
|
+
value =~ /\A([A-Z][0-9]){3}\Z/
|
96
|
+
end
|
97
|
+
|
98
|
+
end
|
99
|
+
|
100
|
+
|
101
|
+
class USZipCode < AbstractFormat
|
102
|
+
|
103
|
+
def to_s
|
104
|
+
case value.length
|
105
|
+
when 5
|
106
|
+
value
|
107
|
+
when 9
|
108
|
+
"#{value[0,5]}-#{value[5,4]}"
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def has_valid_form?
|
113
|
+
value =~ /\A([0-9]{5}|[0-9]{9})\Z/
|
114
|
+
end
|
115
|
+
|
116
|
+
end
|
117
|
+
|
118
|
+
|
119
|
+
end
|
120
|
+
end
|
@@ -0,0 +1,121 @@
|
|
1
|
+
module PostalCoder
|
2
|
+
module GeocodingAPI
|
3
|
+
|
4
|
+
BASE_URI = 'http://maps.google.com/maps/geo'
|
5
|
+
BASE_PARAMS = {
|
6
|
+
:q => nil,
|
7
|
+
:output => 'json',
|
8
|
+
:oe => 'utf8',
|
9
|
+
:sensor => 'false',
|
10
|
+
:key => nil }
|
11
|
+
|
12
|
+
|
13
|
+
class Query
|
14
|
+
|
15
|
+
attr_reader :query
|
16
|
+
|
17
|
+
def self.get(query, options = {})
|
18
|
+
new(query, options).to_hash
|
19
|
+
end
|
20
|
+
|
21
|
+
def initialize(query, options = {})
|
22
|
+
unless query.is_a?(String)
|
23
|
+
raise ArgumentError, "query must be a String, not: #{query.class}"
|
24
|
+
end
|
25
|
+
@config = Config.merge(options)
|
26
|
+
@query = query
|
27
|
+
validate_state!
|
28
|
+
end
|
29
|
+
|
30
|
+
def to_hash
|
31
|
+
return tidy_response if @response
|
32
|
+
parse_json_response(http_get)
|
33
|
+
end
|
34
|
+
|
35
|
+
def params
|
36
|
+
BASE_PARAMS.merge(:key => @config[:gmaps_api_key], :q => query)
|
37
|
+
end
|
38
|
+
|
39
|
+
def to_params
|
40
|
+
# No need to escape the keys and values because (so far) they do not
|
41
|
+
# contain escapable characters. -- CKN
|
42
|
+
params.inject([]) { |a, (k, v)| a << "#{k}=#{v}" }.join('&')
|
43
|
+
end
|
44
|
+
|
45
|
+
def uri
|
46
|
+
[BASE_URI, '?', to_params].join
|
47
|
+
end
|
48
|
+
|
49
|
+
protected
|
50
|
+
|
51
|
+
def placemark
|
52
|
+
@response['Placemark'][0]
|
53
|
+
end
|
54
|
+
|
55
|
+
def latlon_box
|
56
|
+
placemark['ExtendedData']['LatLonBox']
|
57
|
+
end
|
58
|
+
|
59
|
+
def country
|
60
|
+
placemark['AddressDetails']['Country']
|
61
|
+
end
|
62
|
+
|
63
|
+
def tidy_response
|
64
|
+
{ :accuracy => placemark['AddressDetails']['Accuracy'],
|
65
|
+
:country => {
|
66
|
+
:name => country['CountryName'],
|
67
|
+
:code => country['CountryNameCode'],
|
68
|
+
:administrative_area => country['AdministrativeArea']['AdministrativeAreaName']
|
69
|
+
},
|
70
|
+
:point => {
|
71
|
+
:latitude => placemark['Point']['coordinates'][0],
|
72
|
+
:longitude => placemark['Point']['coordinates'][1]
|
73
|
+
},
|
74
|
+
:box => {
|
75
|
+
:north => latlon_box['north'],
|
76
|
+
:south => latlon_box['south'],
|
77
|
+
:east => latlon_box['east'],
|
78
|
+
:west => latlon_box['west']
|
79
|
+
} }
|
80
|
+
end
|
81
|
+
|
82
|
+
def http_get
|
83
|
+
return @json_response if @json_response
|
84
|
+
Timeout.timeout(@config[:gmaps_api_timeout]) do
|
85
|
+
@json_response = open(uri).read
|
86
|
+
end
|
87
|
+
rescue Timeout::TimeoutError
|
88
|
+
raise Errors::QueryTimeoutError, 'The query timed out at ' \
|
89
|
+
"#{@config[:timeout]} second(s)"
|
90
|
+
end
|
91
|
+
|
92
|
+
def parse_json_response(json_response)
|
93
|
+
@response = JSON.parse(json_response)
|
94
|
+
case @response['Status']['code']
|
95
|
+
when 200
|
96
|
+
tidy_response
|
97
|
+
when 400
|
98
|
+
raise Errors::APIMalformedRequestError, 'The GMaps Geo API has ' \
|
99
|
+
'indicated that the request is not formed correctly: ' \
|
100
|
+
"(#{query.inspect})"
|
101
|
+
when 602
|
102
|
+
raise Errors::APIGeocodingError, 'The GMaps Geo API has indicated ' \
|
103
|
+
"that it is not able to geocode the request: (#{query.inspect})"
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def validate_state!
|
108
|
+
if '' == query.strip.to_s
|
109
|
+
raise Errors::BlankQueryError, 'You must specifiy a query to resolve.'
|
110
|
+
end
|
111
|
+
unless @config[:gmaps_api_key]
|
112
|
+
raise Errors::NoAPIKeyError, 'You must provide a Google Maps API ' \
|
113
|
+
'key in your configuration. Go to http://code.google.com/apis/maps/' \
|
114
|
+
'signup.html to get one.'
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
end
|
119
|
+
|
120
|
+
end
|
121
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
module PostalCoder
|
2
|
+
module Persistence
|
3
|
+
|
4
|
+
|
5
|
+
class DataStore
|
6
|
+
|
7
|
+
def initialize(options = {})
|
8
|
+
@config = Config.merge(options)
|
9
|
+
unless @config[:tt_host]
|
10
|
+
raise ArgumentError, ':tt_host must be specified when it is not ' \
|
11
|
+
'present in the global configuration.'
|
12
|
+
end
|
13
|
+
@tyrant = Rufus::Tokyo::Tyrant.new(@config[:tt_host], @config[:tt_port])
|
14
|
+
rescue RuntimeError => boom
|
15
|
+
if boom.message.include?('couldn\'t connect to tyrant')
|
16
|
+
raise Errors::TTUnableToConnectError, 'Unable to connect to the ' \
|
17
|
+
"Tokyo Tyrant server at #{@config[:tt_host]} [#{@config[:tt_port]}]"
|
18
|
+
else
|
19
|
+
raise boom
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def fetch(key, &block)
|
24
|
+
unless block_given?
|
25
|
+
raise ArgumentError, 'no block was given but one was expected'
|
26
|
+
end
|
27
|
+
value = storage_get(key)
|
28
|
+
return value if value
|
29
|
+
storage_put(key, block.call(key.to_s))
|
30
|
+
end
|
31
|
+
|
32
|
+
def [](key)
|
33
|
+
storage_get(key)
|
34
|
+
end
|
35
|
+
|
36
|
+
def []=(key, value)
|
37
|
+
unless key.is_a?(String) || key.is_a?(Symbol)
|
38
|
+
raise ArgumentError, "key must be String or Symbol, not: #{key.class}"
|
39
|
+
end
|
40
|
+
storage_put(key, value)
|
41
|
+
end
|
42
|
+
|
43
|
+
protected
|
44
|
+
|
45
|
+
def storage_get(key)
|
46
|
+
value = tyrant[key.to_s]
|
47
|
+
value ? YAML.load(value) : nil
|
48
|
+
end
|
49
|
+
|
50
|
+
def storage_put(key, value)
|
51
|
+
tyrant[key.to_s] = YAML.dump(value)
|
52
|
+
value # <- We don't want to return YAML in this case.
|
53
|
+
end
|
54
|
+
|
55
|
+
def tyrant
|
56
|
+
@tyrant
|
57
|
+
end
|
58
|
+
|
59
|
+
end
|
60
|
+
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
module PostalCoder
|
2
|
+
class Resolver < Persistence::DataStore
|
3
|
+
|
4
|
+
def resolve(postal_code_value, format_symbol = nil)
|
5
|
+
postal_code = Formats.instantiate(postal_code_value, format_symbol)
|
6
|
+
fetch(postal_code.to_s) do |code|
|
7
|
+
GeocodingAPI::Query.get(code, @config)
|
8
|
+
end
|
9
|
+
end
|
10
|
+
|
11
|
+
def [](postal_code)
|
12
|
+
resolve(postal_code)
|
13
|
+
end
|
14
|
+
|
15
|
+
end
|
16
|
+
end
|
data/lib/postalcoder.rb
ADDED
@@ -0,0 +1,43 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'rufus/tokyo/tyrant'
|
3
|
+
require 'json'
|
4
|
+
require 'yaml'
|
5
|
+
require 'open-uri'
|
6
|
+
require 'timeout'
|
7
|
+
|
8
|
+
$:.unshift(File.dirname(__FILE__))
|
9
|
+
|
10
|
+
require 'postalcoder/config'
|
11
|
+
require 'postalcoder/formats'
|
12
|
+
require 'postalcoder/geocoding_api'
|
13
|
+
require 'postalcoder/persistence'
|
14
|
+
require 'postalcoder/resolver'
|
15
|
+
|
16
|
+
|
17
|
+
module PostalCoder
|
18
|
+
|
19
|
+
module Errors
|
20
|
+
class Error < StandardError; end
|
21
|
+
class MalformedPostalCodeError < Error; end
|
22
|
+
class BlankQueryError < Error; end
|
23
|
+
class QueryTimeoutError < Error; end
|
24
|
+
class NoAPIKeyError < Error; end
|
25
|
+
class APIMalformedRequestError < Error; end
|
26
|
+
class APIGeocodingError < Error; end
|
27
|
+
class TTUnableToConnectError < Error; end
|
28
|
+
class InvalidStorageValueError < Error; end
|
29
|
+
class UnknownFormatSymbolError < Error; end
|
30
|
+
end
|
31
|
+
|
32
|
+
|
33
|
+
module ProxyMethods
|
34
|
+
def PostalCoder.config=(hsh)
|
35
|
+
Config.update(hsh)
|
36
|
+
end
|
37
|
+
|
38
|
+
def PostalCoder.connect(options = {})
|
39
|
+
Resolver.new(options)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
end
|
data/test/config_test.rb
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class ConfigTest < Test::Unit::TestCase
|
4
|
+
|
5
|
+
context 'Config#merge' do
|
6
|
+
setup do
|
7
|
+
@config = PostalCoder::Config.merge(:gmaps_api_timeout => 3)
|
8
|
+
end
|
9
|
+
|
10
|
+
should 'return a hash of updated config settings' do
|
11
|
+
assert_instance_of Hash, @config
|
12
|
+
assert_equal 5, @config.size
|
13
|
+
assert_equal 3, @config[:gmaps_api_timeout]
|
14
|
+
end
|
15
|
+
|
16
|
+
should 'not change default configuration' do
|
17
|
+
assert_equal 2, PostalCoder::Config[:gmaps_api_timeout]
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
context 'Config#update' do
|
22
|
+
setup do
|
23
|
+
@config = PostalCoder::Config.update(:gmaps_api_timeout => 1)
|
24
|
+
end
|
25
|
+
|
26
|
+
should 'return a hash of updated config settings' do
|
27
|
+
assert_instance_of Hash, @config
|
28
|
+
assert_equal 5, @config.size
|
29
|
+
assert_equal 1, @config[:gmaps_api_timeout]
|
30
|
+
end
|
31
|
+
|
32
|
+
should 'change default configuration' do
|
33
|
+
assert_equal 1, PostalCoder::Config[:gmaps_api_timeout]
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
end
|
@@ -0,0 +1,163 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class FormatsTest < Test::Unit::TestCase
|
4
|
+
|
5
|
+
context 'Formats.symbol_to_class' do
|
6
|
+
should 'throw an argument error unless given a symbol' do
|
7
|
+
assert_raise ArgumentError do
|
8
|
+
PostalCoder::Formats.symbol_to_class('boom!')
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
should 'throw an error when passed an unknown format symbol' do
|
13
|
+
assert_raise PostalCoder::Errors::UnknownFormatSymbolError do
|
14
|
+
PostalCoder::Formats.symbol_to_class(:uberfail)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
should 'return the appropriate class when passed the proper symbol' do
|
19
|
+
assert_equal PostalCoder::Formats::CAPostalCode,
|
20
|
+
PostalCoder::Formats.symbol_to_class(:ca_postal_code)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
context 'Formats.symbols_to_classes' do
|
25
|
+
should 'throw an argument error unless passed an array' do
|
26
|
+
assert_raise ArgumentError do
|
27
|
+
PostalCoder::Formats.symbols_to_classes(nil)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
should 'throw an argument error if passed an empty array' do
|
32
|
+
assert_raise ArgumentError do
|
33
|
+
PostalCoder::Formats.symbols_to_classes([])
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
should 'return the appropriate classes when passed proper symbols' do
|
38
|
+
assert_equal [PostalCoder::Formats::CAPostalCode],
|
39
|
+
PostalCoder::Formats.symbols_to_classes([:ca_postal_code])
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
context 'Formats.auto_instantiate' do
|
44
|
+
should 'raise a malformed postal code error if no accepted formats match the input' do
|
45
|
+
assert_raise PostalCoder::Errors::MalformedPostalCodeError do
|
46
|
+
PostalCoder::Formats.auto_instantiate('failtron')
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
should 'return the first matching instance' do
|
51
|
+
assert_instance_of PostalCoder::Formats::USZipCode,
|
52
|
+
PostalCoder::Formats.auto_instantiate('20037')
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
context 'Formats.instantiate' do
|
57
|
+
should 'raise an error if passed nil as a postal code' do
|
58
|
+
assert_raise PostalCoder::Errors::MalformedPostalCodeError do
|
59
|
+
PostalCoder::Formats.instantiate(nil)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
should 'auto instantiate to the first matching accepted format if no format is specified' do
|
64
|
+
assert_instance_of PostalCoder::Formats::CAPostalCode,
|
65
|
+
PostalCoder::Formats.instantiate('m6r2g5')
|
66
|
+
end
|
67
|
+
|
68
|
+
should 'return true if passed a postal code that matches at least one of the accepted formats' do
|
69
|
+
assert_instance_of PostalCoder::Formats::CAPostalCode,
|
70
|
+
PostalCoder::Formats.instantiate('m6r2g5', :ca_postal_code)
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
context 'Creating an instance of AbstractFormat' do
|
75
|
+
should 'throw a not implemented error' do
|
76
|
+
assert_raise NotImplementedError do
|
77
|
+
PostalCoder::Formats::AbstractFormat.new('test')
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
context 'Creating an instance of USZipCode' do
|
83
|
+
should 'fail with the wrong type' do
|
84
|
+
assert_raise ArgumentError do
|
85
|
+
PostalCoder::Formats::USZipCode.new(nil)
|
86
|
+
end
|
87
|
+
assert_raise ArgumentError do
|
88
|
+
PostalCoder::Formats::USZipCode.new(20037)
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
should 'fail with a string of the wrong format' do
|
93
|
+
assert_raise PostalCoder::Errors::MalformedPostalCodeError do
|
94
|
+
PostalCoder::Formats::USZipCode.new('2003')
|
95
|
+
end
|
96
|
+
assert_raise PostalCoder::Errors::MalformedPostalCodeError do
|
97
|
+
PostalCoder::Formats::USZipCode.new('20037-8')
|
98
|
+
end
|
99
|
+
assert_raise PostalCoder::Errors::MalformedPostalCodeError do
|
100
|
+
PostalCoder::Formats::USZipCode.new('')
|
101
|
+
end
|
102
|
+
assert_raise PostalCoder::Errors::MalformedPostalCodeError do
|
103
|
+
PostalCoder::Formats::USZipCode.new('BOOM7-8001')
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
should 'work with whitespace present' do
|
108
|
+
assert PostalCoder::Formats::USZipCode.new(' 200 37 ')
|
109
|
+
end
|
110
|
+
|
111
|
+
should 'allow ZIP+4 with or without a dash' do
|
112
|
+
assert PostalCoder::Formats::USZipCode.new('20037-8001')
|
113
|
+
assert PostalCoder::Formats::USZipCode.new('20037 8001')
|
114
|
+
assert PostalCoder::Formats::USZipCode.new('200378001')
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
context 'An instance of USZipCode for ZIP+4 address' do
|
119
|
+
setup do
|
120
|
+
@zip = PostalCoder::Formats::USZipCode.new(' 20037 8001 ')
|
121
|
+
end
|
122
|
+
|
123
|
+
should 'reformat the value to include a dash' do
|
124
|
+
assert_equal '20037-8001', @zip.to_s
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
context 'Creating an instance of CAPostalCode' do
|
129
|
+
should 'fail with the wrong type' do
|
130
|
+
assert_raise ArgumentError do
|
131
|
+
PostalCoder::Formats::CAPostalCode.new(123456)
|
132
|
+
end
|
133
|
+
assert_raise ArgumentError do
|
134
|
+
PostalCoder::Formats::CAPostalCode.new(nil)
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
should 'fail with a string of the wrong format' do
|
139
|
+
assert_raise PostalCoder::Errors::MalformedPostalCodeError do
|
140
|
+
PostalCoder::Formats::CAPostalCode.new('M6R')
|
141
|
+
end
|
142
|
+
assert_raise PostalCoder::Errors::MalformedPostalCodeError do
|
143
|
+
PostalCoder::Formats::CAPostalCode.new('')
|
144
|
+
end
|
145
|
+
assert_raise PostalCoder::Errors::MalformedPostalCodeError do
|
146
|
+
PostalCoder::Formats::CAPostalCode.new('M6R2G5A1')
|
147
|
+
end
|
148
|
+
assert_raise PostalCoder::Errors::MalformedPostalCodeError do
|
149
|
+
PostalCoder::Formats::CAPostalCode.new('M6R205')
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
should 'work with whitespace present' do
|
154
|
+
assert PostalCoder::Formats::CAPostalCode.new(' M6R2G5 ')
|
155
|
+
assert PostalCoder::Formats::CAPostalCode.new(' M6R 2G5 ')
|
156
|
+
end
|
157
|
+
|
158
|
+
should 'upcase any lowercase characters' do
|
159
|
+
assert_equal 'M6R2G5', PostalCoder::Formats::CAPostalCode.new('m6r2g5').to_s
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
end
|
@@ -0,0 +1,73 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
|
4
|
+
class GeocodingAPITest < Test::Unit::TestCase
|
5
|
+
|
6
|
+
context 'initialize with incorrect arguments' do
|
7
|
+
should 'fail with no arguments' do
|
8
|
+
assert_raise ArgumentError do
|
9
|
+
PostalCoder::GeocodingAPI::Query.new
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
should 'fail with any argument other than a string' do
|
14
|
+
assert_raise ArgumentError do
|
15
|
+
PostalCoder::GeocodingAPI::Query.new(nil)
|
16
|
+
end
|
17
|
+
assert_raise ArgumentError do
|
18
|
+
PostalCoder::GeocodingAPI::Query.new(0)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
should 'fail when passed a blank string as an argument' do
|
23
|
+
assert_raise PostalCoder::Errors::BlankQueryError do
|
24
|
+
PostalCoder::GeocodingAPI::Query.new(' ')
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
context 'initialize with no API key present' do
|
30
|
+
should 'fall down, go boom' do
|
31
|
+
assert_raise PostalCoder::Errors::NoAPIKeyError do
|
32
|
+
PostalCoder::GeocodingAPI::Query.new('M6R2G5')
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
context 'query with correct arguments' do
|
38
|
+
setup do
|
39
|
+
@zip = PostalCoder::GeocodingAPI::Query.new('M6R2G5',
|
40
|
+
:gmaps_api_key => 'apikey')
|
41
|
+
end
|
42
|
+
|
43
|
+
should 'return parsed and tidied JSON' do
|
44
|
+
@zip.expects(:http_get).returns(PAYLOADS[:json_m6r2g5])
|
45
|
+
assert_equal 5, @zip.to_hash[:accuracy]
|
46
|
+
assert_equal 'Canada', @zip.to_hash[:country][:name]
|
47
|
+
assert_equal 'CA', @zip.to_hash[:country][:code]
|
48
|
+
assert_equal 'ON', @zip.to_hash[:country][:administrative_area]
|
49
|
+
assert_equal -79.4449720, @zip.to_hash[:point][:latitude]
|
50
|
+
assert_equal 43.6504650, @zip.to_hash[:point][:longitude]
|
51
|
+
assert_equal 43.6536126, @zip.to_hash[:box][:north]
|
52
|
+
assert_equal 43.6473174, @zip.to_hash[:box][:south]
|
53
|
+
assert_equal -79.4418244, @zip.to_hash[:box][:east]
|
54
|
+
assert_equal -79.4481196, @zip.to_hash[:box][:west]
|
55
|
+
end
|
56
|
+
|
57
|
+
should 'raise an error when API returns malformed request' do
|
58
|
+
@zip.expects(:http_get).returns(PAYLOADS[:json_400])
|
59
|
+
assert_raise PostalCoder::Errors::APIMalformedRequestError do
|
60
|
+
@zip.to_hash
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
should 'have an appropriate URI' do
|
65
|
+
assert_match /output=json/, @zip.uri
|
66
|
+
assert_match /q=M6R2G5/, @zip.uri
|
67
|
+
assert_match /oe=u/, @zip.uri
|
68
|
+
assert_match /sensor=false/, @zip.uri
|
69
|
+
assert_match /key=apikey/, @zip.uri
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class PersistenceTest < Test::Unit::TestCase
|
4
|
+
|
5
|
+
context 'DataStore.new' do
|
6
|
+
should 'throw and argument error without :tt_host specified' do
|
7
|
+
assert_raise ArgumentError do
|
8
|
+
PostalCoder::Persistence::DataStore.new
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
should 'throw a better error if the Tokyo Tyrant connection fails' do
|
13
|
+
assert_raise PostalCoder::Errors::TTUnableToConnectError do
|
14
|
+
PostalCoder::Persistence::DataStore.new(:tt_host => '/tmp/fake')
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
context 'DataStore' do
|
20
|
+
setup do
|
21
|
+
Rufus::Tokyo::Tyrant.stubs(:new).returns({})
|
22
|
+
@db = PostalCoder::Persistence::DataStore.new(:tt_host => '/tmp/tttest')
|
23
|
+
end
|
24
|
+
|
25
|
+
context '#fetch' do
|
26
|
+
should 'raise an error without any block specified' do
|
27
|
+
assert_raise ArgumentError do
|
28
|
+
@db.fetch(:stuff)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
should 'get a value that exists' do
|
33
|
+
@db[:test_key] = 'test_value'
|
34
|
+
assert_equal 'test_value', @db.fetch(:test_key) { raise 'ultrafail' }
|
35
|
+
end
|
36
|
+
|
37
|
+
should 'set the value to whatever the block yields if value is nil' do
|
38
|
+
@db.fetch(:test_key) { 'block_value' }
|
39
|
+
assert_equal 'block_value', @db[:test_key]
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
context '#[]=' do
|
44
|
+
should 'now allow numbers or nil as a key' do
|
45
|
+
assert_raise ArgumentError do
|
46
|
+
@db[nil] = 'extreme fail'
|
47
|
+
end
|
48
|
+
assert_raise ArgumentError do
|
49
|
+
@db[23] = 'massive fail'
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
context '#[]' do
|
55
|
+
should 'return nil for a key that does not exist' do
|
56
|
+
assert_nil @db['nope']
|
57
|
+
assert_nil @db['']
|
58
|
+
assert_nil @db[0]
|
59
|
+
assert_nil @db[nil]
|
60
|
+
end
|
61
|
+
|
62
|
+
should 'alow retrieval of nil' do
|
63
|
+
@db[:nil_key] = nil
|
64
|
+
assert_nil @db[:nil_key]
|
65
|
+
end
|
66
|
+
|
67
|
+
should 'return parsed JSON for keys that do exist' do
|
68
|
+
@db[:payload_1] = PAYLOADS[:test_string]
|
69
|
+
@db[:payload_2] = PAYLOADS[:test_hash]
|
70
|
+
@db[:payload_3] = PAYLOADS[:test_array]
|
71
|
+
assert_equal PAYLOADS[:test_string], @db[:payload_1]
|
72
|
+
assert_equal PAYLOADS[:test_hash], @db[:payload_2]
|
73
|
+
assert_equal PAYLOADS[:test_array], @db[:payload_3]
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class PostalCoderTest < Test::Unit::TestCase
|
4
|
+
|
5
|
+
context 'ProxyMethods' do
|
6
|
+
should 'be present in PostalCoder module' do
|
7
|
+
assert_respond_to PostalCoder, :config=
|
8
|
+
assert_respond_to PostalCoder, :connect
|
9
|
+
end
|
10
|
+
|
11
|
+
context 'PostalCoder.config=' do
|
12
|
+
should 'proxy to Config.update' do
|
13
|
+
PostalCoder::Config.expects(:update).with({})
|
14
|
+
assert PostalCoder.config = {}
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
context 'PostalCoder.connect' do
|
19
|
+
should 'proxy to Resolver.new' do
|
20
|
+
PostalCoder::Resolver.expects(:new).with({}).returns(:it_works)
|
21
|
+
assert_equal :it_works, PostalCoder.connect
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
require 'test_helper'
|
2
|
+
|
3
|
+
class ResolverTest < Test::Unit::TestCase
|
4
|
+
|
5
|
+
context 'Resolver' do
|
6
|
+
setup do
|
7
|
+
Rufus::Tokyo::Tyrant.stubs(:new).returns({})
|
8
|
+
@db = PostalCoder::Resolver.new(:tt_host => '/tmp/tttest', :gmaps_api_key => 'testkey')
|
9
|
+
end
|
10
|
+
|
11
|
+
should 'return a hash of information for a new address' do
|
12
|
+
PostalCoder::GeocodingAPI::Query.any_instance.
|
13
|
+
expects(:http_get).returns(PAYLOADS[:json_m6r2g5])
|
14
|
+
assert_instance_of Hash, @db.resolve('m6r2g5')
|
15
|
+
end
|
16
|
+
|
17
|
+
should 'not call api when a cached postal code is called' do
|
18
|
+
assert_instance_of Hash, @db.resolve('m6r2g5')
|
19
|
+
end
|
20
|
+
|
21
|
+
should 'store the postal code key in the correct format' do
|
22
|
+
assert_instance_of Hash, @db.resolve('M6R2G5')
|
23
|
+
end
|
24
|
+
|
25
|
+
should 'allow [] to resolve with auto-instantiation' do
|
26
|
+
assert_instance_of Hash, @db['M6R2G5']
|
27
|
+
end
|
28
|
+
|
29
|
+
should 'raise malformed postal code error for a malformed postal code' do
|
30
|
+
assert_raise PostalCoder::Errors::MalformedPostalCodeError do
|
31
|
+
@db.resolve('m6r212')
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
should 'raise malformed postal code error for a nil postal code' do
|
36
|
+
assert_raise PostalCoder::Errors::MalformedPostalCodeError do
|
37
|
+
@db.resolve(nil)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
end
|
data/test/test_helper.rb
ADDED
@@ -0,0 +1,58 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'test/unit'
|
3
|
+
require 'shoulda'
|
4
|
+
require 'mocha'
|
5
|
+
|
6
|
+
$:.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
|
7
|
+
$:.unshift(File.dirname(__FILE__))
|
8
|
+
require 'postalcoder'
|
9
|
+
|
10
|
+
class Test::Unit::TestCase
|
11
|
+
|
12
|
+
PAYLOADS = {
|
13
|
+
:json_m6r2g5 => %q<
|
14
|
+
{
|
15
|
+
"name": "M6R2G5",
|
16
|
+
"Status": {
|
17
|
+
"code": 200,
|
18
|
+
"request": "geocode"
|
19
|
+
},
|
20
|
+
"Placemark": [ {
|
21
|
+
"id": "p1",
|
22
|
+
"address": "Ontario M6R 2G5, Canada",
|
23
|
+
"AddressDetails": {"Country": {"CountryNameCode": "CA","CountryName": "Canada","AdministrativeArea": {"AdministrativeAreaName": "ON","PostalCode": {"PostalCodeNumber": "M6R 2G5"}}},"Accuracy": 5},
|
24
|
+
"ExtendedData": {
|
25
|
+
"LatLonBox": {
|
26
|
+
"north": 43.6536126,
|
27
|
+
"south": 43.6473174,
|
28
|
+
"east": -79.4418244,
|
29
|
+
"west": -79.4481196
|
30
|
+
}
|
31
|
+
},
|
32
|
+
"Point": {
|
33
|
+
"coordinates": [ -79.4449720, 43.6504650, 0 ]
|
34
|
+
}
|
35
|
+
} ]
|
36
|
+
}>,
|
37
|
+
:json_602 => %q<
|
38
|
+
{
|
39
|
+
"name": "crashbangboom",
|
40
|
+
"Status": {
|
41
|
+
"code": 602,
|
42
|
+
"request": "geocode"
|
43
|
+
}
|
44
|
+
}>,
|
45
|
+
:json_400 => %q<
|
46
|
+
{
|
47
|
+
"name": "",
|
48
|
+
"Status": {
|
49
|
+
"code": 400,
|
50
|
+
"request": "geocode"
|
51
|
+
}
|
52
|
+
}>,
|
53
|
+
:test_string => "test\nstring",
|
54
|
+
:test_hash => { 'test' => 'value', 100 => 'one hundred' },
|
55
|
+
:test_array => ['test', 1, 3.1415, true, false, nil]
|
56
|
+
}
|
57
|
+
|
58
|
+
end
|
metadata
ADDED
@@ -0,0 +1,74 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: heycarsten-postalcoder
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.3
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Carsten Nielsen
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2009-05-20 00:00:00 -07:00
|
13
|
+
default_executable:
|
14
|
+
dependencies: []
|
15
|
+
|
16
|
+
description:
|
17
|
+
email: heycarsten@gmail.com
|
18
|
+
executables: []
|
19
|
+
|
20
|
+
extensions: []
|
21
|
+
|
22
|
+
extra_rdoc_files:
|
23
|
+
- README.rdoc
|
24
|
+
files:
|
25
|
+
- README.rdoc
|
26
|
+
- Rakefile
|
27
|
+
- VERSION.yml
|
28
|
+
- lib/postalcoder.rb
|
29
|
+
- lib/postalcoder/config.rb
|
30
|
+
- lib/postalcoder/formats.rb
|
31
|
+
- lib/postalcoder/geocoding_api.rb
|
32
|
+
- lib/postalcoder/persistence.rb
|
33
|
+
- lib/postalcoder/resolver.rb
|
34
|
+
- test/config_test.rb
|
35
|
+
- test/formats_test.rb
|
36
|
+
- test/geocoding_api_test.rb
|
37
|
+
- test/persistence_test.rb
|
38
|
+
- test/postalcoder_test.rb
|
39
|
+
- test/resolver_test.rb
|
40
|
+
- test/test_helper.rb
|
41
|
+
has_rdoc: true
|
42
|
+
homepage: http://github.com/heycarsten/postalcoder
|
43
|
+
post_install_message:
|
44
|
+
rdoc_options:
|
45
|
+
- --charset=UTF-8
|
46
|
+
require_paths:
|
47
|
+
- lib
|
48
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
49
|
+
requirements:
|
50
|
+
- - ">="
|
51
|
+
- !ruby/object:Gem::Version
|
52
|
+
version: "0"
|
53
|
+
version:
|
54
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
55
|
+
requirements:
|
56
|
+
- - ">="
|
57
|
+
- !ruby/object:Gem::Version
|
58
|
+
version: "0"
|
59
|
+
version:
|
60
|
+
requirements: []
|
61
|
+
|
62
|
+
rubyforge_project:
|
63
|
+
rubygems_version: 1.2.0
|
64
|
+
signing_key:
|
65
|
+
specification_version: 2
|
66
|
+
summary: A library for geocoding postal codes via the Google Maps Geocoding API with a persisted cache through Tokyo Tyrant
|
67
|
+
test_files:
|
68
|
+
- test/config_test.rb
|
69
|
+
- test/formats_test.rb
|
70
|
+
- test/geocoding_api_test.rb
|
71
|
+
- test/persistence_test.rb
|
72
|
+
- test/postalcoder_test.rb
|
73
|
+
- test/resolver_test.rb
|
74
|
+
- test/test_helper.rb
|